feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Adds the third axis of mesh organization: mesh = trust boundary, group = identity tag, topic = conversation scope. Topic-tagged messages filter delivery by topic_member rows and persist to a topic_message history table for back-scroll on reconnect. Schema (additive): - mesh.topic, mesh.topic_member, mesh.topic_message tables - topic_visibility (public|private|dm) and topic_member_role (lead|member|observer) enums - migration 0022_topics.sql, hand-written following project convention (drizzle journal has been drifting since 0011) Broker: - 10 helpers (createTopic, listTopics, findTopicByName, joinTopic, leaveTopic, topicMembers, getMemberTopicIds, appendTopicMessage, topicHistory, markTopicRead) - drainForMember matches "#<topicId>" target_specs via member's topic memberships - 7 WS handlers (topic_create/list/join/leave/members/history/mark_read) + resolveTopicId helper accepting id-or-name - handleSend auto-persists topic-tagged messages to history CLI: - claudemesh topic create/list/join/leave/members/history/read - claudemesh send "#deploys" "..." resolves topic name to id - bundled skill teaches Claude the DM/group/topic decision matrix - policy-classify recognizes topic create/join/leave as writes Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,11 @@ export class BrokerClient {
|
||||
private grantFileAccessResolvers = new Map<string, { resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
||||
private peerFileResponseResolvers = new Map<string, { resolve: (result: { content?: string; error?: string }) => void; timer: NodeJS.Timeout }>();
|
||||
private peerDirResponseResolvers = new Map<string, { resolve: (result: { entries?: string[]; error?: string }) => void; timer: NodeJS.Timeout }>();
|
||||
// ── Topics (v0.2.0) ──
|
||||
private topicCreatedResolvers = new Map<string, { resolve: (r: { id: string; name: string; created: boolean } | null) => void; timer: NodeJS.Timeout }>();
|
||||
private topicListResolvers = new Map<string, { resolve: (topics: Array<{ id: string; name: string; description: string | null; visibility: "public" | "private" | "dm"; memberCount: number; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||
private topicMembersResolvers = new Map<string, { resolve: (members: Array<{ memberId: string; pubkey: string; displayName: string; role: "lead" | "member" | "observer"; joinedAt: string; lastReadAt: string | null }>) => void; timer: NodeJS.Timeout }>();
|
||||
private topicHistoryResolvers = new Map<string, { resolve: (messages: Array<{ id: string; senderPubkey: string; nonce: string; ciphertext: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||
/** Directories from which this peer serves files. Default: [process.cwd()]. */
|
||||
private sharedDirs: string[] = [process.cwd()];
|
||||
private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = [];
|
||||
@@ -527,6 +532,121 @@ export class BrokerClient {
|
||||
this.ws.send(JSON.stringify({ type: "leave_group", name }));
|
||||
}
|
||||
|
||||
// --- Topics (v0.2.0) ---
|
||||
// Conversation-scope primitive within a mesh. Spec:
|
||||
// .artifacts/specs/2026-05-02-v0.2.0-scope.md
|
||||
|
||||
async topicCreate(args: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility?: "public" | "private" | "dm";
|
||||
}): Promise<{ id: string; name: string; created: boolean } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.topicCreatedResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.topicCreatedResolvers.delete(reqId)) resolve(null);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(
|
||||
JSON.stringify({ type: "topic_create", _reqId: reqId, ...args }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async topicList(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: "public" | "private" | "dm";
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}>
|
||||
> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.topicListResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.topicListResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(JSON.stringify({ type: "topic_list", _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
async topicJoin(topic: string, role?: "lead" | "member" | "observer"): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "topic_join", topic, role }));
|
||||
}
|
||||
|
||||
async topicLeave(topic: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "topic_leave", topic }));
|
||||
}
|
||||
|
||||
async topicMembers(topic: string): Promise<
|
||||
Array<{
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
role: "lead" | "member" | "observer";
|
||||
joinedAt: string;
|
||||
lastReadAt: string | null;
|
||||
}>
|
||||
> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.topicMembersResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.topicMembersResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(
|
||||
JSON.stringify({ type: "topic_members", _reqId: reqId, topic }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async topicHistory(args: {
|
||||
topic: string;
|
||||
limit?: number;
|
||||
beforeId?: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
senderPubkey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
}>
|
||||
> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
this.topicHistoryResolvers.set(reqId, {
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
if (this.topicHistoryResolvers.delete(reqId)) resolve([]);
|
||||
}, 5_000),
|
||||
});
|
||||
this.ws!.send(
|
||||
JSON.stringify({ type: "topic_history", _reqId: reqId, ...args }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async topicMarkRead(topic: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "topic_mark_read", topic }));
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
/** Set a shared state value visible to all peers in the mesh. */
|
||||
@@ -1694,6 +1814,28 @@ export class BrokerClient {
|
||||
this.resolveFromMap(this.listPeersResolvers, msgReqId, peers);
|
||||
return;
|
||||
}
|
||||
// ── Topics (v0.2.0) ──
|
||||
if (msg.type === "topic_created") {
|
||||
const r = (msg.topic ?? {}) as { id: string; name: string };
|
||||
this.resolveFromMap(this.topicCreatedResolvers, msgReqId, {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
created: !!msg.created,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.type === "topic_list_response") {
|
||||
this.resolveFromMap(this.topicListResolvers, msgReqId, (msg.topics as any[]) ?? []);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "topic_members_response") {
|
||||
this.resolveFromMap(this.topicMembersResolvers, msgReqId, (msg.members as any[]) ?? []);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "topic_history_response") {
|
||||
this.resolveFromMap(this.topicHistoryResolvers, msgReqId, (msg.messages as any[]) ?? []);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "push") {
|
||||
this._statsCounters.messagesIn++;
|
||||
const nonce = String(msg.nonce ?? "");
|
||||
|
||||
Reference in New Issue
Block a user