feat: merge schedule_reminder + send_later, add subtype reminder
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled

- Merge send_later into schedule_reminder (optional `to` param — omit for self-reminder)
- Add subtype?: "reminder" to WSPushMessage, WSScheduleMessage, ScheduledEntry, InboundPush
- Broker handleSend now accepts optional subtype and injects into push envelope
- deliver closure passes sm.subtype so reminders surface correctly
- MCP channel meta includes subtype field; formatPush tags [REMINDER] in check_messages
- MCP server instructions document subtype and schedule_reminder/list_scheduled/cancel_scheduled
- client.scheduleMessage accepts isReminder flag, sends subtype: "reminder" on wire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 22:38:41 +01:00
parent 3ff7a61e3f
commit 0bb9d71a26
5 changed files with 62 additions and 45 deletions

View File

@@ -103,7 +103,7 @@ const connectionsPerMesh = new Map<string, number>();
// Stream subscriptions: "meshId:streamName" → Set of presenceIds // Stream subscriptions: "meshId:streamName" → Set of presenceIds
const streamSubscriptions = new Map<string, Set<string>>(); const streamSubscriptions = new Map<string, Set<string>>();
// Scheduled messages: meshId → Map<scheduledId, entry> /// Scheduled messages: meshId → Map<scheduledId, entry>
interface ScheduledEntry { interface ScheduledEntry {
id: string; id: string;
meshId: string; meshId: string;
@@ -112,6 +112,7 @@ interface ScheduledEntry {
message: string; message: string;
deliverAt: number; deliverAt: number;
createdAt: number; createdAt: number;
subtype?: "reminder";
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
const scheduledMessages = new Map<string, ScheduledEntry>(); // keyed by scheduledId const scheduledMessages = new Map<string, ScheduledEntry>(); // keyed by scheduledId
@@ -652,6 +653,7 @@ async function handleHello(
async function handleSend( async function handleSend(
conn: PeerConn, conn: PeerConn,
msg: Extract<WSClientMessage, { type: "send" }>, msg: Extract<WSClientMessage, { type: "send" }>,
subtype?: "reminder",
): Promise<void> { ): Promise<void> {
const messageId = await queueMessage({ const messageId = await queueMessage({
meshId: conn.meshId, meshId: conn.meshId,
@@ -696,6 +698,7 @@ async function handleSend(
nonce: msg.nonce, nonce: msg.nonce,
ciphertext: msg.ciphertext, ciphertext: msg.ciphertext,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...(subtype ? { subtype } : {}),
}; };
for (const [pid, peer] of connections) { for (const [pid, peer] of connections) {
@@ -1824,7 +1827,7 @@ function handleConnection(ws: WebSocket): void {
nonce: "", nonce: "",
ciphertext: Buffer.from(sm.message, "utf-8").toString("base64"), ciphertext: Buffer.from(sm.message, "utf-8").toString("base64"),
}; };
handleSend(conn2, presenceId, fakeMsg).catch((e) => handleSend(conn2, fakeMsg, sm.subtype).catch((e) =>
log.warn("scheduled delivery error", { scheduled_id: scheduledId, error: String(e) }), log.warn("scheduled delivery error", { scheduled_id: scheduledId, error: String(e) }),
); );
log.info("ws schedule deliver", { scheduled_id: scheduledId, to: sm.to }); log.info("ws schedule deliver", { scheduled_id: scheduledId, to: sm.to });
@@ -1838,6 +1841,7 @@ function handleConnection(ws: WebSocket): void {
message: sm.message, message: sm.message,
deliverAt: sm.deliverAt, deliverAt: sm.deliverAt,
createdAt: now, createdAt: now,
...(sm.subtype ? { subtype: sm.subtype } : {}),
timer: setTimeout(deliver, delay), timer: setTimeout(deliver, delay),
}; };
scheduledMessages.set(scheduledId, entry); scheduledMessages.set(scheduledId, entry);

View File

@@ -86,6 +86,8 @@ export interface WSPushMessage {
nonce: string; nonce: string;
ciphertext: string; ciphertext: string;
createdAt: string; createdAt: string;
/** Optional semantic tag — "reminder" when delivered by the scheduler. */
subtype?: "reminder";
} }
/** Client → broker: manual status override (dnd, forced idle). */ /** Client → broker: manual status override (dnd, forced idle). */
@@ -673,6 +675,8 @@ export interface WSScheduleMessage {
message: string; message: string;
/** Unix timestamp (ms) when to deliver. */ /** Unix timestamp (ms) when to deliver. */
deliverAt: number; deliverAt: number;
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
subtype?: "reminder";
_reqId?: string; _reqId?: string;
} }

View File

@@ -123,7 +123,8 @@ function decryptFailedWarning(senderPubkey: string): string {
function formatPush(p: InboundPush, meshSlug: string): string { function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey); const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`; const tag = p.subtype === "reminder" ? " [REMINDER]" : "";
return `[${meshSlug}]${tag} from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
} }
export async function startMcpServer(): Promise<void> { export async function startMcpServer(): Promise<void> {
@@ -147,6 +148,8 @@ You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh
## Responding to messages ## Responding to messages
When you receive a <channel source="claudemesh" ...> 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 <channel source="claudemesh" ...> 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.
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
## Tools ## Tools
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
@@ -188,6 +191,9 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| claim_task(id) | Claim an unclaimed task. | | claim_task(id) | Claim an unclaimed task. |
| complete_task(id, result?) | Mark task done with optional result. | | complete_task(id, result?) | Mark task done with optional result. |
| list_tasks(status?, assignee?) | List tasks filtered by status/assignee. | | list_tasks(status?, assignee?) | List tasks filtered by status/assignee. |
| schedule_reminder(message, in_seconds?, deliver_at?, to?) | Schedule a reminder to yourself (no \`to\`) or a delayed message to a peer/group. Delivered as a push with \`subtype: reminder\` in the channel meta. |
| list_scheduled() | List pending scheduled reminders and messages. |
| cancel_scheduled(id) | Cancel a pending scheduled item. |
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`). If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
@@ -445,17 +451,14 @@ Your message mode is "${messageMode}".
} }
// --- Scheduled messages --- // --- Scheduled messages ---
case "schedule_reminder": case "schedule_reminder": {
case "send_later": {
const sArgs = (args ?? {}) as { const sArgs = (args ?? {}) as {
message?: string; message?: string;
to?: string; to?: string;
deliver_at?: number; deliver_at?: number;
in_seconds?: number; in_seconds?: number;
}; };
if (!sArgs.message) return text(`${name}: \`message\` required`, true); if (!sArgs.message) return text("schedule_reminder: `message` required", true);
const to = name === "schedule_reminder" ? "self" : (sArgs.to ?? "");
if (name === "send_later" && !to) return text("send_later: `to` required", true);
let deliverAt: number; let deliverAt: number;
if (sArgs.deliver_at) { if (sArgs.deliver_at) {
@@ -463,32 +466,37 @@ Your message mode is "${messageMode}".
} else if (sArgs.in_seconds) { } else if (sArgs.in_seconds) {
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000; deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
} else { } else {
return text(`${name}: provide \`deliver_at\` (ms timestamp) or \`in_seconds\``, true); return text("schedule_reminder: provide `deliver_at` (ms timestamp) or `in_seconds`", true);
} }
// For send_later, resolve display name → pubkey if needed const isSelf = !sArgs.to;
let targetSpec = to; let targetSpec: string;
if (name === "send_later" && !to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to) && to !== "self") { if (isSelf) {
const peers = await client.listPeers(); // Self-reminder: target own session pubkey
const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase());
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
return text(`send_later: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
}
if (name === "schedule_reminder") {
// Self-reminder: use own session pubkey
targetSpec = client.getSessionPubkey() ?? "*"; targetSpec = client.getSessionPubkey() ?? "*";
} else {
const to = sArgs.to!;
// Resolve display name → pubkey if not a raw spec
if (!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());
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
} else {
targetSpec = to;
}
} }
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt); const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
if (!result) return text(`${name}: broker did not acknowledge — check connection`, true); if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
const when = new Date(result.deliverAt).toISOString(); const when = new Date(result.deliverAt).toISOString();
return text( return text(
name === "schedule_reminder" isSelf
? `Reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}` ? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
: `Message to "${to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`, : `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
); );
} }
case "list_scheduled": { case "list_scheduled": {
@@ -1016,6 +1024,7 @@ Your message mode is "${messageMode}".
sent_at: msg.createdAt, sent_at: msg.createdAt,
delivered_at: msg.receivedAt, delivered_at: msg.receivedAt,
kind: msg.kind, kind: msg.kind,
...(msg.subtype ? { subtype: msg.subtype } : {}),
}, },
}, },
}); });

