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

@@ -123,7 +123,8 @@ function decryptFailedWarning(senderPubkey: string): string {
function formatPush(p: InboundPush, meshSlug: string): string {
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> {
@@ -147,6 +148,8 @@ You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh
## 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.
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
## Tools
| Tool | Description |
|------|-------------|
@@ -188,6 +191,9 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| claim_task(id) | Claim an unclaimed task. |
| complete_task(id, result?) | Mark task done with optional result. |
| 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\`).
@@ -445,17 +451,14 @@ Your message mode is "${messageMode}".
}
// --- Scheduled messages ---
case "schedule_reminder":
case "send_later": {
case "schedule_reminder": {
const sArgs = (args ?? {}) as {
message?: string;
to?: string;
deliver_at?: number;
in_seconds?: number;
};
if (!sArgs.message) return text(`${name}: \`message\` required`, true);
const to = name === "schedule_reminder" ? "self" : (sArgs.to ?? "");
if (name === "send_later" && !to) return text("send_later: `to` required", true);
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
let deliverAt: number;
if (sArgs.deliver_at) {
@@ -463,32 +466,37 @@ Your message mode is "${messageMode}".
} else if (sArgs.in_seconds) {
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
} 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
let targetSpec = to;
if (name === "send_later" && !to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to) && to !== "self") {
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(`send_later: peer "${to}" not found. Online: ${names || "(none)"}`, true);
}
targetSpec = match.pubkey;
}
if (name === "schedule_reminder") {
// Self-reminder: use own session pubkey
const isSelf = !sArgs.to;
let targetSpec: string;
if (isSelf) {
// Self-reminder: target own session pubkey
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);
if (!result) return text(`${name}: broker did not acknowledge — check connection`, true);
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
const when = new Date(result.deliverAt).toISOString();
return text(
name === "schedule_reminder"
? `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}`,
isSelf
? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}`
: `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`,
);
}
case "list_scheduled": {
@@ -1016,6 +1024,7 @@ Your message mode is "${messageMode}".
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
...(msg.subtype ? { subtype: msg.subtype } : {}),
},
},
});

View File

@@ -568,32 +568,21 @@ export const TOOLS: Tool[] = [
{
name: "schedule_reminder",
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: {
type: "object",
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" },
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"],
},
},
{
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",
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",