@c4t4/heyamigo 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -227,3 +227,64 @@ A shared Chrome runs on the server at `localhost:9222` with the owner's real ses
227
227
  **Never call `browser_*` / `mcp__*playwright*` tools inline.** All browser work goes via `[ASYNC-BROWSER:...]`. See the two-track section above.
228
228
 
229
229
  To send a screenshot back: the browser worker takes it (saving to `storage/outbox/`), then includes `[IMAGE: /absolute/path.png]` in its result message.
230
+
231
+ ## Scheduling: reminders and recurring crons
232
+
233
+ The bot has a built-in scheduler. When the user asks for any future or recurring action, you MUST emit a marker at the END of your reply — saying "I'll remind you" without a marker creates no schedule and the user gets nothing.
234
+
235
+ The current local time is shown at the top of every chat preamble in the SENDER's timezone. Use it when interpreting "at 10:30am" / "tomorrow morning" / etc.
236
+
237
+ ### One-shot reminders — `[REMIND: <time> — <text>]`
238
+
239
+ Time forms (case-insensitive):
240
+
241
+ | Form | Example | Meaning |
242
+ |---|---|---|
243
+ | `in N<unit>` | `in 30m` / `in 2h` / `in 3d` | Units: `s`, `m`, `h`, `d`. Also accepts the word forms: `in 30 minutes`. |
244
+ | `at HH(:MM)?[am\|pm]` | `at 10:30am` / `at 14:00` | TODAY at the user's local time. If already past, rolls to tomorrow. |
245
+ | `tomorrow at HH:MM` | `tomorrow at 9am` | Tomorrow at the user's local time. |
246
+ | `<weekday> at HH:MM` | `mon at 9am` / `friday at 18:00` | Next occurrence of that weekday. |
247
+ | `YYYY-MM-DD HH:MM` | `2026-12-25 09:00` | Specific date, user's local time. |
248
+
249
+ The `<text>` is what the user will receive at fire time.
250
+
251
+ Examples:
252
+ ```
253
+ [REMIND: in 30m — take the chicken out of the oven]
254
+ [REMIND: at 10:30am — call mom]
255
+ [REMIND: tomorrow at 9am — gym]
256
+ [REMIND: mon at 9am — weekly planning]
257
+ ```
258
+
259
+ ### Recurring crons — `[CRON: <recurrence> — <text>]`
260
+
261
+ Recurrence forms:
262
+
263
+ | Form | Example | Meaning |
264
+ |---|---|---|
265
+ | `@every N<unit>` | `@every 1h` / `@every 5m` | Fires repeatedly at that interval. |
266
+ | `@daily HH:MM` | `@daily 09:00` | Every day at that user-local time (24h format). |
267
+ | `@weekly <DOW> HH:MM` | `@weekly mon 09:00` | Every week on that weekday at that time. |
268
+
269
+ Examples:
270
+ ```
271
+ [CRON: @daily 09:00 — morning check-in: what's the focus today?]
272
+ [CRON: @weekly sun 18:00 — weekly review: what worked, what didn't]
273
+ [CRON: @every 2h — hydration reminder]
274
+ ```
275
+
276
+ ### Cross-chat send — `[SEND-TEXT: ...]`
277
+
278
+ Send a text to a DIFFERENT chat than the one you're responding in. Rare; usually owner-only.
279
+
280
+ ```
281
+ [SEND-TEXT: address=wa:dm:5491234567890@s.whatsapp.net body="heads up: just posted"]
282
+ ```
283
+
284
+ ### Rules
285
+
286
+ - Acknowledge the schedule in your CHAT REPLY ("got it, reminding you at 10:30") so the user has immediate feedback. The marker is the side effect; the text is what they see right now.
287
+ - ONE marker per scheduled item. Multiple markers in one reply OK.
288
+ - Times are always in the **sender's timezone**, never the server's. The preamble shows the current local time so you can compute deltas if needed.
289
+ - If parsing fails (malformed marker), the bot logs a warning and the schedule is dropped silently. Stick to the grammars above.
290
+ - To cancel a scheduled item, the user types `/reminders` or `/crons` to see what's pending, then deletes via chat command.
@@ -133,7 +133,7 @@ export function syncIdentitiesFromAccess() {
133
133
  logger.info({ personsUpserted, identitiesUpserted, seeded: seeds.length }, 'identity sync from access.json complete');
134
134
  return { personsUpserted, identitiesUpserted };
135
135
  }
