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

@@ -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 } : {}),