feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-05-02 01:53:42 +01:00
parent b4f457fceb
commit 1afae7a507
12 changed files with 1741 additions and 196 deletions

View File

@@ -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 ?? "");