136
- // Lookup helper used by the inbound resolution step (in later phases).
136
+ // Lookup helper used by the inbound resolution step.
137
137
  // Returns null if the address has no matching person — caller decides
138
138
  // whether to auto-create or treat as stranger.
139
139
  export function personIdForAddress(address) {
@@ -145,3 +145,35 @@ export function personIdForAddress(address) {
145
145
  .get();
146
146
  return row?.personId ?? null;
147
147
  }
148
+ // Timezone for a person, falling back to owner timezone when unknown.
149
+ // Used by scheduling code (REMIND/CRON) so absolute times like "at
150
+ // 10:30am" land in the SENDER's local time, not the server's.
151
+ export function getTimezoneForPerson(personId) {
152
+ if (!personId)
153
+ return config.owner.timezone;
154
+ const db = getDb();
155
+ const row = db
156
+ .select({ tz: persons.timezone })
157
+ .from(persons)
158
+ .where(eq(persons.id, personId))
159
+ .get();
160
+ return row?.tz ?? config.owner.timezone;
161
+ }
162
+ // Convenience: resolve sender's address → person → timezone.
163
+ // Address can be a WA jid or a wa:dm:... formatted address.
164
+ export function getTimezoneForAddress(address) {
165
+ const pid = personIdForAddress(address);
166
+ return getTimezoneForPerson(pid);
167
+ }
168
+ // Sender-number → tz (handles the senderNumber field on Job/inbound
169
+ // rows). Builds the wa:dm: address shape internally so we don't have
170
+ // to duplicate the format-from-number logic at every call site.
171
+ export function getTimezoneForSenderNumber(senderNumber) {
172
+ if (!senderNumber)
173
+ return config.owner.timezone;
174
+ const sanitized = senderNumber.replace(/\D/g, '');
175
+ if (!sanitized)
176
+ return config.owner.timezone;
177
+ const address = formatAddress(jidToAddress(`${sanitized}@s.whatsapp.net`));
178
+ return getTimezoneForAddress(address);
179
+ }
package/dist/db/schema.js CHANGED
@@ -115,6 +115,10 @@ export const crons = sqliteTable('crons', {
115
115
  enqueueInto: text('enqueue_into').notNull(), // 'inbound'|'async'|'outbound'|'memory_writes'
116
116
  payload: text('payload').notNull(), // JSON passed to the target queue
117
117
  recurrence: text('recurrence'), // null = one-shot
118
+ // IANA timezone for resolving @daily HH:MM / @weekly DOW HH:MM
119
+ // recurrences. Set to the sender's local tz when an agent emits a
120
+ // [CRON:] tag; nullable for system crons that prefer owner tz.
121
+ timezone: text('timezone'),
118
122
  nextRunAt: integer('next_run_at').notNull(),
119
123
  lastRunAt: integer('last_run_at'),
120
124
  enabled: integer('enabled').notNull().default(1), // SQLite bool = int
@@ -0,0 +1,29 @@
1
+ // Generic async-task estimator (non-browser background work).
2
+ //
3
+ // The general async lane is still on in-memory fastq (no durable
4
+ // table). Real duration samples aren't queryable yet → the estimate
5
+ // uses defaultMs every time until/unless that lane gets migrated to
6
+ // SQLite. Cards still surface useful "long task incoming" UX.
7
+ import { aggregateMean, humanDur, registerEstimator, } from './registry.js';
8
+ class AsyncTaskEstimator {
9
+ kind = 'async-task';
10
+ // 3 min — generic background work tends to be moderate. A deeper
11
+ // research task might run longer; a quick one shorter. Single
12
+ // ballpark until we have real samples.
13
+ defaultMs = 3 * 60 * 1000;
14
+ matches(ctx) {
15
+ return ctx.taskKind === 'async';
16
+ }
17
+ // No durable samples (general async lane is still in-memory fastq).
18
+ // Returning [] forces aggregateMean to fall back to defaultMs.
19
+ querySamples() {
20
+ return [];
21
+ }
22
+ estimate(samples) {
23
+ return aggregateMean(samples, this.defaultMs);
24
+ }
25
+ format(estimate) {
26
+ return `background task, ~${humanDur(estimate.pointMs)}`;
27
+ }
28
+ }
29
+ registerEstimator(new AsyncTaskEstimator());
@@ -0,0 +1,49 @@
1
+ // Generic browser-task estimator. Matches any agent-delegated
2
+ // [ASYNC-BROWSER:] task. Pulls duration samples from the durable
3
+ // browser_tasks table so the average reflects real observed runtimes.
4
+ import { and, desc, eq, isNotNull } from 'drizzle-orm';
5
+ import { getDb } from '../db/index.js';
6
+ import { browserTasks } from '../db/schema.js';
7
+ import { aggregateMean, humanDur, registerEstimator, } from './registry.js';
8
+ class BrowserTaskEstimator {
9
+ kind = 'browser-task';
10
+ // 5 min is a reasonable ballpark for IG/TT scrapes. Real samples
11
+ // dominate after the first 1-2 jobs.
12
+ defaultMs = 5 * 60 * 1000;
13
+ matches(ctx) {
14
+ return ctx.taskKind === 'async-browser';
15
+ }
16
+ querySamples(limit = 20) {
17
+ const db = getDb();
18
+ // All done browser tasks — single bucket. Could be sliced further
19
+ // (per-domain) later via more-specific estimators registered ahead
20
+ // of this catch-all.
21
+ const rows = db
22
+ .select({
23
+ claimedAt: browserTasks.claimedAt,
24
+ updatedAt: browserTasks.updatedAt,
25
+ })
26
+ .from(browserTasks)
27
+ .where(and(eq(browserTasks.status, 'done'), isNotNull(browserTasks.claimedAt)))
28
+ .orderBy(desc(browserTasks.id))
29
+ .limit(limit)
30
+ .all();
31
+ return rows
32
+ .filter((r) => r.claimedAt !== null)
33
+ .map((r) => ({
34
+ durationMs: (r.updatedAt - r.claimedAt) * 1000,
35
+ finishedAt: r.updatedAt,
36
+ }))
37
+ .filter((s) => s.durationMs > 0);
38
+ }
39
+ estimate(samples) {
40
+ return aggregateMean(samples, this.defaultMs);
41
+ }
42
+ format(estimate) {
43
+ if (estimate.rangeMs) {
44
+ return `browser task, ~${humanDur(estimate.rangeMs.lowMs)} to ~${humanDur(estimate.rangeMs.highMs)}`;
45
+ }
46
+ return `browser task, ~${humanDur(estimate.pointMs)}`;
47
+ }
48
+ }
49
+ registerEstimator(new BrowserTaskEstimator());
@@ -15,6 +15,13 @@ class ImageGenEstimator {
15
15
  // observations.
16
16
  defaultMs = 30_000;
17
17
  matches(ctx) {
18
+ // Only match direct user input. When taskKind is set, the context
19
+ // is an agent-delegated task — those go through the browser/async
20
+ // estimators below, not here. Prevents an agent's
21
+ // "[ASYNC-BROWSER: generate marketing image of X]" from being
22
+ // mis-classified as a user-typed image-gen request.
23
+ if (ctx.taskKind)
24
+ return false;
18
25
  return IMAGE_GEN_RE.test(ctx.description);
19
26
  }
20
27
  estimate(samples) {
@@ -6,7 +6,13 @@
6
6
  //
7
7
  // Adding a new kind = drop a file alongside image-gen.ts and import
8
8
  // it below. No other code in the codebase needs to change.
9
+ // Order matters: more-specific estimators register first so they win
10
+ // classify() over the catch-all task estimators. image-gen and other
11
+ // user-input matchers can run first because they explicitly DON'T
12
+ // match when ctx.taskKind is set.
9
13
  import './image-gen.js';
10
- // future: import './browser-ig.js'
14
+ import './browser-task.js'; // catches all [ASYNC-BROWSER:] tasks
15
+ import './async-task.js'; // catches all [ASYNC:] tasks
16
+ // future: import './browser-ig.js' // more specific than browser-task
11
17
  // future: import './voice-gen.js'
12
18
  export { classify, estimate, formatEstimateDefault, humanDur, listEstimators, querySamplesForKind, registerEstimator, } from './registry.js';
@@ -61,7 +61,10 @@ export function estimate(ctx) {
61
61
  const e = classify(ctx);
62
62
  if (!e)
63
63
  return null;
64
- const samples = querySamplesForKind(e.kind);
64
+ // Estimator's own querySamples (if provided) takes precedence —
65
+ // browser/async estimators pull from their dedicated tables. Otherwise
66
+ // fall back to the inbound-by-kind default.
67
+ const samples = e.querySamples ? e.querySamples() : querySamplesForKind(e.kind);
65
68
  const result = e.estimate(samples);
66
69
  const text = (e.format ?? formatEstimateDefault)(result);
67
70
  return { kind: e.kind, result, text };
@@ -68,5 +68,16 @@ export async function tryCommand(ctx) {
68
68
  await sendText(ctx.sock, ctx.jid, formatQueuesSnapshot(snap), ctx.quoted);
69
69
  return true;
70
70
  }
71
+ if (cmd === 'reminders' || cmd === 'crons') {
72
+ const { listChatSchedules, formatScheduleList } = await import('../queue/schedule-list.js');
73
+ const { formatAddress, jidToAddress } = await import('../db/address.js');
74
+ const { getTimezoneForSenderNumber } = await import('../db/identity-sync.js');
75
+ const chatAddress = formatAddress(jidToAddress(ctx.jid));
76
+ const tz = getTimezoneForSenderNumber(ctx.senderNumber);
77
+ const onlyKind = cmd === 'reminders' ? 'one-shot' : 'recurring';
78
+ const items = listChatSchedules(chatAddress, onlyKind);
79
+ await sendText(ctx.sock, ctx.jid, formatScheduleList(items, tz, onlyKind), ctx.quoted);
80
+ return true;
81
+ }
71
82
  return false;
72
83
  }
@@ -108,7 +108,25 @@ export async function handleReply(job, result, _originalMsg) {
108
108
  enqueuePiece({ address, kind: 'text', text: chunkForSend });
109
109
  }
110
110
  }
111
- logger.info({ jid: job.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'reply enqueued for outbound');
111
+ // Job cards (ETAs for delegated async/browser tasks) go LAST so
112
+ // they arrive after the agent's reply chunks in chat. Each card
113
+ // has its own producer-supplied idempotencyKey; we don't slot them
114
+ // into the piece-numbered key space.
115
+ for (const card of result.jobCards ?? []) {
116
+ enqueueOutbound({
117
+ address,
118
+ kind: 'text',
119
+ text: card.text,
120
+ idempotencyKey: card.idempotencyKey,
121
+ });
122
+ }
123
+ logger.info({
124
+ jid: job.jid,
125
+ files: files.length,
126
+ chars: text.length,
127
+ pieces: pieceIdx,
128
+ cards: result.jobCards?.length ?? 0,
129
+ }, 'reply enqueued for outbound');
112
130
  }
113
131
  // Proactive outbound: send a message to a chat without an incoming
114
132
  // trigger. Same parsing as handleReply; enqueues outbound rows.
@@ -8,6 +8,8 @@
8
8
  // drops it entirely. That bug leaked two real markers into user-facing
9
9
  // replies today (DIGEST ~morning, ASYNC later). This parser closes that
10
10
  // whole class of failure.
11
+ import { logger } from '../logger.js';
12
+ import { parseTimeExpression } from '../queue/time-expr.js';
11
13
  const KINDS = [
12
14
  'DIGEST',
13
15
  'JOURNAL',
@@ -115,16 +117,22 @@ export function extractFlags(reply) {
115
117
  const parsed = parseSendTextPayload(payload);
116
118
  if (parsed)
117
119
  sendTexts.unshift(parsed);
120
+ else
121
+ logger.warn({ payload }, 'SEND-TEXT tag dropped: unparseable payload');
118
122
  }
119
123
  else if (kind === 'CRON') {
120
124
  const parsed = parseCronPayload(payload);
121
125
  if (parsed)
122
126
  crons.unshift(parsed);
127
+ else
128
+ logger.warn({ payload }, 'CRON tag dropped: unparseable payload');
123
129
  }
124
130
  else if (kind === 'REMIND') {
125
131
  const parsed = parseRemindPayload(payload);
126
132
  if (parsed)
127
133
  reminds.unshift(parsed);
134
+ else
135
+ logger.warn({ payload }, 'REMIND tag dropped: unparseable payload');
128
136
  }
129
137
  }
130
138
  return {
@@ -178,7 +186,10 @@ function parseCronPayload(payload) {
178
186
  return null;
179
187
  return { recurrence, body };
180
188
  }
181
- // Parse `in <n><unit> — <body>` payload. Supported units: s,m,h,d.
189
+ // Parse `<time-spec> — <body>` payload. Time spec is anything the
190
+ // TimeExpression parser accepts: `in 30m`, `at 10:30am`, `tomorrow
191
+ // at 9am`, `mon at 9am`, `YYYY-MM-DD HH:MM`. Resolution happens
192
+ // later in the worker using the sender's timezone.
182
193
  function parseRemindPayload(payload) {
183
194
  const sepMatch = payload.match(/\s+[—–-]\s+/);
184
195
  if (!sepMatch || sepMatch.index === undefined)
@@ -187,15 +198,10 @@ function parseRemindPayload(payload) {
187
198
  const body = payload.slice(sepMatch.index + sepMatch[0].length).trim();
188
199
  if (!timeSpec || !body)
189
200
  return null;
190
- const m = timeSpec.match(/^in\s+(\d+)\s*([smhd])$/i);
191
- if (!m)
201
+ const when = parseTimeExpression(timeSpec);
202
+ if (!when)
192
203
  return null;
193
- const n = parseInt(m[1], 10);
194
- const unit = m[2].toLowerCase();
195
- const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
196
- if (n <= 0)
197
- return null;
198
- return { whenSecondsFromNow: n * mult, body };
204
+ return { when, body };
199
205
  }
200
206
  // Parse `address=<addr> body="..."` style key=value payload.
201
207
  // Body is delimited by double quotes; everything else by whitespace.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import { config } from '../config.js';
4
+ import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
4
5
  import { listAsyncTasks } from '../queue/async-tasks.js';
5
6
  import { readCompressed } from './compressed.js';
6
7
  import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
@@ -11,6 +12,36 @@ import { getRoleForContext } from '../wa/whitelist.js';
11
12
  const DIGEST_REMINDER = `When something worth remembering happens (new preference, key fact, life event, changed plan), append [DIGEST: <one-line reason>] to the END of your reply. It will be stripped before sending. Flag sparingly.`;
12
13
  const JOURNAL_REMINDER = `When a message contains info for one of the journals above, append [JOURNAL:<slug> — <one-line note>] to the END of your reply. Multiple tags OK. Only use slugs listed; never invent. Full rules are in your memory instructions.`;
13
14
  const ASYNC_REMINDER = `TWO TRACKS run in parallel: you are the chat track, a separate browser track runs a persistent Claude session dedicated to the shared Chrome at localhost:9222. Never call browser tools (browser_*, mcp__*playwright*) yourself — delegate via [ASYNC-BROWSER: <self-sufficient task description>] at the END of your reply, plus a short ack ("On it, will report back."). For non-browser long work (>30s, multi-step reasoning) use [ASYNC: ...]. Irreversible actions (DM send, post, purchase) split into gather→confirm→act phases — never send on your own judgment.`;
15
+ // Buildable per-turn so the agent always sees the SENDER's current
16
+ // time and timezone — not the server's. Critical for resolving
17
+ // "today at 10:30am" / "tomorrow morning" relative to the user.
18
+ function buildSchedulingReminder(nowLocal, tz) {
19
+ return [
20
+ `SCHEDULING (reminders and recurring schedules):`,
21
+ `Current local time for THIS sender: ${nowLocal} (${tz}).`,
22
+ `Saying "I'll remind you" is NOT enough — you must emit a tag.`,
23
+ `Without the tag, NO schedule is created and the user gets nothing.`,
24
+ ``,
25
+ `One-shot reminder — append at END of your reply, ONE PER LINE:`,
26
+ ` [REMIND: in 30m — <text the user will receive>]`,
27
+ ` [REMIND: in 2h — <text>]`,
28
+ ` [REMIND: at 10:30am — <text>]`,
29
+ ` [REMIND: tomorrow at 9am — <text>]`,
30
+ ` [REMIND: mon at 9am — <text>] (next occurrence of Monday)`,
31
+ ` [REMIND: 2026-12-25 09:00 — <text>]`,
32
+ `Units for "in N": s | m | h | d. Times are in the sender's tz.`,
33
+ ``,
34
+ `Recurring schedule:`,
35
+ ` [CRON: @daily 09:00 — <text>]`,
36
+ ` [CRON: @every 3h — <text>]`,
37
+ ` [CRON: @weekly mon 09:00 — <text>]`,
38
+ ``,
39
+ `Cross-chat send (rare):`,
40
+ ` [SEND-TEXT: address=wa:dm:1234567890@s.whatsapp.net body="..."]`,
41
+ ``,
42
+ `Acknowledge the schedule in your chat reply ("got it, reminding you at 10:30") and emit the tag at the END. Reply text is what the user sees right now; the tag is the side effect.`,
43
+ ].join('\n');
44
+ }
14
45
  function buildCriticalSection(params) {
15
46
  const { senderNumber, roleName, role, userName } = params;
16
47
  const who = userName
@@ -131,6 +162,23 @@ export function buildMemoryPreamble(params) {
131
162
  sections.push(`[Journals: active]\n${journalsBlock}`);
132
163
  instructions.push(JOURNAL_REMINDER);
133
164
  }
165
+ // Scheduling reminder — tells the agent the current local time in
166
+ // the SENDER's timezone + lists the REMIND/CRON/SEND-TEXT grammar.
167
+ // Without this the agent never emits the tag and reminders silently
168
+ // never fire (was the root-cause of the May 2026 reminders-not-
169
+ // working bug).
170
+ const senderTz = getTimezoneForSenderNumber(params.senderNumber);
171
+ const nowLocal = new Intl.DateTimeFormat('en-GB', {
172
+ timeZone: senderTz,
173
+ weekday: 'short',
174
+ year: 'numeric',
175
+ month: 'short',
176
+ day: '2-digit',
177
+ hour: '2-digit',
178
+ minute: '2-digit',
179
+ hour12: false,
180
+ }).format(new Date());
181
+ instructions.push(buildSchedulingReminder(nowLocal, senderTz));
134
182
  // Async tasks in progress for this chat — so Claude doesn't re-promise or
135
183
  // contradict work already running in the background.
136
184
  const asyncTasks = listAsyncTasks(params.jid);
@@ -23,6 +23,7 @@ export function enqueueCron(input) {
23
23
  const db = getDb();
24
24
  const now = Math.floor(Date.now() / 1000);
25
25
  const enabled = (input.enabled ?? true) ? 1 : 0;
26
+ const tz = input.timezone ?? config.owner.timezone;
26
27
  if (input.recurrence) {
27
28
  const existing = db
28
29
  .select()
@@ -36,6 +37,7 @@ export function enqueueCron(input) {
36
37
  enqueueInto: input.enqueueInto,
37
38
  payload: JSON.stringify(input.payload),
38
39
  recurrence: input.recurrence,
40
+ timezone: tz,
39
41
  enabled,
40
42
  })
41
43
  .where(eq(crons.name, input.name))
@@ -44,7 +46,7 @@ export function enqueueCron(input) {
44
46
  return updated;
45
47
  }
46
48
  }
47
- const firstRunAt = input.firstRunAt ?? computeNextRun(input.recurrence, now);
49
+ const firstRunAt = input.firstRunAt ?? computeNextRun(input.recurrence, now, tz);
48
50
  if (firstRunAt === null) {
49
51
  throw new Error(`enqueueCron(${input.name}): one-shot requires firstRunAt`);
50
52
  }
@@ -55,6 +57,7 @@ export function enqueueCron(input) {
55
57
  enqueueInto: input.enqueueInto,
56
58
  payload: JSON.stringify(input.payload),
57
59
  recurrence: input.recurrence,
60
+ timezone: tz,
58
61
  nextRunAt: firstRunAt,
59
62
  lastRunAt: null,
60
63
  enabled,
@@ -74,7 +77,8 @@ export function listDueCrons(asOf = Math.floor(Date.now() / 1000)) {
74
77
  }
75
78
  // Called by orchestrator after the cron's payload has been enqueued
76
79
  // into its target queue. Recurring crons get nextRunAt advanced;
77
- // one-shots get deleted.
80
+ // one-shots get deleted. Uses the row's stored timezone for @daily /
81
+ // @weekly resolution; falls back to owner tz for legacy rows.
78
82
  export function markCronFired(row) {
79
83
  const db = getDb();
80
84
  const now = Math.floor(Date.now() / 1000);
@@ -82,7 +86,8 @@ export function markCronFired(row) {
82
86
  db.delete(crons).where(eq(crons.id, row.id)).run();
83
87
  return;
84
88
  }
85
- const next = computeNextRun(row.recurrence, now);
89
+ const tz = row.timezone ?? config.owner.timezone;
90
+ const next = computeNextRun(row.recurrence, now, tz);
86
91
  if (next === null) {
87
92
  logger.error({ id: row.id, name: row.name, recurrence: row.recurrence }, 'cron has unparseable recurrence after firing; disabling');
88
93
  db.update(crons)
@@ -128,8 +133,10 @@ const DOW_INDEX = {
128
133
  sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
129
134
  };
130
135
  // Returns the next-run timestamp (unix seconds) for a recurrence, or
131
- // null for an unparseable format.
132
- export function computeNextRun(recurrence, nowSec) {
136
+ // null for an unparseable format. tz defaults to owner timezone but
137
+ // per-user crons should pass the sender's local tz so @daily HH:MM
138
+ // fires in the user's wall-clock time, not the server's.
139
+ export function computeNextRun(recurrence, nowSec, tz = config.owner.timezone) {
133
140
  if (!recurrence)
134
141
  return null;
135
142
  const everyMatch = EVERY_RE.exec(recurrence);
@@ -142,20 +149,19 @@ export function computeNextRun(recurrence, nowSec) {
142
149
  }
143
150
  const dailyMatch = DAILY_RE.exec(recurrence);
144
151
  if (dailyMatch) {
145
- return nextLocalHourMinute(nowSec, parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), null);
152
+ return nextLocalHourMinute(nowSec, parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), null, tz);
146
153
  }
147
154
  const weeklyMatch = WEEKLY_RE.exec(recurrence);
148
155
  if (weeklyMatch) {
149
156
  const dow = DOW_INDEX[weeklyMatch[1].toLowerCase()];
150
- return nextLocalHourMinute(nowSec, parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), dow);
157
+ return nextLocalHourMinute(nowSec, parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), dow, tz);
151
158
  }
152
159
  return null;
153
160
  }
154
- // Compute the next unix-seconds at HH:MM in the owner timezone,
161
+ // Compute the next unix-seconds at HH:MM in the given timezone,
155
162
  // optionally constrained to a day-of-week. Always returns a moment
156
163
  // strictly in the future (> nowSec).
157
- function nextLocalHourMinute(nowSec, hour, minute, dayOfWeek) {
158
- const tz = config.owner.timezone;
164
+ function nextLocalHourMinute(nowSec, hour, minute, dayOfWeek, tz) {
159
165
  const fmt = new Intl.DateTimeFormat('en-CA', {
160
166
  timeZone: tz,
161
167
  year: 'numeric', month: '2-digit', day: '2-digit',
@@ -0,0 +1,61 @@
1
+ // Helpers for the /reminders and /crons chat commands. Lists pending
2
+ // cron rows that target a particular chat address, formatted for
3
+ // reading on a phone.
4
+ import { and, asc, eq, like } from 'drizzle-orm';
5
+ import { getDb } from '../db/index.js';
6
+ import { crons } from '../db/schema.js';
7
+ import { formatLocalTime } from './time-expr.js';
8
+ // Pull crons whose payload targets the given chat address. We tag
9
+ // chat-emitted crons with names prefixed by 'chat-cron-' (recurring)
10
+ // or 'chat-remind-' (one-shot), so a LIKE filter on the JSON address
11
+ // is the simplest way to scope per chat.
12
+ export function listChatSchedules(chatAddress, kind) {
13
+ const db = getDb();
14
+ // SQLite has no JSON containment operator that's portable; use a
15
+ // simple substring search on the serialized payload. Payloads
16
+ // include `"address":"<addr>"` which is unique enough.
17
+ const addressNeedle = `%"address":"${chatAddress.replace(/"/g, '\\"')}"%`;
18
+ const rows = db
19
+ .select()
20
+ .from(crons)
21
+ .where(and(eq(crons.enabled, 1), like(crons.payload, addressNeedle)))
22
+ .orderBy(asc(crons.nextRunAt))
23
+ .all();
24
+ return rows
25
+ .filter((r) => kind === 'one-shot' ? r.recurrence === null : r.recurrence !== null)
26
+ .map((r) => ({
27
+ id: r.id,
28
+ name: r.name,
29
+ recurrence: r.recurrence,
30
+ nextRunAt: r.nextRunAt,
31
+ bodyPreview: extractBodyPreview(r.payload),
32
+ }));
33
+ }
34
+ function extractBodyPreview(payload) {
35
+ try {
36
+ const obj = JSON.parse(payload);
37
+ const t = obj.text ?? '';
38
+ return t.length > 80 ? t.slice(0, 77) + '...' : t;
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ export function formatScheduleList(items, tz, kind) {
45
+ if (items.length === 0) {
46
+ return kind === 'one-shot'
47
+ ? 'No reminders pending for this chat.'
48
+ : 'No recurring schedules for this chat.';
49
+ }
50
+ const header = kind === 'one-shot' ? '*Pending reminders*' : '*Recurring schedules*';
51
+ const lines = [header];
52
+ for (const item of items) {
53
+ const when = formatLocalTime(item.nextRunAt, tz);
54
+ const tail = item.recurrence ? ` · ${item.recurrence}` : '';
55
+ lines.push(` ${when}${tail}`);
56
+ if (item.bodyPreview)
57
+ lines.push(` "${item.bodyPreview}"`);
58
+ }
59
+ lines.push(`Timezone: ${tz}`);
60
+ return lines.join('\n');
61
+ }