@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.
@@ -0,0 +1,192 @@
1
+ // Time expression parsing and resolution.
2
+ //
3
+ // Split into two stages so timezone application happens at resolve
4
+ // time, not parse time. The parser doesn't know whose timezone to
5
+ // use — it just produces a structured TimeExpression. The worker
6
+ // looks up the sender's timezone and calls resolveTimeExpression()
7
+ // to get a unix-second timestamp.
8
+ //
9
+ // Grammars supported (case-insensitive, trimmed):
10
+ // in <n>(s|m|h|d) relative
11
+ // in <n> second(s)|minute(s)|hour(s)|day(s)
12
+ // at <H>(:MM)?(am|pm)? today, user-tz
13
+ // tomorrow at <H>(:MM)?(am|pm)?
14
+ // <mon|tue|wed|thu|fri|sat|sun> at <H>(:MM)?(am|pm)?
15
+ // YYYY-MM-DD HH:MM ISO-style, user-tz
16
+ //
17
+ // Past times today (e.g. user says "at 9am" at 11am) shift to
18
+ // tomorrow. Weekday targets land on the NEXT occurrence of that
19
+ // weekday including today if it's still in the future.
20
+ const DOW_INDEX = {
21
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
22
+ };
23
+ const UNIT_SECONDS = {
24
+ s: 1, second: 1, seconds: 1,
25
+ m: 60, minute: 60, minutes: 60,
26
+ h: 3600, hour: 3600, hours: 3600,
27
+ d: 86400, day: 86400, days: 86400,
28
+ };
29
+ const REL_RE = /^in\s+(\d+)\s*([a-z]+)$/i;
30
+ const TODAY_RE = /^at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
31
+ const TOMORROW_RE = /^tomorrow(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
32
+ const WEEKDAY_RE = /^(mon|tue|wed|thu|fri|sat|sun)(?:day)?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i;
33
+ const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/;
34
+ export function parseTimeExpression(input) {
35
+ const s = input.trim();
36
+ const m1 = REL_RE.exec(s);
37
+ if (m1) {
38
+ const n = parseInt(m1[1], 10);
39
+ const unit = m1[2].toLowerCase();
40
+ const mult = UNIT_SECONDS[unit];
41
+ if (!mult || n <= 0)
42
+ return null;
43
+ return { kind: 'relative', seconds: n * mult };
44
+ }
45
+ const m2 = TODAY_RE.exec(s);
46
+ if (m2) {
47
+ const hm = parseHourMinute(m2[1], m2[2], m2[3]);
48
+ if (!hm)
49
+ return null;
50
+ return { kind: 'today', hour: hm.hour, minute: hm.minute };
51
+ }
52
+ const m3 = TOMORROW_RE.exec(s);
53
+ if (m3) {
54
+ const hm = parseHourMinute(m3[1], m3[2], m3[3]);
55
+ if (!hm)
56
+ return null;
57
+ return { kind: 'tomorrow', hour: hm.hour, minute: hm.minute };
58
+ }
59
+ const m4 = WEEKDAY_RE.exec(s);
60
+ if (m4) {
61
+ const dow = DOW_INDEX[m4[1].toLowerCase()];
62
+ if (dow === undefined)
63
+ return null;
64
+ const hm = parseHourMinute(m4[2], m4[3], m4[4]);
65
+ if (!hm)
66
+ return null;
67
+ return { kind: 'weekday', dayOfWeek: dow, hour: hm.hour, minute: hm.minute };
68
+ }
69
+ const m5 = ISO_RE.exec(s);
70
+ if (m5) {
71
+ const year = parseInt(m5[1], 10);
72
+ const month = parseInt(m5[2], 10);
73
+ const day = parseInt(m5[3], 10);
74
+ const hour = parseInt(m5[4], 10);
75
+ const minute = parseInt(m5[5], 10);
76
+ if (month < 1 || month > 12 || day < 1 || day > 31)
77
+ return null;
78
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
79
+ return null;
80
+ return { kind: 'iso', year, month, day, hour, minute };
81
+ }
82
+ return null;
83
+ }
84
+ function parseHourMinute(hStr, mStr, ampm) {
85
+ let h = parseInt(hStr, 10);
86
+ const m = mStr ? parseInt(mStr, 10) : 0;
87
+ if (m < 0 || m > 59)
88
+ return null;
89
+ if (ampm) {
90
+ const meridiem = ampm.toLowerCase();
91
+ if (h < 1 || h > 12)
92
+ return null;
93
+ if (meridiem === 'am')
94
+ h = h === 12 ? 0 : h;
95
+ else
96
+ h = h === 12 ? 12 : h + 12;
97
+ }
98
+ else {
99
+ // 24h form when no am/pm
100
+ if (h < 0 || h > 23)
101
+ return null;
102
+ }
103
+ return { hour: h, minute: m };
104
+ }
105
+ // Resolve to absolute unix seconds in the given timezone. Always
106
+ // returns a moment strictly in the future relative to `nowSec`.
107
+ export function resolveTimeExpression(expr, tz, nowSec) {
108
+ if (expr.kind === 'relative') {
109
+ return nowSec + expr.seconds;
110
+ }
111
+ if (expr.kind === 'today') {
112
+ const today = localCalendarDate(nowSec, tz);
113
+ const candidate = makeDateInTz(today.year, today.month, today.day, expr.hour, expr.minute, tz);
114
+ // If already past today, roll to tomorrow.
115
+ return candidate > nowSec
116
+ ? candidate
117
+ : makeDateInTz(today.year, today.month, today.day + 1, expr.hour, expr.minute, tz);
118
+ }
119
+ if (expr.kind === 'tomorrow') {
120
+ const today = localCalendarDate(nowSec, tz);
121
+ return makeDateInTz(today.year, today.month, today.day + 1, expr.hour, expr.minute, tz);
122
+ }
123
+ if (expr.kind === 'weekday') {
124
+ // Walk forward 0..7 days in user-tz until day-of-week matches AND
125
+ // the resulting moment is in the future.
126
+ const today = localCalendarDate(nowSec, tz);
127
+ for (let offset = 0; offset < 8; offset++) {
128
+ const candidate = makeDateInTz(today.year, today.month, today.day + offset, expr.hour, expr.minute, tz);
129
+ const candidateDow = localCalendarDate(candidate, tz).dayOfWeek;
130
+ if (candidateDow === expr.dayOfWeek && candidate > nowSec) {
131
+ return candidate;
132
+ }
133
+ }
134
+ // Shouldn't reach — fallback to a week from now.
135
+ return nowSec + 7 * 86400;
136
+ }
137
+ // ISO
138
+ return makeDateInTz(expr.year, expr.month - 1, expr.day, expr.hour, expr.minute, tz);
139
+ }
140
+ // Build a unix-seconds for a given Y/M/D HH:MM interpreted in a named
141
+ // timezone. Guess-and-correct via Intl.DateTimeFormat — same technique
142
+ // the cron parser uses. Handles DST seamlessly.
143
+ function makeDateInTz(year, month, day, hour, minute, tz) {
144
+ const guessUtcMs = Date.UTC(year, month, day, hour, minute, 0);
145
+ const fmt = new Intl.DateTimeFormat('en-US', {
146
+ timeZone: tz,
147
+ year: 'numeric', month: '2-digit', day: '2-digit',
148
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
149
+ hour12: false,
150
+ });
151
+ const parts = fmt.formatToParts(new Date(guessUtcMs));
152
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '0';
153
+ let parsedHour = parseInt(get('hour'), 10);
154
+ // Intl formats midnight as "24" in some locale/version combos; normalize.
155
+ if (parsedHour === 24)
156
+ parsedHour = 0;
157
+ const renderedUtcMs = Date.UTC(parseInt(get('year'), 10), parseInt(get('month'), 10) - 1, parseInt(get('day'), 10), parsedHour, parseInt(get('minute'), 10), parseInt(get('second'), 10));
158
+ const offsetMs = guessUtcMs - renderedUtcMs;
159
+ return Math.floor((guessUtcMs + offsetMs) / 1000);
160
+ }
161
+ // Decompose a unix-second into Y/M/D and day-of-week in a named
162
+ // timezone. Returns calendar fields as the local user would see
163
+ // them — used to compute "today" anchors before applying HH:MM.
164
+ function localCalendarDate(sec, tz) {
165
+ const fmt = new Intl.DateTimeFormat('en-CA', {
166
+ timeZone: tz,
167
+ year: 'numeric', month: '2-digit', day: '2-digit',
168
+ weekday: 'short',
169
+ });
170
+ const parts = fmt.formatToParts(new Date(sec * 1000));
171
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
172
+ return {
173
+ year: parseInt(get('year'), 10),
174
+ month: parseInt(get('month'), 10) - 1, // 0-indexed for makeDateInTz Date.UTC compat
175
+ day: parseInt(get('day'), 10),
176
+ dayOfWeek: (DOW_INDEX[get('weekday').toLowerCase()] ?? 0),
177
+ };
178
+ }
179
+ // Human-readable formatter used in chat acks. Renders an absolute
180
+ // resolved time back into the user's local time.
181
+ export function formatLocalTime(unixSec, tz) {
182
+ return new Intl.DateTimeFormat('en-GB', {
183
+ timeZone: tz,
184
+ weekday: 'short',
185
+ year: 'numeric',
186
+ month: 'short',
187
+ day: '2-digit',
188
+ hour: '2-digit',
189
+ minute: '2-digit',
190
+ hour12: false,
191
+ }).format(new Date(unixSec * 1000));
192
+ }
@@ -4,12 +4,15 @@ import { config } from '../config.js';
4
4
  import { formatAddress, jidToAddress } from '../db/address.js';
5
5
  import { logger } from '../logger.js';
6
6
  import { addDailyTokens } from '../store/usage.js';
7
+ import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
8
+ import { estimate as estimateJob } from '../estimates/index.js';
7
9
  import { extractFlags, filterFlagsByRole } from '../memory/digest-flag.js';
8
10
  import { isValidSlug } from '../memory/journals.js';
9
11
  import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
10
12
  import { enqueueCron } from './crons.js';
11
13
  import { enqueueMemoryWrite } from './memory-writes.js';
12
14
  import { enqueueOutbound } from './outbound.js';
15
+ import { formatLocalTime, resolveTimeExpression } from './time-expr.js';
13
16
  function isStaleSessionError(err) {
14
17
  return (err instanceof Error &&
15
18
  err.message.includes('No conversation found'));
@@ -171,7 +174,15 @@ async function callClaude(job) {
171
174
  // [ASYNC:...] → general lane, stateless, concurrency 3, non-browser work
172
175
  // [ASYNC-BROWSER:...] → browser lane, persistent session, concurrency 1
173
176
  // Both report back via initiate() when done.
174
- for (const t of asyncTasks) {
177
+ //
178
+ // For each delegation, we also build a "job card" — a short ETA
179
+ // message that handleReply will emit after the agent's reply
180
+ // chunks. Gives the user a visible "doing X, ~Y min" instead of
181
+ // wondering whether anything's happening.
182
+ const jobCards = [];
183
+ const cardBase = `card-${job.jid}-${Date.now()}`;
184
+ for (let i = 0; i < asyncTasks.length; i++) {
185
+ const t = asyncTasks[i];
175
186
  enqueueAsyncTask({
176
187
  jid: job.jid,
177
188
  senderNumber: job.senderNumber,
@@ -179,8 +190,19 @@ async function callClaude(job) {
179
190
  originatingMessage: job.text,
180
191
  allowedTools: job.allowedTools ?? 'all',
181
192
  });
193
+ const est = estimateJob({
194
+ description: t.description,
195
+ taskKind: 'async',
196
+ });
197
+ if (est) {
198
+ jobCards.push({
199
+ text: formatJobCard(est.text, t.description),
200
+ idempotencyKey: `${cardBase}-async-${i}`,
201
+ });
202
+ }
182
203
  }
183
- for (const t of asyncBrowserTasks) {
204
+ for (let i = 0; i < asyncBrowserTasks.length; i++) {
205
+ const t = asyncBrowserTasks[i];
184
206
  enqueueBrowserTask({
185
207
  jid: job.jid,
186
208
  senderNumber: job.senderNumber,
@@ -188,6 +210,16 @@ async function callClaude(job) {
188
210
  originatingMessage: job.text,
189
211
  allowedTools: job.allowedTools ?? 'all',
190
212
  });
213
+ const est = estimateJob({
214
+ description: t.description,
215
+ taskKind: 'async-browser',
216
+ });
217
+ if (est) {
218
+ jobCards.push({
219
+ text: formatJobCard(est.text, t.description),
220
+ idempotencyKey: `${cardBase}-browser-${i}`,
221
+ });
222
+ }
191
223
  }
192
224
  // SEND-TEXT: cross-chat text send. Agent specified the destination
193
225
  // address explicitly. Just drops a row in outbound; sender worker
@@ -204,8 +236,12 @@ async function callClaude(job) {
204
236
  }
205
237
  // [CRON: @every X — body] and [REMIND: in Nu — body] create cron
206
238
  // rows that fire into outbound at their scheduled time. The
207
- // originating chat (job.jid) is the destination for both.
239
+ // originating chat (job.jid) is the destination for both. Sender's
240
+ // timezone drives "9am" / "today at..." resolution so the schedule
241
+ // lands in their wall-clock time, not the server's.
208
242
  const chatAddress = formatAddress(jidToAddress(job.jid));
243
+ const senderTz = getTimezoneForSenderNumber(job.senderNumber);
244
+ const nowSec = Math.floor(Date.now() / 1000);
209
245
  const cronBase = `chat-cron-${job.jid}-${Date.now()}`;
210
246
  for (let i = 0; i < crons.length; i++) {
211
247
  const c = crons[i];
@@ -215,24 +251,50 @@ async function callClaude(job) {
215
251
  enqueueInto: 'outbound',
216
252
  payload: { address: chatAddress, kind: 'text', text: c.body },
217
253
  recurrence: c.recurrence,
254
+ // Sender's local timezone so @daily HH:MM fires in their
255
+ // wall-clock time, not the server's.
256
+ timezone: senderTz,
218
257
  });
219
- logger.info({ jid: job.jid, recurrence: c.recurrence, chars: c.body.length }, 'CRON tag scheduled');
258
+ logger.info({
259
+ jid: job.jid,
260
+ recurrence: c.recurrence,
261
+ tz: senderTz,
262
+ chars: c.body.length,
263
+ }, 'CRON tag scheduled');
220
264
  }
221
265
  catch (err) {
222
266
  logger.warn({ err, jid: job.jid, recurrence: c.recurrence }, 'CRON tag failed (bad recurrence?)');
223
267
  }
224
268
  }
269
+ // REMIND resolution uses the same senderTz computed above.
225
270
  const remindBase = `chat-remind-${job.jid}-${Date.now()}`;
226
271
  for (let i = 0; i < reminds.length; i++) {
227
272
  const r = reminds[i];
273
+ let firstRunAt;
274
+ try {
275
+ firstRunAt = resolveTimeExpression(r.when, senderTz, nowSec);
276
+ }
277
+ catch (err) {
278
+ logger.warn({ err, jid: job.jid, when: r.when }, 'REMIND time resolution failed');
279
+ continue;
280
+ }
281
+ if (firstRunAt <= nowSec) {
282
+ logger.warn({ jid: job.jid, when: r.when, firstRunAt }, 'REMIND resolved to past — skipped');
283
+ continue;
284
+ }
228
285
  enqueueCron({
229
286
  name: `${remindBase}-${i}`,
230
287
  enqueueInto: 'outbound',
231
288
  payload: { address: chatAddress, kind: 'text', text: r.body },
232
289
  recurrence: null,
233
- firstRunAt: Math.floor(Date.now() / 1000) + r.whenSecondsFromNow,
290
+ firstRunAt,
234
291
  });
235
- logger.info({ jid: job.jid, inSeconds: r.whenSecondsFromNow, chars: r.body.length }, 'REMIND tag scheduled');
292
+ logger.info({
293
+ jid: job.jid,
294
+ fires: formatLocalTime(firstRunAt, senderTz),
295
+ tz: senderTz,
296
+ chars: r.body.length,
297
+ }, 'REMIND tag scheduled');
236
298
  }
237
299
  return {
238
300
  reply: clean,
@@ -250,8 +312,18 @@ async function callClaude(job) {
250
312
  journalSlugs: journals.map((j) => j.slug),
251
313
  asyncCount: asyncTasks.length + asyncBrowserTasks.length,
252
314
  },
315
+ jobCards: jobCards.length > 0 ? jobCards : undefined,
253
316
  };
254
317
  }
318
+ // Compact card text. Emoji + ETA + a brief excerpt of what the agent
319
+ // delegated, so the user knows which job each card refers to when
320
+ // multiple are running.
321
+ function formatJobCard(etaText, description) {
322
+ const excerpt = description.length > 100
323
+ ? description.slice(0, 97) + '...'
324
+ : description;
325
+ return `🔄 ${etaText}\n${excerpt}`;
326
+ }
255
327
  function titleCase(slug) {
256
328
  return slug
257
329
  .split('-')
@@ -0,0 +1 @@
1
+ ALTER TABLE `crons` ADD `timezone` text;