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:
@@ -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(),
|
||||
|
||||
177
apps/cli/src/commands/topic.ts
Normal file
177
apps/cli/src/commands/topic.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user