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

@@ -85,6 +85,13 @@ export function classifyInvocation(command: string, positionals: string[]): Invo
case "task": {
return { resource: "task", verb: sub || "list", isWrite: isWrite(sub) };
}
case "topic": {
// topic verbs: create | list | join | leave | members | history | read
// writes: create, join, leave; reads: list, members, history, read
const verb = sub || "list";
const writeVerbs = new Set(["create", "join", "leave"]);
return { resource: "topic", verb, isWrite: writeVerbs.has(verb) };
}
// Platform — sub is the verb.
case "vector": case "graph": case "context": case "stream":

View File

@@ -69,7 +69,19 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
// Cold path
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) {
// Topic by name → resolve to "#<topicId>" via topicList. The broker
// wire format is "#<topicId>"; users type "#<name>" for ergonomics.
const name = to.slice(1);
const topics = await client.topicList();
const match = topics.find((t) => t.name === name);
if (!match) {
const names = topics.map((t) => "#" + t.name).join(", ");
render.err(`Topic "${to}" not found.`, `topics: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = "#" + match.id;
} else if (!to.startsWith("@") && !to.startsWith("#") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
const peers = await client.listPeers();
const match = peers.find(
(p) => p.displayName.toLowerCase() === to.toLowerCase(),

View File

@@ -0,0 +1,177 @@
/**
* `claudemesh topic <verb>` — conversation-scope primitive within a mesh.
*
* Topics complement groups: groups are identity tags ("@frontend"); topics
* are conversation scopes ("#deploys") with persistent history,
* subscription-based delivery, and per-topic state.
*
* Verbs:
* create <name> [--description X] [--visibility public|private|dm]
* list
* join <topic> [--role lead|member|observer]
* leave <topic>
* members <topic>
* history <topic> [--limit N] [--before <id>]
* read <topic> (mark all as read)
*
* Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim, green } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export interface TopicFlags {
mesh?: string;
json?: boolean;
description?: string;
visibility?: "public" | "private" | "dm";
role?: "lead" | "member" | "observer";
limit?: number | string;
before?: string;
}
export async function runTopicCreate(name: string, flags: TopicFlags): Promise<number> {
if (!name) {
render.err("Usage: claudemesh topic create <name> [--description X] [--visibility V]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const result = await client.topicCreate({
name,
description: flags.description,
visibility: flags.visibility,
});
if (!result) {
render.err("topic create failed");
return EXIT.INTERNAL_ERROR;
}
if (flags.json) {
console.log(JSON.stringify(result));
return EXIT.SUCCESS;
}
if (result.created) {
render.ok("created", `${clay("#" + name)} ${dim(result.id.slice(0, 8))}`);
} else {
render.info(dim(`already exists: #${name} ${result.id.slice(0, 8)}`));
}
return EXIT.SUCCESS;
});
}
export async function runTopicList(flags: TopicFlags): Promise<number> {
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const topics = await client.topicList();
if (flags.json) {
console.log(JSON.stringify(topics, null, 2));
return EXIT.SUCCESS;
}
if (topics.length === 0) {
render.info(dim("no topics in this mesh."));
return EXIT.SUCCESS;
}
render.section(`topics (${topics.length})`);
for (const t of topics) {
const vis = t.visibility === "public" ? green(t.visibility) : dim(t.visibility);
process.stdout.write(` ${clay("#" + t.name)} ${vis} ${dim(`${t.memberCount} member${t.memberCount === 1 ? "" : "s"}`)}\n`);
if (t.description) process.stdout.write(` ${dim(t.description)}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runTopicJoin(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic join <topic> [--role lead|member|observer]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicJoin(topic, flags.role);
if (flags.json) console.log(JSON.stringify({ joined: topic }));
else render.ok("joined", clay("#" + topic));
return EXIT.SUCCESS;
});
}
export async function runTopicLeave(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic leave <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicLeave(topic);
if (flags.json) console.log(JSON.stringify({ left: topic }));
else render.ok("left", clay("#" + topic));
return EXIT.SUCCESS;
});
}
export async function runTopicMembers(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic members <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const members = await client.topicMembers(topic);
if (flags.json) {
console.log(JSON.stringify(members, null, 2));
return EXIT.SUCCESS;
}
if (members.length === 0) {
render.info(dim(`no members in ${clay("#" + topic)}.`));
return EXIT.SUCCESS;
}
render.section(`${clay("#" + topic)} members (${members.length})`);
for (const m of members) {
process.stdout.write(` ${bold(m.displayName)} ${dim(m.role)} ${dim(m.pubkey.slice(0, 8))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runTopicHistory(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic history <topic> [--limit N] [--before <id>]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const limit = flags.limit ? Number(flags.limit) : undefined;
const messages = await client.topicHistory({
topic,
limit,
beforeId: flags.before,
});
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return EXIT.SUCCESS;
}
if (messages.length === 0) {
render.info(dim(`no messages in ${clay("#" + topic)}.`));
return EXIT.SUCCESS;
}
// History returns newest-first; render oldest-first for chat UX.
const ordered = [...messages].reverse();
render.section(`${clay("#" + topic)} history (${ordered.length})`);
for (const m of ordered) {
const t = new Date(m.createdAt).toLocaleString();
process.stdout.write(
` ${dim(t)} ${bold(m.senderPubkey.slice(0, 8))} ${dim("(encrypted, " + m.ciphertext.length + "b)")}\n`,
);
}
return EXIT.SUCCESS;
});
}
export async function runTopicMarkRead(topic: string, flags: TopicFlags): Promise<number> {
if (!topic) {
render.err("Usage: claudemesh topic read <topic>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.topicMarkRead(topic);
if (flags.json) console.log(JSON.stringify({ read: topic }));
else render.ok("marked read", clay("#" + topic));
return EXIT.SUCCESS;
});
}

View File

@@ -102,6 +102,16 @@ Profile / presence (resource form)
claudemesh group join @<name> join a group (--role X)
claudemesh group leave @<name> leave a group
Topic (conversation scope, v0.2.0)
claudemesh topic create <name> create a topic [--description --visibility]
claudemesh topic list list topics in the mesh
claudemesh topic join <topic> subscribe (via name or id)
claudemesh topic leave <topic> unsubscribe
claudemesh topic members <t> list topic subscribers
claudemesh topic history <t> fetch message history [--limit --before]
claudemesh topic read <topic> mark all as read
claudemesh send "#topic" "msg" send to a topic
Schedule (resource form)
claudemesh schedule msg <m> one-shot or recurring (alias: remind)
claudemesh schedule list list pending
@@ -499,6 +509,30 @@ async function main(): Promise<void> {
break;
}
// topic — conversational primitive within a mesh (v0.2.0)
case "topic": {
const sub = positionals[0];
const f = {
mesh: flags.mesh as string,
json: !!flags.json,
description: flags.description as string,
visibility: flags.visibility as "public" | "private" | "dm" | undefined,
role: flags.role as "lead" | "member" | "observer" | undefined,
limit: flags.limit as string | undefined,
before: flags.before as string | undefined,
};
const arg = positionals[1] ?? "";
if (sub === "create") { const { runTopicCreate } = await import("~/commands/topic.js"); process.exit(await runTopicCreate(arg, f)); }
else if (sub === "list") { const { runTopicList } = await import("~/commands/topic.js"); process.exit(await runTopicList(f)); }
else if (sub === "join") { const { runTopicJoin } = await import("~/commands/topic.js"); process.exit(await runTopicJoin(arg, f)); }
else if (sub === "leave") { const { runTopicLeave } = await import("~/commands/topic.js"); process.exit(await runTopicLeave(arg, f)); }
else if (sub === "members") { const { runTopicMembers } = await import("~/commands/topic.js"); process.exit(await runTopicMembers(arg, f)); }
else if (sub === "history") { const { runTopicHistory } = await import("~/commands/topic.js"); process.exit(await runTopicHistory(arg, f)); }
else if (sub === "read") { const { runTopicMarkRead } = await import("~/commands/topic.js"); process.exit(await runTopicMarkRead(arg, f)); }
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read>"); process.exit(EXIT.INVALID_ARGS); }
break;
}
// task — extends broker-actions.ts (claim/complete) with list/create
case "task": {
const sub = positionals[0];

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