diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 4f25113..a29237d 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -838,6 +838,13 @@ export async function appendTopicMessage(args: { senderSessionPubkey?: string; nonce: string; ciphertext: string; + bodyVersion?: number; + /** + * Optional id of the parent topic message this one replies to. Server + * verifies the parent exists and lives in the same topic; otherwise + * silently drops the reference (treated as a top-level post). + */ + replyToId?: string; /** * Optional client-extracted mention list (lowercased display names * without the leading @). Required once per-topic encryption lands — @@ -846,6 +853,17 @@ export async function appendTopicMessage(args: { */ mentions?: string[]; }): Promise { + let validatedReplyTo: string | null = null; + if (args.replyToId) { + const [parent] = await db + .select({ id: meshTopicMessage.id, topicId: meshTopicMessage.topicId }) + .from(meshTopicMessage) + .where(eq(meshTopicMessage.id, args.replyToId)); + if (parent && parent.topicId === args.topicId) { + validatedReplyTo = parent.id; + } + } + const [row] = await db .insert(meshTopicMessage) .values({ @@ -854,6 +872,8 @@ export async function appendTopicMessage(args: { senderSessionPubkey: args.senderSessionPubkey ?? null, nonce: args.nonce, ciphertext: args.ciphertext, + bodyVersion: args.bodyVersion ?? 1, + replyToId: validatedReplyTo, }) .returning({ id: meshTopicMessage.id }); if (!row) throw new Error("failed to append topic message"); @@ -958,8 +978,11 @@ export async function topicHistory(args: { id: string; senderMemberId: string; senderPubkey: string; + senderName: string; nonce: string; ciphertext: string; + bodyVersion: number; + replyToId: string | null; createdAt: Date; }> > { @@ -971,13 +994,18 @@ export async function topicHistory(args: { id: string; sender_member_id: string; sender_pubkey: string; + sender_name: string; nonce: string; ciphertext: string; + body_version: number; + reply_to_id: string | null; 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 + m.display_name AS sender_name, + tm.nonce, tm.ciphertext, tm.body_version, tm.reply_to_id, + 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} @@ -989,16 +1017,22 @@ export async function topicHistory(args: { id: string; sender_member_id: string; sender_pubkey: string; + sender_name: string; nonce: string; ciphertext: string; + body_version: number; + reply_to_id: string | null; created_at: Date; }>; return rows.map((r) => ({ id: r.id, senderMemberId: r.sender_member_id, senderPubkey: r.sender_pubkey, + senderName: r.sender_name, nonce: r.nonce, ciphertext: r.ciphertext, + bodyVersion: r.body_version ?? 1, + replyToId: r.reply_to_id, createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at), })); } diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index b656620..6ee5ca0 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -1944,18 +1944,23 @@ async function handleSend( // 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 + let persistedTopicMessageId: string | null = null; 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, - mentions: msg.mentions, - }).catch((e) => - log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) }), - ); + try { + persistedTopicMessageId = await appendTopicMessage({ + topicId, + senderMemberId: conn.memberId, + senderSessionPubkey: conn.sessionPubkey ?? undefined, + nonce: msg.nonce, + ciphertext: msg.ciphertext, + bodyVersion: msg.bodyVersion ?? 1, + replyToId: msg.replyToId, + mentions: msg.mentions, + }); + } catch (e) { + log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) }); + } } void audit(conn.meshId, "message_sent", conn.memberId, conn.displayName, { @@ -1987,15 +1992,29 @@ async function handleSend( const isMulticast = isBroadcast || !!groupName; // Build the push envelope once (reused for all recipients). + const isTopicTarget = msg.targetSpec.startsWith("#"); + let topicName: string | undefined; + if (isTopicTarget) { + const topicId = msg.targetSpec.slice(1); + const [topicRow] = await db + .select({ name: meshTopic.name }) + .from(meshTopic) + .where(eq(meshTopic.id, topicId)); + if (topicRow) topicName = topicRow.name; + } const pushEnvelope: WSPushMessage = { type: "push", - messageId, + messageId: persistedTopicMessageId ?? messageId, meshId: conn.meshId, senderPubkey: conn.sessionPubkey ?? conn.memberPubkey, + senderMemberId: conn.memberId, + senderName: conn.displayName, priority: msg.priority, nonce: msg.nonce, ciphertext: msg.ciphertext, createdAt: new Date().toISOString(), + ...(topicName ? { topic: topicName } : {}), + ...(msg.replyToId ? { replyToId: msg.replyToId } : {}), ...(subtype ? { subtype } : {}), }; @@ -2449,8 +2468,12 @@ function handleConnection(ws: WebSocket): void { messages: history.map((h) => ({ id: h.id, senderPubkey: h.senderPubkey, + senderMemberId: h.senderMemberId, + senderName: h.senderName, nonce: h.nonce, ciphertext: h.ciphertext, + bodyVersion: h.bodyVersion, + ...(h.replyToId ? { replyToId: h.replyToId } : {}), createdAt: h.createdAt.toISOString(), })), ...(_reqId ? { _reqId } : {}), diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index 321eb1b..ad3428f 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -106,6 +106,10 @@ export interface WSSendMessage { * the body when this is absent. */ mentions?: string[]; + /** Optional id of a previous topic message this one replies to. + * Server validates same-topic membership; FK is set null if parent + * later disappears. Ignored for non-topic targets. */ + replyToId?: string; } /** Broker → client: an envelope addressed to this peer. */ @@ -114,6 +118,17 @@ export interface WSPushMessage { messageId: string; meshId: string; senderPubkey: string; + /** Stable mesh.member id of the sender — survives display-name changes, + * use this as the canonical reply target when set. Optional for + * legacy/non-topic broker paths that haven't been wired yet. */ + senderMemberId?: string; + /** Sender's current display name as a convenience for renderers. */ + senderName?: string; + /** Topic name when the push originates from a topic post (vs DM). */ + topic?: string; + /** Server-side message id of the parent message when this push is a + * reply, so the recipient can render thread context and re-thread. */ + replyToId?: string; priority: Priority; nonce: string; ciphertext: string; @@ -345,8 +360,12 @@ export interface WSTopicHistoryResponseMessage { messages: Array<{ id: string; senderPubkey: string; + senderMemberId?: string; + senderName?: string; nonce: string; ciphertext: string; + bodyVersion?: number; + replyToId?: string | null; createdAt: string; }>; _reqId?: string; diff --git a/apps/cli/package.json b/apps/cli/package.json index 2f32510..3edb5fb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.8.0", + "version": "1.9.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/skills/claudemesh/SKILL.md b/apps/cli/skills/claudemesh/SKILL.md index 6506dd8..68f09c1 100644 --- a/apps/cli/skills/claudemesh/SKILL.md +++ b/apps/cli/skills/claudemesh/SKILL.md @@ -13,6 +13,34 @@ description: Use when the user asks to send a message to a peer Claude session, Respond IMMEDIATELY. Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it. +### Channel attributes (everything you need to reply is in the push) + +The `` interrupt carries these attributes — no lookup needed: + +| Attribute | What it is | +|---|---| +| `from_name` | Sender's display name. **Use as `to` in your reply** for DMs. | +| `from_pubkey` | Sender's session pubkey (hex). Stable per-session. | +| `from_member_id` | Sender's stable mesh.member id. Survives display-name changes — the canonical id. | +| `mesh_slug` | Mesh the message arrived on. Pass via `--mesh ` if the parent isn't on the same mesh. | +| `priority` | `now` / `next` / `low`. | +| `message_id` | Server-side id of THIS message. **Pass to `--reply-to ` to thread your reply** in topic posts. | +| `topic` | Set when the source is a topic post. Reply via `topic post --reply-to `. | +| `reply_to_id` | Set when the message itself is a reply to a previous one — render thread context. | + +**Reply patterns:** + +```bash +# DM → use from_name as the target +claudemesh send "" "ack — looking now" + +# Topic reply → thread it onto the message you got +claudemesh topic post "" "yep, looks good" --reply-to + +# When the sender is on a different mesh you've joined +claudemesh send "" "..." --mesh "" +``` + ## Performance model (warm vs cold path) If the parent Claude session was launched via `claudemesh launch`, an MCP push-pipe is running and holds the per-mesh WS connection. CLI invocations dial `~/.claudemesh/sockets/.sock` and reuse that warm connection (~200ms total round-trip including Node.js startup). If no push-pipe is running (cron, scripts, hooks fired outside a session), the CLI opens its own WS, which takes ~500-700ms cold. **You don't manage this** — every verb auto-detects and falls through. @@ -62,8 +90,14 @@ claudemesh topic tail deploys --limit 50 # v1.8.0+: encrypted REST send (body_version 2). Falls back to v1 # automatically for legacy unencrypted topics. --plaintext forces v1. claudemesh topic post deploys "rolling out, cc @Alexis stay around" + +# v1.9.0+: thread a reply onto a previous topic message. Accepts the +# full id or an 8+ char prefix; resolved against recent history. +claudemesh topic post deploys "yes — same here" --reply-to 7XtIeF7o ``` +In `topic tail` output, replies render with a `↳ in reply to : ""` line above the message and every row shows a short id tag (`#xxxxxxxx`) so you can copy-paste into `--reply-to`. + When to use topics vs groups vs DM: - **DM** (`send `) — 1:1, ephemeral. - **Group** (`send "@frontend"`) — addresses everyone in a group; ephemeral; for coordinating teams. diff --git a/apps/cli/src/commands/topic-post.ts b/apps/cli/src/commands/topic-post.ts index 60db813..1eb9a01 100644 --- a/apps/cli/src/commands/topic-post.ts +++ b/apps/cli/src/commands/topic-post.ts @@ -29,6 +29,8 @@ export interface TopicPostFlags { json?: boolean; /** Force v1 plaintext send even if the topic is encrypted. */ plaintext?: boolean; + /** Reply-to message id (full or 8+ char prefix). */ + replyTo?: string; } interface PostResponse { @@ -37,6 +39,7 @@ interface PostResponse { topic: string; topicId: string; notifications: number; + replyToId?: string | null; } export async function runTopicPost( @@ -101,6 +104,36 @@ export async function runTopicPost( } } + // Resolve reply-to: accept full id or 8+ char prefix by querying recent + // history once and matching. Server validates same-topic membership. + let replyToId: string | undefined; + if (flags.replyTo) { + if (flags.replyTo.length >= 16) { + replyToId = flags.replyTo; + } else if (flags.replyTo.length >= 6) { + const recent = await request<{ + messages: Array<{ id: string }>; + }>({ + path: `/api/v1/topics/${encodeURIComponent(cleanName)}/messages?limit=200`, + method: "GET", + token: secret, + }); + const hit = recent.messages?.find((r) => + r.id.startsWith(flags.replyTo!), + ); + if (!hit) { + render.err( + `--reply-to ${flags.replyTo}: no recent message id starts with that prefix`, + ); + return EXIT.INVALID_ARGS; + } + replyToId = hit.id; + } else { + render.err("--reply-to needs at least 6 characters of the message id"); + return EXIT.INVALID_ARGS; + } + } + const result = await request({ path: "/api/v1/messages", method: "POST", @@ -111,6 +144,7 @@ export async function runTopicPost( nonce, bodyVersion, ...(mentions.length > 0 ? { mentions } : {}), + ...(replyToId ? { replyToId } : {}), }, }); @@ -120,9 +154,12 @@ export async function runTopicPost( } const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1"); + const replyTag = result.replyToId + ? ` ${dim("↳ " + result.replyToId.slice(0, 8))}` + : ""; render.ok( "posted", - `${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`, + `${clay("#" + cleanName)} ${versionTag}${replyTag} ${dim(`(${result.notifications} mentions)`)}`, ); return EXIT.SUCCESS; }, diff --git a/apps/cli/src/commands/topic-tail.ts b/apps/cli/src/commands/topic-tail.ts index 8ce8239..166f32b 100644 --- a/apps/cli/src/commands/topic-tail.ts +++ b/apps/cli/src/commands/topic-tail.ts @@ -27,14 +27,34 @@ export interface TopicTailFlags { interface TopicMessage { id: string; + senderMemberId?: string; senderPubkey: string; senderName: string; nonce: string; ciphertext: string; bodyVersion?: number; + replyToId?: string | null; createdAt: string; } +/** Bounded recent-message cache used to render reply-context lines. */ +type RenderedSnippet = { name: string; snippet: string }; +const RECENT_CACHE_MAX = 256; +function rememberRendered( + cache: Map, + m: TopicMessage, + text: string, +): void { + cache.set(m.id, { + name: m.senderName || m.senderPubkey.slice(0, 8), + snippet: text.replace(/\s+/g, " ").slice(0, 60), + }); + if (cache.size > RECENT_CACHE_MAX) { + const firstKey = cache.keys().next().value; + if (firstKey) cache.delete(firstKey); + } +} + interface HistoryResponse { topic: string; topicId: string; @@ -79,16 +99,27 @@ async function printMessage( m: TopicMessage, topicKey: Uint8Array | null, json: boolean, + cache: Map, ): Promise { const text = await decryptForRender(m, topicKey); if (json) { console.log(JSON.stringify({ ...m, message: text })); + rememberRendered(cache, m, text); return; } const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : ""; + if (m.replyToId) { + const parent = cache.get(m.replyToId); + const ref = parent + ? `${parent.name}: "${parent.snippet}${parent.snippet.length === 60 ? "…" : ""}"` + : `${m.replyToId.slice(0, 8)}…`; + process.stdout.write(` ${dim("↳ in reply to " + ref)}\n`); + } + const idTag = dim(`#${m.id.slice(0, 8)}`); process.stdout.write( - ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`, + ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${idTag} ${v2Marker}${text}\n`, ); + rememberRendered(cache, m, text); } interface SseEvent { @@ -153,6 +184,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise topicName: cleanName, }); const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null; + const snippetCache = new Map(); // Re-seal background loop. While we hold the topic key, every // 30s we look for newly-joined members who don't have a sealed @@ -241,7 +273,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise } // History is newest-first; reverse for chronological display. for (const m of history.messages.slice().reverse()) { - await printMessage(m, topicKey, flags.json ?? false); + await printMessage(m, topicKey, flags.json ?? false, snippetCache); } } catch (err) { render.warn(`backfill failed: ${(err as Error).message}`); @@ -283,7 +315,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise if (ev.event === "message") { try { const m = JSON.parse(ev.data) as TopicMessage; - await printMessage(m, topicKey, flags.json ?? false); + await printMessage(m, topicKey, flags.json ?? false, snippetCache); } catch { // skip malformed } diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index e2f3b48..6dc4d9c 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -120,7 +120,7 @@ Topic (conversation scope, v0.2.0) claudemesh topic history fetch message history [--limit --before] claudemesh topic read mark all as read claudemesh topic tail live SSE tail [--limit --forward-only] - claudemesh topic post encrypted REST post (v0.3.0 v2) + claudemesh topic post encrypted REST post (v0.3.0 v2) [--reply-to ] claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext) claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -592,6 +592,7 @@ async function main(): Promise { mesh: flags.mesh as string, json: !!flags.json, plaintext: !!flags.plaintext, + replyTo: (flags["reply-to"] as string) || (flags.replyTo as string), }; const message = positionals.slice(2).join(" "); const { runTopicPost } = await import("~/commands/topic-post.js"); diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index e297e65..80c390e 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -293,7 +293,16 @@ export async function startMcpServer(): Promise { You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority. ## Responding to messages -When you receive a message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. +When you receive a message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message (or \`claudemesh topic post --reply-to \` for topic threads), then resume. Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. + +The channel attributes carry everything you need to reply — no extra lookups: +- \`from_name\` — sender display name. Use as the \`to\` arg when replying to a DM. +- \`from_pubkey\` / \`from_member_id\` — stable ids. Use \`from_member_id\` if the sender's display name might change. +- \`mesh_slug\` — pass via \`--mesh\` if your default mesh differs. +- \`priority\` — \`now\` / \`next\` / \`low\`. +- \`message_id\` — id of THIS message. To thread a reply onto it in a topic, run \`claudemesh topic post "" --reply-to \`. +- \`topic\` — set when the message arrived through a topic (vs DM). Reply in the same topic. +- \`reply_to_id\` — set when the incoming message is itself a reply. Render thread context if you re-narrate. If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed). @@ -678,13 +687,18 @@ Your message mode is "${messageMode}". content, meta: { from_id: fromPubkey, + from_pubkey: fromPubkey, from_name: fromName, + ...(msg.senderMemberId ? { from_member_id: msg.senderMemberId } : {}), mesh_slug: client.meshSlug, mesh_id: client.meshId, priority: msg.priority, sent_at: msg.createdAt, delivered_at: msg.receivedAt, kind: msg.kind, + message_id: msg.messageId, + ...(msg.topic ? { topic: msg.topic } : {}), + ...(msg.replyToId ? { reply_to_id: msg.replyToId } : {}), ...(msg.subtype ? { subtype: msg.subtype } : {}), }, }, diff --git a/apps/cli/src/services/broker/ws-client.ts b/apps/cli/src/services/broker/ws-client.ts index c11e35e..cfc082f 100644 --- a/apps/cli/src/services/broker/ws-client.ts +++ b/apps/cli/src/services/broker/ws-client.ts @@ -101,6 +101,14 @@ export interface InboundPush { messageId: string; meshId: string; senderPubkey: string; + /** Stable mesh.member id of the sender — preferred id for replies. */ + senderMemberId?: string; + /** Sender's current display name (a join from the broker). */ + senderName?: string; + /** Topic name when the push originated from a topic post (vs DM). */ + topic?: string; + /** Server-side id of the parent message when this push is a reply. */ + replyToId?: string; priority: Priority; nonce: string; ciphertext: string; @@ -2028,6 +2036,10 @@ export class BrokerClient { messageId: String(msg.messageId ?? ""), meshId: String(msg.meshId ?? ""), senderPubkey, + ...(msg.senderMemberId ? { senderMemberId: String(msg.senderMemberId) } : {}), + ...(msg.senderName ? { senderName: String(msg.senderName) } : {}), + ...(msg.topic ? { topic: String(msg.topic) } : {}), + ...(msg.replyToId ? { replyToId: String(msg.replyToId) } : {}), priority: (msg.priority as Priority) ?? "next", nonce, ciphertext, diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 7d138d3..de424fb 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -77,6 +77,12 @@ const sendMessageSchema = z.object({ * MUST send this array. */ mentions: z.array(z.string().min(1).max(64)).max(16).optional(), + /** + * Optional id of a previous topic message this one replies to. Server + * verifies the parent exists in the same topic; otherwise silently + * drops the reference (treated as a top-level post). + */ + replyToId: z.string().min(1).max(128).optional(), }); /** @@ -158,6 +164,21 @@ export const v1Router = new Hono() // legacy keys with no issuer. const senderMemberId = key.issuedByMemberId ?? ownerMember.id; + // Validate replyToId belongs to the same topic before insert. + let validatedReplyTo: string | null = null; + if (body.replyToId) { + const [parent] = await db + .select({ + id: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + }) + .from(meshTopicMessage) + .where(eq(meshTopicMessage.id, body.replyToId)); + if (parent && parent.topicId === topic.id) { + validatedReplyTo = parent.id; + } + } + // Persist to history (topic_message) + ephemeral queue (message_queue). // Broker's drain loop picks up the queue entry and pushes to live peers. const [historyRow] = await db @@ -168,6 +189,7 @@ export const v1Router = new Hono() nonce: body.nonce, ciphertext: body.ciphertext, bodyVersion: body.bodyVersion, + replyToId: validatedReplyTo, }) .returning({ id: meshTopicMessage.id }); @@ -238,6 +260,8 @@ export const v1Router = new Hono() topic: body.topic, topicId: topic.id, notifications, + bodyVersion: body.bodyVersion, + ...(validatedReplyTo ? { replyToId: validatedReplyTo } : {}), }); }) @@ -400,6 +424,7 @@ export const v1Router = new Hono() nonce: meshTopicMessage.nonce, ciphertext: meshTopicMessage.ciphertext, bodyVersion: meshTopicMessage.bodyVersion, + replyToId: meshTopicMessage.replyToId, createdAt: meshTopicMessage.createdAt, }) .from(meshTopicMessage) @@ -423,11 +448,13 @@ export const v1Router = new Hono() topicId: topic.id, messages: rows.map((r) => ({ id: r.id, + senderMemberId: r.senderMemberId, senderPubkey: r.senderPubkey, senderName: r.senderName, nonce: r.nonce, ciphertext: r.ciphertext, bodyVersion: r.bodyVersion, + replyToId: r.replyToId, createdAt: r.createdAt.toISOString(), })), }); @@ -495,11 +522,13 @@ export const v1Router = new Hono() const rows = await db .select({ id: meshTopicMessage.id, + senderMemberId: meshTopicMessage.senderMemberId, senderPubkey: meshMember.peerPubkey, senderName: meshMember.displayName, nonce: meshTopicMessage.nonce, ciphertext: meshTopicMessage.ciphertext, bodyVersion: meshTopicMessage.bodyVersion, + replyToId: meshTopicMessage.replyToId, createdAt: meshTopicMessage.createdAt, }) .from(meshTopicMessage) @@ -522,11 +551,13 @@ export const v1Router = new Hono() id: r.id, data: JSON.stringify({ id: r.id, + senderMemberId: r.senderMemberId, senderPubkey: r.senderPubkey, senderName: r.senderName, nonce: r.nonce, ciphertext: r.ciphertext, bodyVersion: r.bodyVersion, + replyToId: r.replyToId, createdAt: r.createdAt.toISOString(), }), }); diff --git a/packages/db/migrations/0027_topic_message_reply_to.sql b/packages/db/migrations/0027_topic_message_reply_to.sql new file mode 100644 index 0000000..f246532 --- /dev/null +++ b/packages/db/migrations/0027_topic_message_reply_to.sql @@ -0,0 +1,15 @@ +-- Threaded replies on topic messages (v0.3.1). +-- +-- Adds a self-FK column so any topic message can be marked as a reply to a +-- previous message in the same topic. ON DELETE SET NULL because deleting +-- a parent message shouldn't ripple-delete the children — the thread just +-- becomes "in reply to a deleted message". +-- +-- Index supports the cheap backlink lookup: "give me all replies to X". + +ALTER TABLE "mesh"."topic_message" + ADD COLUMN IF NOT EXISTS "reply_to_id" text + REFERENCES "mesh"."topic_message"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX IF NOT EXISTS "topic_message_by_reply_to" + ON "mesh"."topic_message" ("reply_to_id"); diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index 3d1e0ab..c254023 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -1491,11 +1491,13 @@ export const meshTopicMessage = meshSchema.table( * a v2 message still resolves @-mentions correctly. */ bodyVersion: integer().notNull().default(1), + replyToId: text("reply_to_id"), createdAt: timestamp().defaultNow().notNull(), }, (t) => [ index("topic_message_by_topic_time").on(t.topicId, t.createdAt), index("topic_message_by_version").on(t.bodyVersion), + index("topic_message_by_reply_to").on(t.replyToId), ], );