feat(broker+api+cli): topic message reply-to threading (v0.3.1)
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 a reply_to_id column (self-FK on topic_message) plus end-to-end
plumbing so a message can mark itself as a reply to a previous one in
the same topic.

- Schema: 0027_topic_message_reply_to.sql adds reply_to_id with
  ON DELETE SET NULL + index for backlink lookup.
- Broker: appendTopicMessage validates parent shares the topic, writes
  reply_to_id; topicHistory + topic_history_response surface it; WS
  push envelope now carries senderMemberId, senderName, topic name,
  reply_to_id, and message_id so recipients have everything they need
  to reply without a follow-up query.
- REST: POST /v1/messages accepts replyToId (validated server-side);
  GET /messages and SSE /stream emit it per row.
- CLI: \`topic post --reply-to <id|prefix>\` resolves prefixes against
  recent history; \`topic tail\` renders an "↳ in reply to <name>:
  <snippet>" line above replies and shows a copyable #shortid tag on
  every row.
- MCP push pipe: channel attributes now include from_pubkey,
  from_member_id, message_id, topic, reply_to_id — the recipient can
  thread a reply directly from the inbound notification.
- Skill + identity prompt updated to teach Claude how to use the new
  attributes for replies.

Bumped CLI to 1.9.0.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 21:58:21 +01:00
parent d871988084
commit 038a5b5bf7
13 changed files with 273 additions and 19 deletions

View File

@@ -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<PostResponse>({
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;
},

View File

@@ -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<string, RenderedSnippet>,
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<string, RenderedSnippet>,
): Promise<void> {
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<string, RenderedSnippet>();
// 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
}