@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.
- package/config/memory-instructions.md +61 -0
- package/dist/db/identity-sync.js +33 -1
- package/dist/db/schema.js +4 -0
- package/dist/estimates/async-task.js +29 -0
- package/dist/estimates/browser-task.js +49 -0
- package/dist/estimates/image-gen.js +7 -0
- package/dist/estimates/index.js +7 -1
- package/dist/estimates/registry.js +4 -1
- package/dist/gateway/commands.js +11 -0
- package/dist/gateway/outgoing.js +19 -1
- package/dist/memory/digest-flag.js +15 -9
- package/dist/memory/preamble.js +48 -0
- package/dist/queue/crons.js +16 -10
- package/dist/queue/schedule-list.js +61 -0
- package/dist/queue/time-expr.js +192 -0
- package/dist/queue/worker.js +78 -6
- package/migrations/0008_crons_timezone.sql +1 -0
- package/migrations/meta/0008_snapshot.json +931 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/queue/worker.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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({
|
|
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
|
|
290
|
+
firstRunAt,
|
|
234
291
|
});
|
|
235
|
-
logger.info({
|
|
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;
|