talon-agent 3.25.2 → 3.25.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.25.2",
3
+ "version": "3.25.3",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -46,6 +46,25 @@ the file path is in the message, so you can read it immediately.
46
46
  - No forum topics, no custom admin titles, no stopping a poll.
47
47
  - Pins expire (24h, 7d, or 30d) and only pins Talon placed are listable.
48
48
 
49
+ ### Continuity — your context is not the chat
50
+
51
+ Your working context can start fresh at any time (a `/reset`, a daemon
52
+ restart, an overflowed session) while the WhatsApp conversation continues
53
+ uninterrupted. The chat's real record lives in persistent history, both
54
+ sides of it, and you can always get it back:
55
+
56
+ - `read_chat_history` — the recent messages; pass `offset_id` (a msg_id)
57
+ or `before` (an ISO date) to page further back.
58
+ - `search_chat_history` — keyword search over everything said in this
59
+ chat, including your own earlier messages.
60
+ - `get_message_by_id` — one specific message when you hold its id.
61
+
62
+ If the sender references something you don't recognize — "the flights",
63
+ "what you said yesterday", "that plan" — search the history before
64
+ saying you don't know or asking them to repeat themselves. A fresh
65
+ session is your situation, not theirs; to the person on the phone this
66
+ is one long conversation, and it should read like you remember it.
67
+
49
68
  ### Staying silent
50
69
 
51
70
  Reactions work here, so a react is the light acknowledgement when a
package/src/bootstrap.ts CHANGED
@@ -27,6 +27,7 @@ import { appendToJournal } from "./storage/journal.js";
27
27
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
28
28
  import { initCron } from "./core/background/cron.js";
29
29
  import { initPlanAlerts } from "./core/background/plan-alerts.js";
30
+ import { setAdminNotifier } from "./core/notify.js";
30
31
  import {
31
32
  initTriggers,
32
33
  resumeAfterRestart as resumeTriggersAfterRestart,
@@ -442,6 +443,22 @@ export async function initBackendAndDispatcher(
442
443
  (config.adminUserId ? String(config.adminUserId) : undefined),
443
444
  });
444
445
 
446
+ // Admin notification seam (core/notify.ts) — how a subsystem reaches
447
+ // the operator when its own channel is the thing that is broken (the
448
+ // first consumer is WhatsApp pairing: codes must travel over a LIVE
449
+ // frontend, not the dead one's log). Same delivery route as the plan
450
+ // alerts above.
451
+ if (config.adminUserId) {
452
+ const adminChatId = config.adminUserId;
453
+ setAdminNotifier(async (text: string) =>
454
+ resolveFrontendByNumericId(
455
+ adminChatId,
456
+ String(adminChatId),
457
+ frontends,
458
+ ).sendMessage(adminChatId, text),
459
+ );
460
+ }
461
+
445
462
  // Soul — initialize the identity kernel singleton from config so the prompt
446
463
  // injection / dream hooks see the right enabled state. Off by default; a
447
464
  // failure here must never block startup.
@@ -5,6 +5,8 @@
5
5
 
