feat: add persistent cron-based recurring reminders

Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:33:47 +01:00
parent 58ba01f20f
commit e87380775f
6 changed files with 332 additions and 39 deletions

View File

@@ -463,16 +463,22 @@ Your message mode is "${messageMode}".
to?: string;
deliver_at?: number;
in_seconds?: number;
cron?: string;
};
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
const isCron = !!sArgs.cron;
let deliverAt: number;
if (sArgs.deliver_at) {
if (isCron) {
// For cron, deliverAt is ignored by the broker — set to 0
deliverAt = 0;
} else if (sArgs.deliver_at) {
deliverAt = Number(sArgs.deliver_at);
} else if (sArgs.in_seconds) {
deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000;
} else {
return text("schedule_reminder: provide `deliver_at` (ms timestamp) or `in_seconds`", true);
return text("schedule_reminder: provide `deliver_at` (ms timestamp), `in_seconds`, or `cron` expression", true);
}
const isSelf = !sArgs.to;
@@ -496,8 +502,18 @@ Your message mode is "${messageMode}".
}
}
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true);
const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true, sArgs.cron);
if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true);
if (isCron) {
const nextFire = new Date(result.deliverAt).toISOString();
return text(
isSelf
? `Recurring self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" — cron: ${sArgs.cron}, next fire: ${nextFire}`
: `Recurring reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) — cron: ${sArgs.cron}, next fire: ${nextFire}`,
);
}
const when = new Date(result.deliverAt).toISOString();
return text(
isSelf

View File

@@ -568,13 +568,14 @@ export const TOOLS: Tool[] = [
{
name: "schedule_reminder",
description:
"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.",
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
inputSchema: {
type: "object",
properties: {
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" },
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
to: {
type: "string",
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",