@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
|
@@ -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.
|
package/dist/db/identity-sync.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/estimates/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/gateway/commands.js
CHANGED
|
@@ -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
|
}
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -108,7 +108,25 @@ export async function handleReply(job, result, _originalMsg) {
|
|
|
108
108
|
enqueuePiece({ address, kind: 'text', text: chunkForSend });
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
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
|
|
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
|
|
191
|
-
if (!
|
|
201
|
+
const when = parseTimeExpression(timeSpec);
|
|
202
|
+
if (!when)
|
|
192
203
|
return null;
|
|
193
|
-
|
|
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.
|
package/dist/memory/preamble.js
CHANGED
|
@@ -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);
|
package/dist/queue/crons.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|