6
6
  import {
7
7
  getRecentFormatted,
8
+ getFormattedBefore,
9
+ getFormattedBeforeTime,
8
10
  searchHistory,
9
11
  getMessagesByUser,
10
12
  getKnownUsers,
@@ -15,7 +17,23 @@ import type { SharedActionHandlers } from "./types.js";
15
17
  export const historyHandlers: SharedActionHandlers = {
16
18
  read_history: (body, chatId) => {
17
19
  const limit = Math.min(100, Number(body.limit ?? 30));
18
- return { ok: true, text: getRecentFormatted(String(chatId), limit) };
20
+ const cid = String(chatId);
21
+ // The tool schema advertises `offset_id` and `before` for paging back;
22
+ // this fallback used to ignore both and hand back the same newest
23
+ // window no matter what the model asked, so "go further back" was a
24
+ // silent no-op on frontends without a platform history API (WhatsApp,
25
+ // native).
26
+ const offsetId = Number(body.offset_id);
27
+ if (Number.isFinite(offsetId) && offsetId > 0) {
28
+ return { ok: true, text: getFormattedBefore(cid, offsetId, limit) };
29
+ }
30
+ if (body.before !== undefined) {
31
+ const ts = Date.parse(String(body.before));
32
+ if (Number.isFinite(ts)) {
33
+ return { ok: true, text: getFormattedBeforeTime(cid, ts, limit) };
34
+ }
35
+ }
36
+ return { ok: true, text: getRecentFormatted(cid, limit) };
19
37
  },
20
38
 
21
39
  search_history: (body, chatId) => {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Admin notification seam — a way for any subsystem to reach the human
3
+ * operator on their primary chat (adminUserId, usually Telegram).
4
+ *
5
+ * Core cannot import frontends, so the composition root injects the
6
+ * delivery function at boot (bootstrap.ts, next to the plan-alerts
7
+ * wiring, which does the same thing privately). Unwired — early boot,
8
+ * tests, terminal mode with no admin — notifications degrade to a log
9
+ * line rather than throwing.
10
+ *
11
+ * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
12
+ * recovery needs a human to type a pairing code into the phone — a code
13
+ * that previously only ever appeared in the daemon log, which nobody
14
+ * watches. Alerts about a dead frontend must travel over a LIVE one.
15
+ */
16
+
17
+ import { log, logWarn } from "./../util/log.js";
18
+
19
+ let deliver: ((text: string) => Promise<void>) | null = null;
20
+
21
+ /** Wire (or clear) the delivery function. Called by the composition root. */
22
+ export function setAdminNotifier(
23
+ fn: ((text: string) => Promise<void>) | null,
24
+ ): void {
25
+ deliver = fn;
26
+ }
27
+
28
+ /**
29
+ * Send `text` to the admin chat. Never throws; returns whether delivery
30
+ * was attempted (false = no notifier wired).
31
+ */
32
+ export async function notifyAdmin(text: string): Promise<boolean> {
33
+ if (!deliver) {
34
+ logWarn(
35
+ "notify",
36
+ `No admin notifier wired; dropping: ${text.slice(0, 120)}`,
37
+ );
38
+ return false;
39
+ }
40
+ try {
41
+ await deliver(text);
42
+ log("notify", `Admin notified: ${text.slice(0, 80).replace(/\n/g, " ")}`);
43
+ return true;
44
+ } catch (err) {
45
+ log(
46
+ "notify",
47
+ `Admin notification failed: ${err instanceof Error ? err.message : err}`,
48
+ );
49
+ return false;
50
+ }
51
+ }
@@ -44,6 +44,7 @@ import { formatDuration } from "./format.js";
44
44
  export async function performSessionReset(
45
45
  chatId: string,
46
46
  backend: Backend | null | undefined,
47
+ opts: { keepHistory?: boolean } = {},
47
48
  ): Promise<void> {
48
49
  const info = getSessionInfo(chatId);
49
50
  if (info.turns > 0) {
@@ -59,7 +60,12 @@ export async function performSessionReset(
59
60
  );
60
61
  }
61
62
  resetSession(chatId);
62
- clearHistory(chatId);
63
+ // Frontends whose platform keeps the real chat record (Telegram,
64
+ // Discord) clear the local mirror too — the platform still has
65
+ // everything. WhatsApp passes keepHistory: the local store is the ONLY
66
+ // record there, and wiping it on /reset would destroy exactly what the
67
+ // continuity tools (read/search_chat_history) exist to recover.
68
+ if (!opts.keepHistory) clearHistory(chatId);
63
69
  resetPulseCheckpoint(chatId);
64
70
  backend?.sessions?.resetChat?.(chatId);
65
71
  await backend?.sessions?.warmSession?.(chatId);
@@ -12,6 +12,7 @@ import { log, logError } from "../../../util/log.js";
12
12
  import type { ActionResult } from "../../../core/types.js";
13
13
  import { toWhatsAppChunks } from "../formatting.js";
14
14
  import { lookupMessage, rememberMessage } from "../message-store.js";
15
+ import { pushMessage, type HistoryMessage } from "../../../storage/history.js";
15
16
  import type { WhatsAppChatInfo } from "../registry.js";
16
17
 
17
18
  /** WhatsApp's own ceiling for a media upload. */
@@ -128,6 +129,40 @@ export function resolveQuoted(
128
129
  );
129
130
  }
130
131
 
132
+ /**
133
+ * How the bot signs its own rows in persistent history. Set once at
134
+ * frontend start from `config.botDisplayName`; the 0 sender id is the
135
+ * cross-frontend "this is the assistant" convention (native/protocol.ts).
136
+ */
137
+ let botName = "Talon";
138
+ export function setWhatsAppBotName(name: string): void {
139
+ if (name.trim()) botName = name.trim();
140
+ }
141
+
142
+ /**
143
+ * History labels for outbound media, in HistoryMessage's closed
144
+ * vocabulary. Payloads with no file analogue (polls, locations, contact
145
+ * cards) return a text marker instead via `outboundTextMarker`.
146
+ */
147
+ function outboundMediaType(
148
+ content: AnyMessageContent,
149
+ ): NonNullable<HistoryMessage["mediaType"]> | undefined {
150
+ if ("image" in content) return "photo";
151
+ if ("video" in content) return content.gifPlayback ? "animation" : "video";
152
+ if ("audio" in content) return "voice";
153
+ if ("sticker" in content) return "sticker";
154
+ if ("document" in content) return "document";
155
+ return undefined;
156
+ }
157
+
158
+ /** A readable stand-in for captionless payloads history can't type. */
159
+ function outboundTextMarker(content: AnyMessageContent): string {
160
+ if ("poll" in content) return `[poll: ${content.poll.name}]`;
161
+ if ("location" in content) return "[location]";
162
+ if ("contacts" in content) return "[contact card]";
163
+ return "";
164
+ }
165
+
131
166
  /**
132
167
  * Send one content payload, remember the resulting message so later
133
168
  * tool calls can address it, and report its Talon numeric id.
@@ -141,13 +176,35 @@ export async function sendContent(
141
176
  const sent = await ctx.sock.sendMessage(chat.jid, content, options);
142
177
  ctx.gateway.incrementMessages(chat.numericChatId);
143
178
  if (!sent?.key) return { ok: true };
179
+ const text =
180
+ "text" in content
181
+ ? String(content.text ?? "")
182
+ : "caption" in content && content.caption
183
+ ? String(content.caption)
184
+ : outboundTextMarker(content);
144
185
  const msgId = rememberMessage({
145
186
  key: sent.key,
146
187
  chatId: chat.chatId,
147
188
  message: sent,
148
- text: "text" in content ? String(content.text ?? "") : "",
189
+ text,
149
190
  senderName: "bot",
150
191
  });
192
+ // Persist the bot's side of the conversation. Without this the history
193
+ // store held only inbound messages, so read_chat_history showed a
194
+ // one-sided chat and search_chat_history could never find anything the
195
+ // bot itself had said — exactly the messages a fresh session needs when
196
+ // reconstructing context after a reset or restart.
197
+ const mediaType = outboundMediaType(content);
198
+ if (text || mediaType) {
199
+ pushMessage(chat.chatId, {
200
+ msgId,
201
+ senderId: 0,
202
+ senderName: botName,
203
+ text,
204
+ timestamp: Date.now(),
205
+ ...(mediaType ? { mediaType } : {}),
206
+ });
207
+ }
151
208
  return { ok: true, message_id: msgId };
152
209
  }
153
210
 
@@ -33,12 +33,10 @@ import { toolInputToRecord } from "../../core/agent-runtime/events.js";
33
33
  import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
34
34
  import { performSessionReset } from "../shared/session-status.js";
35
35
  import { appendDailyLog } from "../../storage/daily-log.js";
36
- import { pushMessage } from "../../storage/history.js";
36
+ import { pushMessage, maxMsgIdForChatPrefix } from "../../storage/history.js";
37
37
  import {
38
- recordError,
39
38
  recordMessageProcessed,
40
39
  recordMessageReceived,
41
- recordMessageSettled,
42
40
  } from "../../util/watchdog.js";
43
41
  import { createWhatsAppActionHandler } from "./actions/index.js";
44
42
  import {
@@ -47,9 +45,16 @@ import {
47
45
  identityAllowed,
48
46
  resolveIdentity,
49
47
  } from "./identity.js";
50
- import { sendText } from "./actions/shared.js";
48
+ import { sendText, setWhatsAppBotName } from "./actions/shared.js";
51
49
  import { saveInboundMedia } from "./media-store.js";
52
- import { lookupByWaId, rememberMessage } from "./message-store.js";
50
+ import {
51
+ lookupByWaId,
52
+ rememberMessage,
53
+ seedMessageStore,
54
+ } from "./message-store.js";
55
+ import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
56
+ import { nextPairingDelayMs, shouldNotifyPairingCode } from "./pairing.js";
57
+ import { notifyAdmin } from "../../core/notify.js";
53
58
  import {
54
59
  lookupWhatsAppChat,
55
60
  registerWhatsAppChat,
@@ -151,6 +156,11 @@ export function createWhatsAppFrontend(
151
156
  let sock: WASocket | null = null;
152
157
  let stopping = false;
153
158
  let reconnectDelay = RECONNECT_BASE_MS;
159
+ /** Consecutive failed pairing cycles since the last successful open. */
160
+ let failedPairingCycles = 0;
161
+ /** Codes issued this outage — throttles the admin notifications. */
162
+ let pairingCodesIssued = 0;
163
+ let lastPairingNotifyAt: number | undefined;
154
164
  /** Our own ids (phone and LID), once connected — for mention detection. */
155
165
  let selfIds: string[] = [];
156
166
 
@@ -217,7 +227,10 @@ export function createWhatsAppFrontend(
217
227
  return Boolean(ctx?.participant && isSelf(ctx.participant));
218
228
  }
219
229
 
220
- async function handleInbound(msg: WAMessage): Promise<void> {
230
+ async function handleInbound(
231
+ msg: WAMessage,
232
+ opts: { catchUp?: boolean } = {},
233
+ ): Promise<void> {
221
234
  const jid = msg.key.remoteJid;
222
235
  // `fromMe` covers our own sends echoing back; status@broadcast is the
223
236
  // Stories feed, which is not a conversation.
@@ -278,13 +291,17 @@ export function createWhatsAppFrontend(
278
291
  const replyToWaId =
279
292
  msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
280
293
  const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
294
+ const platformTs = Number(msg.messageTimestamp) * 1000;
281
295
  pushMessage(chat.chatId, {
282
296
  msgId,
283
297
  senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
284
298
  senderName,
285
299
  senderHandle: canonicalId(identity),
286
300
  text,
287
- timestamp: Date.now(),
301
+ // The platform timestamp, so a catch-up message recorded late still
302
+ // reads in true order; Date.now() only when Baileys omits it.
303
+ timestamp:
304
+ Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
288
305
  ...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
289
306
  ...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
290
307
  });
@@ -295,6 +312,9 @@ export function createWhatsAppFrontend(
295
312
  await performSessionReset(
296
313
  chat.chatId,
297
314
  resolveChatBackend(chat.chatId, gateway.backend),
315
+ // The local history store is WhatsApp's only chat record — a
316
+ // reset clears the model's session, not the conversation log.
317
+ { keepHistory: true },
298
318
  );
299
319
  log("whatsapp", `Session reset by ${senderName}`);
300
320
  if (sock) {
@@ -310,13 +330,31 @@ export function createWhatsAppFrontend(
310
330
  await sendText(
311
331
  { sock, gateway },
312
332
  chat,
313
- "*Commands*\n/reset — clear session & history\n/help — this message",
333
+ "*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
314
334
  ).catch(() => {});
315
335
  }
316
336
  recordMessageProcessed();
317
337
  return;
318
338
  }
319
339
 
340
+ // Catch-up messages (queued while the daemon was down) get a reply
341
+ // turn only while fresh; stale ones are already recorded above and
342
+ // the next live turn reads them from history.
343
+ if (opts.catchUp) {
344
+ if (!shouldReplyToCatchUp(platformTs)) {
345
+ log(
346
+ "whatsapp",
347
+ `[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
348
+ );
349
+ recordMessageProcessed();
350
+ return;
351
+ }
352
+ log(
353
+ "whatsapp",
354
+ `[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
355
+ );
356
+ }
357
+
320
358
  const preview = text || `(${media?.type ?? "media"})`;
321
359
  log(
322
360
  "whatsapp",
@@ -334,8 +372,8 @@ export function createWhatsAppFrontend(
334
372
  : "";
335
373
  const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
336
374
 
337
- try {
338
- await execute({
375
+ const runTurn = () =>
376
+ execute({
339
377
  chatId: chat.chatId,
340
378
  numericChatId: chat.numericChatId,
341
379
  prompt,
@@ -374,13 +412,23 @@ export function createWhatsAppFrontend(
374
412
  }
375
413
  },
376
414
  });
377
- recordMessageProcessed();
378
- } catch (err) {
379
- const message = err instanceof Error ? err.message : String(err);
380
- logError("whatsapp", `[${chat.chatId}] execute failed: ${message}`);
381
- recordError(message);
382
- recordMessageSettled();
383
- }
415
+
416
+ await runTurnWithRecovery({
417
+ chatId: chat.chatId,
418
+ senderName,
419
+ runTurn,
420
+ sendErrorText: async (text) => {
421
+ if (!sock) return;
422
+ try {
423
+ await sendText({ sock, gateway }, chat, text);
424
+ } catch (sendErr) {
425
+ logError(
426
+ "whatsapp",
427
+ `error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
428
+ );
429
+ }
430
+ },
431
+ });
384
432
  }
385
433
 
386
434
  /** One socket lifetime. Resolves with what the caller should do next. */
@@ -392,16 +440,28 @@ export function createWhatsAppFrontend(
392
440
  markOnlineOnConnect: false,
393
441
  // The account is a bot: announcing "online" would suppress the
394
442
  // phone's own notifications for the human who owns the number.
443
+ //
444
+ // 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
445
+ // a ~2½-minute socket lifetime in pairing mode, shorter than it
446
+ // takes a human to pick up their phone and type the code.
447
+ qrTimeout: 120_000,
395
448
  });
396
449
  sock = socket;
397
450
  socket.ev.on("creds.update", saveCreds);
398
451
 
399
452
  socket.ev.on("messages.upsert", ({ messages, type }) => {
400
- // "notify" is a live message; "append" is history sync, which must
401
- // not trigger turns for conversations that already happened.
402
- if (type !== "notify") return;
453
+ // "notify" is a live message. "append" is everything delivered out
454
+ // of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
455
+ // (Baileys marks offline-queued nodes as append), but also our own
456
+ // sends echoing back and newsletter posts, which handleInbound's
457
+ // fromMe/allowlist gates drop. Dropping append wholesale meant any
458
+ // message sent during a restart simply vanished: never recorded,
459
+ // never answered. Appends are processed as catch-up: always
460
+ // recorded, replied to only while fresh.
461
+ if (type !== "notify" && type !== "append") return;
462
+ const catchUp = type === "append";
403
463
  for (const msg of messages) {
404
- void handleInbound(msg).catch((err) => {
464
+ void handleInbound(msg, { catchUp }).catch((err) => {
405
465
  logError(
406
466
  "whatsapp",
407
467
  `inbound handler failed: ${err instanceof Error ? err.message : err}`,
@@ -420,13 +480,32 @@ export function createWhatsAppFrontend(
420
480
  pairingRequested = true;
421
481
  socket
422
482
  .requestPairingCode(bareId(settings.pairingNumber))
423
- .then((code) =>
483
+ .then((code) => {
424
484
  log(
425
485
  "whatsapp",
426
486
  `Pairing code: ${code} — enter it on ${settings.pairingNumber} ` +
427
487
  `via WhatsApp → Linked devices → Link with phone number`,
428
- ),
429
- )
488
+ );
489
+ // The daemon log is where pairing codes go to die — the
490
+ // human who has to type this is on another frontend.
491
+ pairingCodesIssued++;
492
+ if (
493
+ shouldNotifyPairingCode(
494
+ pairingCodesIssued,
495
+ lastPairingNotifyAt,
496
+ )
497
+ ) {
498
+ lastPairingNotifyAt = Date.now();
499
+ void notifyAdmin(
500
+ `📱 WhatsApp needs re-pairing.\n` +
501
+ `Code: ${code}\n` +
502
+ `On the phone with ${settings.pairingNumber}: WhatsApp → ` +
503
+ `Linked devices → Link with phone number.\n` +
504
+ `Valid for a few minutes; if it expires, the next code ` +
505
+ `arrives automatically.`,
506
+ );
507
+ }
508
+ })
430
509
  .catch((err) =>
431
510
  logError(
432
511
  "whatsapp",
@@ -448,6 +527,12 @@ export function createWhatsAppFrontend(
448
527
  .filter((id): id is string => Boolean(id))
449
528
  .map(bareId);
450
529
  reconnectDelay = RECONNECT_BASE_MS;
530
+ if (pairingCodesIssued > 0) {
531
+ void notifyAdmin("✅ WhatsApp re-linked and connected.");
532
+ }
533
+ failedPairingCycles = 0;
534
+ pairingCodesIssued = 0;
535
+ lastPairingNotifyAt = undefined;
451
536
  log(
452
537
  "whatsapp",
453
538
  `Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
@@ -498,6 +583,12 @@ export function createWhatsAppFrontend(
498
583
  getBridgePort: () => gateway.getPort(),
499
584
 
500
585
  async init() {
586
+ setWhatsAppBotName(config.botDisplayName);
587
+ // The in-memory message-id counter restarts at its base every boot,
588
+ // but history persists — seed it past what the table already holds
589
+ // so post-restart messages don't re-issue ids INSERT OR IGNORE then
590
+ // silently drops (chat ids all start with "wa_").
591
+ seedMessageStore((maxMsgIdForChatPrefix("wa_") ?? 0) + 1);
501
592
  gateway.registerFrontendHandler(
502
593
  "whatsapp",
503
594
  createWhatsAppActionHandler(() => sock, gateway),
@@ -529,8 +620,29 @@ export function createWhatsAppFrontend(
529
620
  "whatsapp",
530
621
  "Logged out by WhatsApp — clearing auth state, re-pairing",
531
622
  );
623
+ if (failedPairingCycles === 0 && pairingCodesIssued === 0) {
624
+ void notifyAdmin(
625
+ "⚠️ WhatsApp unlinked this device (logged out). " +
626
+ "Re-pairing — a pairing code follows.",
627
+ );
628
+ }
532
629
  rmSync(dirs.whatsappAuth, { recursive: true, force: true });
630
+ // Pairing needs a HUMAN to type a code, so this is not a
631
+ // network-blip backoff: retry quickly once, then space cycles
632
+ // out (5→10→20→30-min cap). The old immediate loop burned a
633
+ // fresh code every ~2½ minutes forever — each invalidating the
634
+ // last, at exactly the cadence WhatsApp rate-limits.
635
+ failedPairingCycles++;
636
+ const pairingDelay = nextPairingDelayMs(failedPairingCycles - 1);
637
+ if (pairingDelay > 0) {
638
+ log(
639
+ "whatsapp",
640
+ `Next pairing attempt in ${Math.round(pairingDelay / 60_000)}m`,
641
+ );
642
+ await new Promise((r) => setTimeout(r, pairingDelay));
643
+ }
533
644
  reconnectDelay = RECONNECT_BASE_MS;
645
+ continue;
534
646
  }
535
647
  await new Promise((r) => setTimeout(r, reconnectDelay));
536
648
  reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
@@ -41,6 +41,22 @@ const ID_BASE = 1_000_000;
41
41
  const MAX_TRACKED = 2_000;
42
42
 
43
43
  let nextId = ID_BASE;
44
+
45
+ /**
46
+ * Raise the id counter past what persistent history already holds.
47
+ *
48
+ * The counter is in-memory and restarts at ID_BASE every boot, but the
49
+ * ids it hands out are also the `msg_id`s written to the history table,
50
+ * where `INSERT OR IGNORE` + UNIQUE(chat_id, msg_id) dedupes. Without
51
+ * this seed, the first messages after a daemon restart re-issue ids the
52
+ * previous run already used — the IGNORE then silently drops them from
53
+ * history, and a reaction/reply addressed at an old id from history hits
54
+ * whatever new message reused the number. Called at frontend start with
55
+ * max(msg_id) over wa_* chats + 1.
56
+ */
57
+ export function seedMessageStore(floor: number): void {
58
+ if (Number.isFinite(floor)) nextId = Math.max(nextId, Math.floor(floor));
59
+ }
44
60
  const byMsgId = new Map<number, StoredMessage>();
45
61
  const byWaId = new Map<string, number>();
46
62
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pairing-recovery policy for the WhatsApp frontend.
3
+ *
4
+ * When WhatsApp unlinks the device (stream conflict `device_removed`,
5
+ * a manual unlink, an account ban), recovery needs a human: someone must
6
+ * enter a pairing code on the phone. The old loop treated a logout like
7
+ * a network blip — wipe auth, reconnect immediately, request a fresh
8
+ * code, time out, repeat — which generated a new code every ~2½ minutes
9
+ * forever (26 codes in 80 minutes on the live deployment), each one
10
+ * invalidating the last, all of them visible only in the daemon log.
11
+ * That cadence is also exactly the shape WhatsApp rate-limits.
12
+ *
13
+ * Policy, kept pure here so it is testable without a socket:
14
+ * - retry pairing on a growing delay (quick first retry, 30-min cap),
15
+ * - surface each fresh code to the admin over a live frontend, but
16
+ * after the first few, at most one notification per hour — each
17
+ * notification always carries the CURRENT code.
18
+ */
19
+
20
+ /** Delay before pairing attempt `attempt` (1-based, i.e. after `attempt` failures). */
21
+ export function nextPairingDelayMs(failedAttempts: number): number {
22
+ if (failedAttempts <= 0) return 0;
23
+ const LADDER = [5_000, 5 * 60_000, 10 * 60_000, 20 * 60_000];
24
+ const CAP = 30 * 60_000;
25
+ return LADDER[failedAttempts - 1] ?? CAP;
26
+ }
27
+
28
+ /** First codes always notify; afterwards at most one per hour. */
29
+ export const PAIRING_NOTIFY_FREE_CODES = 3;
30
+ export const PAIRING_NOTIFY_MIN_GAP_MS = 60 * 60_000;
31
+
32
+ export function shouldNotifyPairingCode(
33
+ codesIssued: number,
34
+ lastNotifiedAt: number | undefined,
35
+ now: number = Date.now(),
36
+ ): boolean {
37
+ if (codesIssued <= PAIRING_NOTIFY_FREE_CODES) return true;
38
+ if (lastNotifiedAt === undefined) return true;
39
+ return now - lastNotifiedAt >= PAIRING_NOTIFY_MIN_GAP_MS;
40
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Failure semantics for a WhatsApp turn, mirroring the Telegram queue
3
+ * (frontend/telegram/handlers/queue.ts): a user-initiated stop settles
4
+ * silently, a brief transient failure gets one retry, and anything else
5
+ * is REPORTED IN THE CHAT. Before this existed the error went only to
6
+ * the log — the WhatsApp user's message was marked read and then nothing
7
+ * ever came back, which reads as being ignored.
8
+ *
9
+ * Split from index.ts so the policy is unit-testable without a Baileys
10
+ * socket: the caller injects the turn and the delivery.
11
+ */
12
+
13
+ import {
14
+ classify,
15
+ friendlyMessage,
16
+ RETRY_ELAPSED_CAP_MS,
17
+ } from "../../core/errors.js";
18
+ import { log, logError } from "../../util/log.js";
19
+ import {
20
+ recordError,
21
+ recordMessageProcessed,
22
+ recordMessageSettled,
23
+ } from "../../util/watchdog.js";
24
+
25
+ export type TurnRecoveryDeps = {
26
+ chatId: string;
27
+ senderName: string;
28
+ runTurn: () => Promise<unknown>;
29
+ /** Deliver a friendly error line into the chat. Must not throw. */
30
+ sendErrorText: (text: string) => Promise<void>;
31
+ /** Test seam for the retry pause; defaults to a real setTimeout. */
32
+ wait?: (ms: number) => Promise<void>;
33
+ };
34
+
35
+ export async function runTurnWithRecovery(
36
+ deps: TurnRecoveryDeps,
37
+ ): Promise<void> {
38
+ const wait =
39
+ deps.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
40
+
41
+ const startedAt = Date.now();
42
+ try {
43
+ await deps.runTurn();
44
+ recordMessageProcessed();
45
+ } catch (err) {
46
+ const classified = classify(err);
47
+ // A user-initiated /stop is an outcome, not a fault — the stop was
48
+ // already acknowledged, so an error bubble here would contradict it.
49
+ if (classified.reason === "stopped") {
50
+ log("whatsapp", `[${deps.chatId}] turn stopped by user`);
51
+ recordMessageSettled();
52
+ return;
53
+ }
54
+ logError(
55
+ "whatsapp",
56
+ `[${deps.chatId}] [${deps.senderName}] ${classified.reason}: ${classified.message}`,
57
+ );
58
+ recordError(classified.message);
59
+
60
+ // Retry once for transients (rate_limit, overloaded, network) — but
61
+ // only when the failed attempt was brief. An attempt that already ran
62
+ // for minutes won't be saved by a 2s pause, and turns serialize per
63
+ // chat, so a blind retry doubles the stall for everything behind it.
64
+ const attemptMs = Date.now() - startedAt;
65
+ if (classified.retryable && attemptMs < RETRY_ELAPSED_CAP_MS) {
66
+ const delayMs = classified.retryAfterMs ?? 2000;
67
+ log(
68
+ "whatsapp",
69
+ `[${deps.chatId}] Retrying after ${classified.reason} (${delayMs}ms)...`,
70
+ );
71
+ try {
72
+ await wait(delayMs);
73
+ await deps.runTurn();
74
+ recordMessageProcessed();
75
+ return;
76
+ } catch (retryErr) {
77
+ const retryClassified = classify(retryErr);
78
+ logError(
79
+ "whatsapp",
80
+ `[${deps.chatId}] Retry failed: ${retryClassified.message}`,
81
+ );
82
+ recordMessageSettled();
83
+ await deps.sendErrorText(friendlyMessage(retryClassified));
84
+ return;
85
+ }
86
+ }
87
+
88
+ recordMessageSettled();
89
+ await deps.sendErrorText(friendlyMessage(classified));
90
+ }
91
+ }
92
+
93
+ /**
94
+ * How fresh a catch-up (offline-queued) message must be to still get a
95
+ * reply turn. Messages older than this are recorded in history — the next
96
+ * live turn sees them — but not answered: replying to a whole night's
97
+ * backlog hours later reads worse than picking up naturally.
98
+ */
99
+ export const CATCH_UP_REPLY_WINDOW_MS = 15 * 60_000;
100
+
101
+ /**
102
+ * Whether a message delivered as catch-up (Baileys `append`) should get a
103
+ * reply turn. `platformTsMs` is the message's own timestamp in ms; a
104
+ * missing/zero timestamp counts as fresh — better one odd reply than a
105
+ * silently dropped question.
106
+ */
107
+ export function shouldReplyToCatchUp(
108
+ platformTsMs: number,
109
+ now: number = Date.now(),
110
+ ): boolean {
111
+ if (!Number.isFinite(platformTsMs) || platformTsMs <= 0) return true;
112
+ return now - platformTsMs <= CATCH_UP_REPLY_WINDOW_MS;
113
+ }
@@ -81,6 +81,11 @@ export function pushMessage(chatId: string, msg: HistoryMessage): void {
81
81
  }
82
82
  }
83
83
 
84
+ /** See repositories/history-repo.ts `maxMsgIdForPrefix`. */
85
+ export function maxMsgIdForChatPrefix(prefix: string): number | undefined {
86
+ return repo.maxMsgIdForPrefix(prefix);
87
+ }
88
+
84
89
  export function getRecentHistory(chatId: string, limit = 50): HistoryMessage[] {
85
90
  return repo.recent(chatId, limit);
86
91
  }
@@ -98,6 +103,28 @@ export function getHistoryBefore(
98
103
  return repo.recentBefore(chatId, beforeMsgId, limit);
99
104
  }
100
105
 
106
+ /** Formatted page of the messages strictly older than `beforeMsgId`. */
107
+ export function getFormattedBefore(
108
+ chatId: string,
109
+ beforeMsgId: number,
110
+ limit = 30,
111
+ ): string {
112
+ const messages = repo.recentBefore(chatId, beforeMsgId, limit);
113
+ if (messages.length === 0) return "No messages before that point.";
114
+ return messages.map(formatMessage).join("\n");
115
+ }
116
+
117
+ /** Formatted page of the messages strictly older than a timestamp (ms). */
118
+ export function getFormattedBeforeTime(
119
+ chatId: string,
120
+ beforeTs: number,
121
+ limit = 30,
122
+ ): string {
123
+ const messages = repo.recentBeforeTime(chatId, beforeTs, limit);
124
+ if (messages.length === 0) return "No messages before that date.";
125
+ return messages.map(formatMessage).join("\n");
126
+ }
127
+
101
128
  /**
102
129
  * Raw (wire-friendly) full-text search over a chat's history. Unlike
103
130
  * [searchHistory] — which formats a string for the agent's tool — this
@@ -108,6 +108,17 @@ export function recentBefore(
108
108
  return rows.reverse().map(rowToMessage);
109
109
  }
110
110
 
111
+ export function recentBeforeTime(
112
+ chatId: string,
113
+ beforeTs: number,
114
+ limit: number,
115
+ ): HistoryMessage[] {
116
+ const rows = getDatabase()
117
+ .prepare(historySql.recentBeforeTime)
118
+ .all(chatId, beforeTs, limit) as Row[];
119
+ return rows.reverse().map(rowToMessage);
120
+ }
121
+
111
122
  export function setFilePath(
112
123
  chatId: string,
113
124
  msgId: number,
@@ -174,6 +185,19 @@ export function latestMsgId(chatId: string): number | undefined {
174
185
  return row?.msg_id;
175
186
  }
176
187
 
188
+ /**
189
+ * Highest msg_id over every chat whose id begins with `prefix` (compared
190
+ * literally — LIKE wildcards in the prefix are escaped). Undefined when no
191
+ * such chat has any history.
192
+ */
193
+ export function maxMsgIdForPrefix(prefix: string): number | undefined {
194
+ const pattern = prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`) + "%";
195
+ const row = getDatabase()
196
+ .prepare(historySql.maxMsgIdForPrefix)
197
+ .get(pattern) as { max_id: number | null } | undefined;
198
+ return row?.max_id ?? undefined;
199
+ }
200
+
177
201
  export type KnownUser = {
178
202
  senderId: number;
179
203
  name: string;
@@ -21,6 +21,14 @@ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
21
21
  FROM history_messages
22
22
  WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
23
23
 
24
+ -- name: recentBeforeTime
25
+ -- Time-cursor variant of recentBefore for the read_history `before` date
26
+ -- parameter: the newest `limit` messages strictly older than a timestamp.
27
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
28
+ timestamp, media_type, sticker_file_id, file_path
29
+ FROM history_messages
30
+ WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?
31
+
24
32
  -- name: setFilePath
25
33
  UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?
26
34
 
@@ -60,6 +68,12 @@ WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?
60
68
  -- name: latestMsgId
61
69
  SELECT msg_id FROM history_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1
62
70
 
71
+ -- name: maxMsgIdForPrefix
72
+ -- Highest msg_id across every chat whose id starts with a prefix
73
+ -- (parameter is a LIKE pattern with \ escapes). Seeds the WhatsApp
74
+ -- frontend's in-memory id counter past what history already holds.
75
+ SELECT MAX(msg_id) AS max_id FROM history_messages WHERE chat_id LIKE ? ESCAPE '\'
76
+
63
77
  -- name: knownUsers
64
78
  SELECT sender_id,
65
79
  MAX(timestamp) AS last_seen,
@@ -346,6 +346,12 @@ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
346
346
  timestamp, media_type, sticker_file_id, file_path
347
347
  FROM history_messages
348
348
  WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?`,
349
+ recentBeforeTime: `-- Time-cursor variant of recentBefore for the read_history \`before\` date
350
+ -- parameter: the newest \`limit\` messages strictly older than a timestamp.
351
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
352
+ timestamp, media_type, sticker_file_id, file_path
353
+ FROM history_messages
354
+ WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?`,
349
355
  setFilePath: `UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?`,
350
356
  deleteChat: `DELETE FROM history_messages WHERE chat_id = ?`,
351
357
  searchFts: `-- The match param must already be a valid FTS5 expression
@@ -371,6 +377,10 @@ WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1`,
371
377
  FROM history_messages
372
378
  WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?`,
373
379
  latestMsgId: `SELECT msg_id FROM history_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1`,
380
+ maxMsgIdForPrefix: `-- Highest msg_id across every chat whose id starts with a prefix
381
+ -- (parameter is a LIKE pattern with \\ escapes). Seeds the WhatsApp
382
+ -- frontend's in-memory id counter past what history already holds.
383
+ SELECT MAX(msg_id) AS max_id FROM history_messages WHERE chat_id LIKE ? ESCAPE '\\'`,
374
384
  knownUsers: `SELECT sender_id,
375
385
  MAX(timestamp) AS last_seen,
376
386
  COUNT(*) AS message_count,
package/src/util/log.ts CHANGED
@@ -28,6 +28,7 @@ export type LogComponent =
28
28
  | "journal"
29
29
  | "kv"
30
30
  | "media"
31
+ | "notify"
31
32
  | "agent"
32
33
  | "pulse"
33
34
  | "userbot"