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:
@@ -42,6 +42,9 @@ import {
|
||||
meshService,
|
||||
meshSkill,
|
||||
meshStream,
|
||||
meshTopic,
|
||||
meshTopicMember,
|
||||
meshTopicMessage,
|
||||
meshVaultEntry,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
@@ -531,6 +534,254 @@ export async function leaveGroup(
|
||||
return groups;
|
||||
}
|
||||
|
||||
// --- Topics (v0.2.0) ---
|
||||
//
|
||||
// Conversational primitive within a mesh. Spec:
|
||||
// .artifacts/specs/2026-05-02-v0.2.0-scope.md
|
||||
//
|
||||
// Mesh = trust boundary. Group = identity tag. Topic = conversation scope.
|
||||
// Three orthogonal axes; topics complement (don't replace) groups.
|
||||
//
|
||||
// Routing: topic-tagged messages use targetSpec = "#<topicId>". The drain
|
||||
// query joins topic_member to filter delivery, so non-members never see
|
||||
// the message. Topic-tagged messages are also persisted to topic_message
|
||||
// so humans (and opting-in agents) can fetch history on reconnect.
|
||||
|
||||
/** Create a topic in a mesh. Idempotent on (meshId, name). */
|
||||
export async function createTopic(args: {
|
||||
meshId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility?: "public" | "private" | "dm";
|
||||
createdByMemberId?: string;
|
||||
}): Promise<{ id: string; created: boolean }> {
|
||||
const existing = await db
|
||||
.select({ id: meshTopic.id })
|
||||
.from(meshTopic)
|
||||
.where(and(eq(meshTopic.meshId, args.meshId), eq(meshTopic.name, args.name)));
|
||||
if (existing[0]) return { id: existing[0].id, created: false };
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshTopic)
|
||||
.values({
|
||||
meshId: args.meshId,
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
visibility: args.visibility ?? "public",
|
||||
createdByMemberId: args.createdByMemberId ?? null,
|
||||
})
|
||||
.returning({ id: meshTopic.id });
|
||||
if (!row) throw new Error("failed to create topic");
|
||||
return { id: row.id, created: true };
|
||||
}
|
||||
|
||||
/** List topics in a mesh, with member counts. */
|
||||
export async function listTopics(meshId: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: "public" | "private" | "dm";
|
||||
memberCount: number;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: meshTopic.id,
|
||||
name: meshTopic.name,
|
||||
description: meshTopic.description,
|
||||
visibility: meshTopic.visibility,
|
||||
createdAt: meshTopic.createdAt,
|
||||
memberCount: sql<number>`(SELECT COUNT(*)::int FROM mesh.topic_member WHERE topic_id = ${meshTopic.id})`,
|
||||
})
|
||||
.from(meshTopic)
|
||||
.where(and(eq(meshTopic.meshId, meshId), isNull(meshTopic.archivedAt)))
|
||||
.orderBy(asc(meshTopic.name));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Resolve a topic by name within a mesh. */
|
||||
export async function findTopicByName(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; visibility: "public" | "private" | "dm" } | null> {
|
||||
const [row] = await db
|
||||
.select({ id: meshTopic.id, visibility: meshTopic.visibility })
|
||||
.from(meshTopic)
|
||||
.where(
|
||||
and(
|
||||
eq(meshTopic.meshId, meshId),
|
||||
eq(meshTopic.name, name),
|
||||
isNull(meshTopic.archivedAt),
|
||||
),
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Add a member to a topic. Idempotent. */
|
||||
export async function joinTopic(args: {
|
||||
topicId: string;
|
||||
memberId: string;
|
||||
role?: "lead" | "member" | "observer";
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.insert(meshTopicMember)
|
||||
.values({
|
||||
topicId: args.topicId,
|
||||
memberId: args.memberId,
|
||||
role: args.role ?? "member",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Remove a member from a topic. */
|
||||
export async function leaveTopic(args: {
|
||||
topicId: string;
|
||||
memberId: string;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.delete(meshTopicMember)
|
||||
.where(
|
||||
and(
|
||||
eq(meshTopicMember.topicId, args.topicId),
|
||||
eq(meshTopicMember.memberId, args.memberId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** List members of a topic with display names. */
|
||||
export async function topicMembers(topicId: string): Promise<
|
||||
Array<{
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
role: "lead" | "member" | "observer";
|
||||
joinedAt: Date;
|
||||
lastReadAt: Date | null;
|
||||
}>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
memberId: meshTopicMember.memberId,
|
||||
pubkey: memberTable.peerPubkey,
|
||||
displayName: memberTable.displayName,
|
||||
role: meshTopicMember.role,
|
||||
joinedAt: meshTopicMember.joinedAt,
|
||||
lastReadAt: meshTopicMember.lastReadAt,
|
||||
})
|
||||
.from(meshTopicMember)
|
||||
.innerJoin(memberTable, eq(meshTopicMember.memberId, memberTable.id))
|
||||
.where(eq(meshTopicMember.topicId, topicId))
|
||||
.orderBy(asc(memberTable.displayName));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Return all topic ids a member belongs to (used by message routing). */
|
||||
export async function getMemberTopicIds(memberId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: meshTopicMember.topicId })
|
||||
.from(meshTopicMember)
|
||||
.where(eq(meshTopicMember.memberId, memberId));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/** Append a topic message to persistent history. */
|
||||
export async function appendTopicMessage(args: {
|
||||
topicId: string;
|
||||
senderMemberId: string;
|
||||
senderSessionPubkey?: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshTopicMessage)
|
||||
.values({
|
||||
topicId: args.topicId,
|
||||
senderMemberId: args.senderMemberId,
|
||||
senderSessionPubkey: args.senderSessionPubkey ?? null,
|
||||
nonce: args.nonce,
|
||||
ciphertext: args.ciphertext,
|
||||
})
|
||||
.returning({ id: meshTopicMessage.id });
|
||||
if (!row) throw new Error("failed to append topic message");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch topic history for a member. Pagination via `before` cursor (id of
|
||||
* an earlier message); pass null for the latest page.
|
||||
*/
|
||||
export async function topicHistory(args: {
|
||||
topicId: string;
|
||||
limit?: number;
|
||||
beforeId?: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
senderMemberId: string;
|
||||
senderPubkey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
|
||||
const beforeClause = args.beforeId
|
||||
? sql`AND tm.created_at < (SELECT created_at FROM mesh.topic_message WHERE id = ${args.beforeId})`
|
||||
: sql``;
|
||||
const result = await db.execute<{
|
||||
id: string;
|
||||
sender_member_id: string;
|
||||
sender_pubkey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
created_at: Date;
|
||||
}>(sql`
|
||||
SELECT tm.id, tm.sender_member_id,
|
||||
COALESCE(tm.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey,
|
||||
tm.nonce, tm.ciphertext, tm.created_at
|
||||
FROM mesh.topic_message tm
|
||||
JOIN mesh.member m ON m.id = tm.sender_member_id
|
||||
WHERE tm.topic_id = ${args.topicId}
|
||||
${beforeClause}
|
||||
ORDER BY tm.created_at DESC, tm.id DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
const rows = (result.rows ?? result) as Array<{
|
||||
id: string;
|
||||
sender_member_id: string;
|
||||
sender_pubkey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
senderMemberId: r.sender_member_id,
|
||||
senderPubkey: r.sender_pubkey,
|
||||
nonce: r.nonce,
|
||||
ciphertext: r.ciphertext,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Update last_read_at for a member's topic subscription. */
|
||||
export async function markTopicRead(args: {
|
||||
topicId: string;
|
||||
memberId: string;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.update(meshTopicMember)
|
||||
.set({ lastReadAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(meshTopicMember.topicId, args.topicId),
|
||||
eq(meshTopicMember.memberId, args.memberId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared state ---
|
||||
|
||||
/**
|
||||
@@ -1563,7 +1814,7 @@ function deliverablePriorities(status: PeerStatus): Priority[] {
|
||||
*/
|
||||
export async function drainForMember(
|
||||
meshId: string,
|
||||
_memberId: string,
|
||||
memberId: string,
|
||||
memberPubkey: string,
|
||||
status: PeerStatus,
|
||||
sessionPubkey?: string,
|
||||
@@ -1615,6 +1866,17 @@ export async function drainForMember(
|
||||
groupTargets.map((t) => `'${t}'`).join(","),
|
||||
);
|
||||
|
||||
// Topic membership targets (v0.2.0). targetSpec for topic-tagged
|
||||
// messages is "#<topicId>". A member receives a topic message iff
|
||||
// they're in topic_member for that topic. We resolve memberships
|
||||
// here and inline the list — same pattern as groups, no schema join
|
||||
// in the hot path.
|
||||
const topicIds = await getMemberTopicIds(memberId);
|
||||
const topicTargetList =
|
||||
topicIds.length > 0
|
||||
? sql.raw(topicIds.map((id) => `'#${id}'`).join(","))
|
||||
: null;
|
||||
|
||||
// Atomic claim with SQL-side ordering. The CTE claims rows via
|
||||
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
|
||||
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
|
||||
@@ -1638,7 +1900,7 @@ export async function drainForMember(
|
||||
WHERE mesh_id = ${meshId}
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList})${topicTargetList ? sql` OR target_spec IN (${topicTargetList})` : sql``})
|
||||
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
|
||||
@@ -18,7 +18,7 @@ import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
import { env } from "./env";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember, messageQueue, presence, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||
import { invite as inviteTable, mesh, meshMember, messageQueue, presence, scheduledMessage as scheduledMessageTable, meshWebhook, peerState, meshTopic } from "@turbostarter/db/schema/mesh";
|
||||
import { user } from "@turbostarter/db/schema/auth";
|
||||
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
@@ -84,6 +84,15 @@ import {
|
||||
listDbMeshServices,
|
||||
deleteService,
|
||||
getRunningServices,
|
||||
createTopic,
|
||||
listTopics,
|
||||
findTopicByName,
|
||||
joinTopic,
|
||||
leaveTopic,
|
||||
topicMembers,
|
||||
topicHistory,
|
||||
markTopicRead,
|
||||
appendTopicMessage,
|
||||
} from "./broker";
|
||||
import * as serviceManager from "./service-manager";
|
||||
import { ensureBucket, meshBucketName, minioClient } from "./minio";
|
||||
@@ -1495,6 +1504,26 @@ function sendError(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a topic identifier — accepts either a topic id directly OR a
|
||||
* topic name within the given mesh. Returns the topic id, or null if no
|
||||
* matching topic exists. Used by every topic_* WS handler so callers can
|
||||
* reference topics by human-readable name without an extra round trip.
|
||||
*/
|
||||
async function resolveTopicId(meshId: string, idOrName: string): Promise<string | null> {
|
||||
// ULID-ish ids are 25-26 chars of base32; names are usually shorter and
|
||||
// human-readable. Try as id first (cheap PK lookup), fall back to name.
|
||||
if (idOrName.length >= 20 && /^[a-z0-9_-]+$/i.test(idOrName)) {
|
||||
const byId = await db
|
||||
.select({ id: meshTopic.id })
|
||||
.from(meshTopic)
|
||||
.where(and(eq(meshTopic.id, idOrName), eq(meshTopic.meshId, meshId)));
|
||||
if (byId[0]) return byId[0].id;
|
||||
}
|
||||
const byName = await findTopicByName(meshId, idOrName);
|
||||
return byName?.id ?? null;
|
||||
}
|
||||
|
||||
// --- Peer state persistence ---
|
||||
|
||||
async function savePeerState(conn: PeerConn, memberId: string, meshId: string): Promise<void> {
|
||||
@@ -1901,6 +1930,24 @@ async function handleSend(
|
||||
nonce: msg.nonce,
|
||||
ciphertext: msg.ciphertext,
|
||||
});
|
||||
|
||||
// Topic-tagged messages (targetSpec starts with `#<topicId>`) get
|
||||
// persisted to topic_message in addition to the ephemeral queue, so
|
||||
// humans (and opting-in agents) can fetch history on reconnect.
|
||||
// Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
|
||||
if (msg.targetSpec.startsWith("#")) {
|
||||
const topicId = msg.targetSpec.slice(1);
|
||||
void appendTopicMessage({
|
||||
topicId,
|
||||
senderMemberId: conn.memberId,
|
||||
senderSessionPubkey: conn.sessionPubkey ?? undefined,
|
||||
nonce: msg.nonce,
|
||||
ciphertext: msg.ciphertext,
|
||||
}).catch((e) =>
|
||||
log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) }),
|
||||
);
|
||||
}
|
||||
|
||||
void audit(conn.meshId, "message_sent", conn.memberId, conn.displayName, {
|
||||
targetSpec: msg.targetSpec,
|
||||
priority: msg.priority,
|
||||
@@ -2291,6 +2338,125 @@ function handleConnection(ws: WebSocket): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Topics (v0.2.0) ─────────────────────────────────────────
|
||||
case "topic_create": {
|
||||
const tc = msg as Extract<WSClientMessage, { type: "topic_create" }>;
|
||||
const result = await createTopic({
|
||||
meshId: conn.meshId,
|
||||
name: tc.name,
|
||||
description: tc.description,
|
||||
visibility: tc.visibility,
|
||||
createdByMemberId: conn.memberId,
|
||||
});
|
||||
// Auto-subscribe the creator.
|
||||
await joinTopic({ topicId: result.id, memberId: conn.memberId, role: "lead" });
|
||||
const resp: WSServerMessage = {
|
||||
type: "topic_created",
|
||||
topic: {
|
||||
id: result.id,
|
||||
name: tc.name,
|
||||
visibility: tc.visibility ?? "public",
|
||||
},
|
||||
created: result.created,
|
||||
...(_reqId ? { _reqId } : {}),
|
||||
};
|
||||
conn.ws.send(JSON.stringify(resp));
|
||||
log.info("ws topic_create", { presence_id: presenceId, topic: tc.name, created: result.created });
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_list": {
|
||||
const topics = await listTopics(conn.meshId);
|
||||
const resp: WSServerMessage = {
|
||||
type: "topic_list_response",
|
||||
topics: topics.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
visibility: t.visibility,
|
||||
memberCount: t.memberCount,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
...(_reqId ? { _reqId } : {}),
|
||||
};
|
||||
conn.ws.send(JSON.stringify(resp));
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_join": {
|
||||
const tj = msg as Extract<WSClientMessage, { type: "topic_join" }>;
|
||||
const topicId = await resolveTopicId(conn.meshId, tj.topic);
|
||||
if (!topicId) { sendError(ws, "topic_not_found", `topic "${tj.topic}" not found`, _reqId); break; }
|
||||
await joinTopic({ topicId, memberId: conn.memberId, role: tj.role });
|
||||
log.info("ws topic_join", { presence_id: presenceId, topic: topicId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_leave": {
|
||||
const tl = msg as Extract<WSClientMessage, { type: "topic_leave" }>;
|
||||
const topicId = await resolveTopicId(conn.meshId, tl.topic);
|
||||
if (!topicId) { sendError(ws, "topic_not_found", `topic "${tl.topic}" not found`, _reqId); break; }
|
||||
await leaveTopic({ topicId, memberId: conn.memberId });
|
||||
log.info("ws topic_leave", { presence_id: presenceId, topic: topicId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_members": {
|
||||
const tm = msg as Extract<WSClientMessage, { type: "topic_members" }>;
|
||||
const topicId = await resolveTopicId(conn.meshId, tm.topic);
|
||||
if (!topicId) { sendError(ws, "topic_not_found", `topic "${tm.topic}" not found`, _reqId); break; }
|
||||
const members = await topicMembers(topicId);
|
||||
const resp: WSServerMessage = {
|
||||
type: "topic_members_response",
|
||||
topic: tm.topic,
|
||||
members: members.map((m) => ({
|
||||
memberId: m.memberId,
|
||||
pubkey: m.pubkey,
|
||||
displayName: m.displayName,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
lastReadAt: m.lastReadAt?.toISOString() ?? null,
|
||||
})),
|
||||
...(_reqId ? { _reqId } : {}),
|
||||
};
|
||||
conn.ws.send(JSON.stringify(resp));
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_history": {
|
||||
const th = msg as Extract<WSClientMessage, { type: "topic_history" }>;
|
||||
const topicId = await resolveTopicId(conn.meshId, th.topic);
|
||||
if (!topicId) { sendError(ws, "topic_not_found", `topic "${th.topic}" not found`, _reqId); break; }
|
||||
const history = await topicHistory({
|
||||
topicId,
|
||||
limit: th.limit,
|
||||
beforeId: th.beforeId,
|
||||
});
|
||||
const resp: WSServerMessage = {
|
||||
type: "topic_history_response",
|
||||
topic: th.topic,
|
||||
messages: history.map((h) => ({
|
||||
id: h.id,
|
||||
senderPubkey: h.senderPubkey,
|
||||
nonce: h.nonce,
|
||||
ciphertext: h.ciphertext,
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
})),
|
||||
...(_reqId ? { _reqId } : {}),
|
||||
};
|
||||
conn.ws.send(JSON.stringify(resp));
|
||||
break;
|
||||
}
|
||||
|
||||
case "topic_mark_read": {
|
||||
const tr = msg as Extract<WSClientMessage, { type: "topic_mark_read" }>;
|
||||
const topicId = await resolveTopicId(conn.meshId, tr.topic);
|
||||
if (!topicId) { sendError(ws, "topic_not_found", `topic "${tr.topic}" not found`, _reqId); break; }
|
||||
await markTopicRead({ topicId, memberId: conn.memberId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "set_state": {
|
||||
const ss = msg as Extract<WSClientMessage, { type: "set_state" }>;
|
||||
// Look up the display name for attribution.
|
||||
|
||||
@@ -179,6 +179,107 @@ export interface WSLeaveGroupMessage {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── Topics (v0.2.0) ─────────────────────────────────────────────────
|
||||
// Topics complement groups: groups are identity tags, topics are
|
||||
// conversation scopes. targetSpec for topic-tagged messages is
|
||||
// "#<topicId>". Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
|
||||
|
||||
export interface WSTopicCreateMessage {
|
||||
type: "topic_create";
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility?: "public" | "private" | "dm";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicListMessage {
|
||||
type: "topic_list";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicJoinMessage {
|
||||
type: "topic_join";
|
||||
/** Topic id OR name. Server resolves. */
|
||||
topic: string;
|
||||
role?: "lead" | "member" | "observer";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicLeaveMessage {
|
||||
type: "topic_leave";
|
||||
topic: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicMembersMessage {
|
||||
type: "topic_members";
|
||||
topic: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicHistoryMessage {
|
||||
type: "topic_history";
|
||||
topic: string;
|
||||
limit?: number;
|
||||
beforeId?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicMarkReadMessage {
|
||||
type: "topic_mark_read";
|
||||
topic: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// Server → client topic responses
|
||||
|
||||
export interface WSTopicCreatedMessage {
|
||||
type: "topic_created";
|
||||
topic: { id: string; name: string; visibility: "public" | "private" | "dm" };
|
||||
created: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicListResponseMessage {
|
||||
type: "topic_list_response";
|
||||
topics: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: "public" | "private" | "dm";
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicMembersResponseMessage {
|
||||
type: "topic_members_response";
|
||||
topic: string;
|
||||
members: Array<{
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
role: "lead" | "member" | "observer";
|
||||
joinedAt: string;
|
||||
lastReadAt: string | null;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
export interface WSTopicHistoryResponseMessage {
|
||||
type: "topic_history_response";
|
||||
topic: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
senderPubkey: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: set a shared state key-value. */
|
||||
export interface WSSetStateMessage {
|
||||
type: "set_state";
|
||||
@@ -1145,6 +1246,13 @@ export type WSClientMessage =
|
||||
| WSSetProfileMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage
|
||||
| WSTopicCreateMessage
|
||||
| WSTopicListMessage
|
||||
| WSTopicJoinMessage
|
||||
| WSTopicLeaveMessage
|
||||
| WSTopicMembersMessage
|
||||
| WSTopicHistoryMessage
|
||||
| WSTopicMarkReadMessage
|
||||
| WSSetStateMessage
|
||||
| WSGetStateMessage
|
||||
| WSListStateMessage
|
||||
@@ -1313,6 +1421,10 @@ export type WSServerMessage =
|
||||
| WSPushMessage
|
||||
| WSAckMessage
|
||||
| WSPeersListMessage
|
||||
| WSTopicCreatedMessage
|
||||
| WSTopicListResponseMessage
|
||||
| WSTopicMembersResponseMessage
|
||||
| WSTopicHistoryResponseMessage
|
||||
| WSStateChangeMessage
|
||||
| WSStateResultMessage
|
||||
| WSStateListMessage
|
||||
|
||||
@@ -36,6 +36,31 @@ Every broker-touching verb runs through a policy gate before dispatch. The defau
|
||||
|
||||
**Convention:** every operation is `claudemesh <resource> <verb>`. Legacy short forms (`send`, `peers`, `kick`, `remember`, ...) are aliases that keep working forever; prefer the resource form for new code.
|
||||
|
||||
### `topic` — conversation scope within a mesh (v0.2.0)
|
||||
|
||||
A topic is a named conversation inside a mesh. Mesh = trust boundary. Group = identity tag. **Topic = what you're talking about.** Subscribers receive topic-tagged messages; non-subscribers don't. Topics also persist message history so humans (and opting-in agents) can fetch back-scroll on reconnect.
|
||||
|
||||
```bash
|
||||
claudemesh topic create deploys --description "deploy + on-call"
|
||||
claudemesh topic create incident-2026-05-02 --visibility private
|
||||
claudemesh topic list # all topics in mesh
|
||||
claudemesh topic join deploys # subscribe (by name or id)
|
||||
claudemesh topic join deploys --role lead # join as lead
|
||||
claudemesh topic leave deploys
|
||||
claudemesh topic members deploys # list subscribers
|
||||
claudemesh topic history deploys --limit 50 # fetch back-scroll
|
||||
claudemesh topic history deploys --before <msg-id> # paginate older
|
||||
claudemesh topic read deploys # mark all as read
|
||||
|
||||
# Send to a topic — same `send` verb, target starts with #
|
||||
claudemesh send "#deploys" "rolling out 1.5.1 to staging"
|
||||
```
|
||||
|
||||
When to use topics vs groups vs DM:
|
||||
- **DM** (`send <peer>`) — 1:1, ephemeral.
|
||||
- **Group** (`send "@frontend"`) — addresses everyone in a group; ephemeral; for coordinating teams.
|
||||
- **Topic** (`send "#deploys"`) — durable conversation room; for ongoing work threads, incident channels, build-status feeds.
|
||||
|
||||
### `peer` — read connected peers + admin (kick / ban / verify)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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