View File

@@ -568,32 +568,21 @@ export const TOOLS: Tool[] = [
{ {
name: "schedule_reminder", name: "schedule_reminder",
description: description:
"Schedule a reminder message delivered back to yourself at a future time. The broker fires it as a push when the time arrives. Use to prompt yourself to check on something later.", "Schedule a message for future delivery. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. The broker holds it and delivers when the time arrives. Receivers see `subtype: reminder` in the push envelope.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
message: { type: "string", description: "Reminder text" }, message: { type: "string", description: "Message or reminder text" },
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" }, deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" }, in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
to: {
type: "string",
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
},
}, },
required: ["message"], required: ["message"],
}, },
}, },
{
name: "send_later",
description:
"Send a message to a peer, @group, or broadcast (*) at a future time. The broker holds it and delivers when the time arrives.",
inputSchema: {
type: "object",
properties: {
to: { type: "string", description: "Recipient: display name, pubkey hex, @group, or *" },
message: { type: "string", description: "Message text" },
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver" },
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds" },
},
required: ["to", "message"],
},
},
{ {
name: "list_scheduled", name: "list_scheduled",
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.", description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",

View File

@@ -51,6 +51,8 @@ export interface InboundPush {
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast" /** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
* (plaintext for now). */ * (plaintext for now). */
kind: "direct" | "broadcast" | "channel" | "unknown"; kind: "direct" | "broadcast" | "channel" | "unknown";
/** Optional semantic tag — "reminder" when fired by the scheduler. */
subtype?: "reminder";
} }
type PushHandler = (msg: InboundPush) => void; type PushHandler = (msg: InboundPush) => void;
@@ -406,6 +408,7 @@ export class BrokerClient {
to: string, to: string,
message: string, message: string,
deliverAt: number, deliverAt: number,
isReminder = false,
): Promise<{ scheduledId: string; deliverAt: number } | null> { ): Promise<{ scheduledId: string; deliverAt: number } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -413,7 +416,14 @@ export class BrokerClient {
this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => { this.scheduledAckResolvers.set(reqId, { resolve, timer: setTimeout(() => {
if (this.scheduledAckResolvers.delete(reqId)) resolve(null); if (this.scheduledAckResolvers.delete(reqId)) resolve(null);
}, 8_000) }); }, 8_000) });
this.ws!.send(JSON.stringify({ type: "schedule", to, message, deliverAt, _reqId: reqId })); this.ws!.send(JSON.stringify({
type: "schedule",
to,
message,
deliverAt,
...(isReminder ? { subtype: "reminder" } : {}),
_reqId: reqId,
}));
}); });
} }
@@ -927,6 +937,7 @@ export class BrokerClient {
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
plaintext, plaintext,
kind, kind,
...(msg.subtype ? { subtype: msg.subtype as "reminder" } : {}),
}; };
this.pushBuffer.push(push); this.pushBuffer.push(push);
if (this.pushBuffer.length > 500) this.pushBuffer.shift(); if (this.pushBuffer.length > 500) this.pushBuffer.shift();