talon-agent 3.33.4 → 3.34.0

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.
Files changed (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The Baileys socket lifecycle: one socket per `connectOnce`, the
3
+ * reconnect/backoff loop around it, and the parked state a logged-out or
4
+ * never-paired account waits in until `/whatsapp pair` links it.
5
+ */
6
+
7
+ import { rmSync } from "node:fs";
8
+ import { readFile } from "node:fs/promises";
9
+ import { resolve as resolvePath } from "node:path";
10
+ import makeWASocket, { type AuthenticationState, type WASocket } from "baileys";
11
+ import qrcode from "qrcode-terminal";
12
+ import { log, logError, logWarn } from "../../util/log.js";
13
+ import { dirs } from "../../util/paths.js";
14
+ import { notifyAdmin } from "../../core/notify.js";
15
+ import { useAtomicAuthState } from "./auth-state.js";
16
+ import { bareId } from "./identity.js";
17
+ import { handleInbound } from "./inbound.js";
18
+ import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
19
+ import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
20
+ import {
21
+ RECONNECT_BASE_MS,
22
+ RECONNECT_MAX_MS,
23
+ type WhatsAppRuntime,
24
+ } from "./runtime.js";
25
+ import { makeWaLogger } from "./wa-logger.js";
26
+
27
+ /** What the connection loop should do after a socket ends. */
28
+ type ConnectOutcome = "reconnect" | "logged-out" | "unpaired" | "stop";
29
+
30
+ function bindInbound(runtime: WhatsAppRuntime, socket: WASocket): void {
31
+ socket.ev.on("messages.upsert", ({ messages, type }) => {
32
+ // "notify" is a live message. "append" is everything delivered out
33
+ // of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
34
+ // (Baileys marks offline-queued nodes as append), but also our own
35
+ // sends echoing back and newsletter posts, which handleInbound's
36
+ // fromMe/allowlist gates drop. Dropping append wholesale meant any
37
+ // message sent during a restart simply vanished: never recorded,
38
+ // never answered. Appends are processed as catch-up: always
39
+ // recorded, replied to only while fresh.
40
+ if (type !== "notify" && type !== "append") return;
41
+ const catchUp = type === "append";
42
+ for (const msg of messages) {
43
+ void handleInbound(runtime, msg, { catchUp }).catch((err) => {
44
+ logError(
45
+ "whatsapp",
46
+ `inbound handler failed: ${err instanceof Error ? err.message : err}`,
47
+ );
48
+ });
49
+ }
50
+ });
51
+ }
52
+
53
+ function onQr(runtime: WhatsAppRuntime, qr: string): void {
54
+ // The frontend NEVER requests pairing codes on its own — that
55
+ // loop once burned ~30 codes in 80 minutes and rate-limited
56
+ // the account. Pairing is on demand via /whatsapp pair
57
+ // (core/pairing-broker.ts). The terminal QR stays for
58
+ // foreground first-run setups without a Telegram admin.
59
+ log(
60
+ "whatsapp",
61
+ "Not linked — pair on demand with /whatsapp pair (Telegram), " +
62
+ "or scan below: WhatsApp → Linked devices → Link a device",
63
+ );
64
+ qrcode.generate(qr, { small: true });
65
+ if (!runtime.unpairedNotified) {
66
+ runtime.unpairedNotified = true;
67
+ void notifyAdmin(
68
+ "📱 WhatsApp is not linked. Send /whatsapp pair when you're " +
69
+ "ready and I'll reply with a QR to scan.",
70
+ );
71
+ }
72
+ }
73
+
74
+ function onOpen(runtime: WhatsAppRuntime, socket: WASocket): void {
75
+ // Both forms: a group can @-mention us by either.
76
+ runtime.selfIds = [socket.user?.id, socket.user?.lid]
77
+ .filter((id): id is string => Boolean(id))
78
+ .map(bareId);
79
+ runtime.reconnectDelay = RECONNECT_BASE_MS;
80
+ if (runtime.unpairedNotified) {
81
+ void notifyAdmin("✅ WhatsApp linked and connected.");
82
+ }
83
+ runtime.unpairedNotified = false;
84
+ log(
85
+ "whatsapp",
86
+ `Connected as ${socket.user?.name ?? "?"} (${runtime.selfIds.join("/") || "?"})`,
87
+ );
88
+ }
89
+
90
+ function onClose(
91
+ runtime: WhatsAppRuntime,
92
+ state: AuthenticationState,
93
+ code: number | undefined,
94
+ ): ConnectOutcome {
95
+ if (runtime.stopping) return "stop";
96
+ const disposition = classifyClose(code, state.creds.registered);
97
+ switch (disposition.kind) {
98
+ case "pairing-accepted":
99
+ // 515 right after a pairing code/QR is SUCCESS, not an
100
+ // error: WhatsApp requires one reconnect with the same
101
+ // credentials to complete the login.
102
+ log(
103
+ "whatsapp",
104
+ "Pairing accepted (515) — reconnecting to complete login",
105
+ );
106
+ runtime.reconnectDelay = RECONNECT_BASE_MS;
107
+ return "reconnect";
108
+ case "replaced":
109
+ // Another socket owns this session (440). Fighting it with
110
+ // an instant reconnect just steals the session back and
111
+ // forth; sit out a full minute instead.
112
+ logWarn(
113
+ "whatsapp",
114
+ "Connection replaced by another client (440) — backing off",
115
+ );
116
+ runtime.reconnectDelay = REPLACED_BACKOFF_MS;
117
+ return "reconnect";
118
+ case "logged-out":
119
+ return "logged-out";
120
+ default:
121
+ // A socket that died without ever completing a login is a
122
+ // pairing window that expired — reconnecting would just open QR
123
+ // session after QR session against WhatsApp's servers.
124
+ // Park instead; /whatsapp pair opens the next one.
125
+ //
126
+ // Test with isPaired, not creds.registered: a QR-linked session
127
+ // never sets that flag, so the flag alone parks a healthy
128
+ // session on its first ordinary disconnect.
129
+ if (!isPaired(state.creds)) return "unpaired";
130
+ log("whatsapp", `Connection closed (code ${code ?? "?"}) — reconnecting`);
131
+ return "reconnect";
132
+ }
133
+ }
134
+
135
+ /** One socket lifetime. Resolves with what the caller should do next. */
136
+ async function connectOnce(runtime: WhatsAppRuntime): Promise<ConnectOutcome> {
137
+ // Atomic replacement for Baileys' useMultiFileAuthState — same disk
138
+ // format, torn-write-proof (see auth-state.ts for why that matters).
139
+ const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
140
+ const socket = makeWASocket({
141
+ auth: state,
142
+ logger: makeWaLogger(),
143
+ markOnlineOnConnect: false,
144
+ // The account is a bot: announcing "online" would suppress the
145
+ // phone's own notifications for the human who owns the number.
146
+ //
147
+ // 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
148
+ // a ~2½-minute socket lifetime in pairing mode, shorter than it
149
+ // takes a human to pick up their phone and type the code.
150
+ qrTimeout: 120_000,
151
+ // OpenClaw's production timings: the Baileys 20s connect timeout
152
+ // is tight on a loaded box, and a slightly faster keepalive spots
153
+ // a dead transport sooner.
154
+ connectTimeoutMs: 60_000,
155
+ keepAliveIntervalMs: 25_000,
156
+ });
157
+ runtime.sock = socket;
158
+ socket.ev.on("creds.update", saveCreds);
159
+ bindInbound(runtime, socket);
160
+
161
+ return new Promise((resolve) => {
162
+ socket.ev.on("connection.update", (update) => {
163
+ const { connection, lastDisconnect, qr } = update;
164
+ if (qr && !state.creds.registered) onQr(runtime, qr);
165
+ if (connection === "open") onOpen(runtime, socket);
166
+ if (connection === "close") {
167
+ const code = (
168
+ lastDisconnect?.error as
169
+ { output?: { statusCode?: number } } | undefined
170
+ )?.output?.statusCode;
171
+ resolve(onClose(runtime, state, code));
172
+ }
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Wait until a manual pairing completes (event from pairing-lock) or
179
+ * registered creds appear on disk, polling slowly. Resolves promptly
180
+ * when the frontend is stopping.
181
+ */
182
+ async function parkUntilPaired(runtime: WhatsAppRuntime): Promise<void> {
183
+ while (!runtime.stopping) {
184
+ if (!isManualPairingActive()) {
185
+ try {
186
+ const raw = await readFile(
187
+ resolvePath(dirs.whatsappAuth, "creds.json"),
188
+ "utf-8",
189
+ );
190
+ // Same trap as above: waiting on `registered` alone means a QR
191
+ // re-link can never end the park, because that flag stays false.
192
+ if (
193
+ isPaired(
194
+ JSON.parse(raw) as {
195
+ registered?: boolean;
196
+ me?: { id?: string } | null;
197
+ },
198
+ )
199
+ )
200
+ return;
201
+ } catch {
202
+ /* no creds yet — stay parked */
203
+ }
204
+ }
205
+ const paired = await new Promise<boolean>((r) => {
206
+ const off = onPairingComplete(() => {
207
+ clearTimeout(timer);
208
+ off();
209
+ r(true);
210
+ });
211
+ const timer = setTimeout(() => {
212
+ off();
213
+ r(false);
214
+ }, 30_000);
215
+ timer.unref?.();
216
+ });
217
+ if (paired) return;
218
+ }
219
+ }
220
+
221
+ /** The frontend's whole `start()`: connect, reconnect, park, until stopped. */
222
+ export async function runConnectionLoop(
223
+ runtime: WhatsAppRuntime,
224
+ ): Promise<void> {
225
+ log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
226
+ while (!runtime.stopping) {
227
+ // A manual pairing attempt owns the auth dir: two sockets on one
228
+ // keypair corrupt it and burn rate-limited pairing attempts.
229
+ if (isManualPairingActive()) {
230
+ await new Promise((r) => setTimeout(r, 5_000));
231
+ continue;
232
+ }
233
+ let outcome: ConnectOutcome;
234
+ try {
235
+ outcome = await connectOnce(runtime);
236
+ } catch (err) {
237
+ logError(
238
+ "whatsapp",
239
+ `Socket error: ${err instanceof Error ? err.message : err}`,
240
+ );
241
+ outcome = "reconnect";
242
+ }
243
+ runtime.sock = null;
244
+ if (outcome === "stop" || runtime.stopping) break;
245
+ if (outcome === "logged-out") {
246
+ // These credentials are dead — WhatsApp unlinked the device.
247
+ // Wipe them and PARK. Re-pairing needs a human holding the
248
+ // phone, so it happens strictly on demand (Telegram's
249
+ // `/whatsapp pair`) — every automatic retry policy tried here,
250
+ // including a 30-minute backoff ladder, still burned codes
251
+ // against WhatsApp's rate limit until scans failed with
252
+ // "couldn't connect device" for everyone.
253
+ logWarn(
254
+ "whatsapp",
255
+ "Logged out by WhatsApp — parked until /whatsapp pair re-links",
256
+ );
257
+ void notifyAdmin(
258
+ "⚠️ WhatsApp unlinked this device. When you're ready to " +
259
+ "re-link, send /whatsapp pair and scan the QR I reply with. " +
260
+ "Nothing is retried until then.",
261
+ );
262
+ rmSync(dirs.whatsappAuth, { recursive: true, force: true });
263
+ await parkUntilPaired(runtime);
264
+ runtime.reconnectDelay = RECONNECT_BASE_MS;
265
+ continue;
266
+ }
267
+ if (outcome === "unpaired") {
268
+ log("whatsapp", "Not paired — parked until /whatsapp pair links");
269
+ await parkUntilPaired(runtime);
270
+ runtime.reconnectDelay = RECONNECT_BASE_MS;
271
+ continue;
272
+ }
273
+ await new Promise((r) => setTimeout(r, runtime.reconnectDelay));
274
+ runtime.reconnectDelay = Math.min(
275
+ runtime.reconnectDelay * 2,
276
+ RECONNECT_MAX_MS,
277
+ );
278
+ }
279
+ log("whatsapp", "WhatsApp connection loop ended");
280
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * One inbound message, from `messages.upsert` to the model's turn:
3
+ * access gates → media saved to the workspace → history recorded →
4
+ * slash commands → catch-up policy → `execute()`.
5
+ */
6
+
7
+ import { isJidGroup, type WAMessage } from "baileys";
8
+ import type { AgentEvent } from "../../core/agent-runtime/events.js";
9
+ import { log, logError } from "../../util/log.js";
10
+ import { execute } from "../../core/engine/dispatcher.js";
11
+ import { toolInputToRecord } from "../../core/agent-runtime/events.js";
12
+ import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
13
+ import { performSessionReset } from "../shared/session-status.js";
14
+ import { appendDailyLog } from "../../storage/daily-log.js";
15
+ import { pushMessage } from "../../storage/history.js";
16
+ import {
17
+ recordMessageProcessed,
18
+ recordMessageReceived,
19
+ } from "../../util/watchdog.js";
20
+ import { isAddressedToSelf, isGroupAllowed } from "./access.js";
21
+ import { sendText } from "./actions/shared.js";
22
+ import {
23
+ bareId,
24
+ canonicalId,
25
+ identityAllowed,
26
+ resolveIdentity,
27
+ type Identity,
28
+ } from "./identity.js";
29
+ import { saveInboundMedia, type SavedMedia } from "./media-store.js";
30
+ import { lookupByWaId, rememberMessage } from "./message-store.js";
31
+ import { registerWhatsAppChat, type WhatsAppChatInfo } from "./registry.js";
32
+ import type { WhatsAppRuntime } from "./runtime.js";
33
+ import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
34
+
35
+ export type InboundOptions = { catchUp?: boolean };
36
+
37
+ /** A message that passed the access gates. */
38
+ type AdmittedMessage = {
39
+ jid: string;
40
+ isGroup: boolean;
41
+ identity: Identity;
42
+ };
43
+
44
+ /** An admitted message with something to say, recorded in history. */
45
+ type RecordedMessage = AdmittedMessage & {
46
+ chat: WhatsAppChatInfo;
47
+ msgId: number;
48
+ text: string;
49
+ media: SavedMedia | undefined;
50
+ senderName: string;
51
+ platformTs: number;
52
+ };
53
+
54
+ /** Plain text of an inbound message, across the wrappers WhatsApp uses. */
55
+ function extractText(msg: WAMessage): string {
56
+ const m = msg.message;
57
+ if (!m) return "";
58
+ return (
59
+ m.conversation ??
60
+ m.extendedTextMessage?.text ??
61
+ m.imageMessage?.caption ??
62
+ m.videoMessage?.caption ??
63
+ m.documentMessage?.caption ??
64
+ m.documentWithCaptionMessage?.message?.documentMessage?.caption ??
65
+ ""
66
+ );
67
+ }
68
+
69
+ async function admitInbound(
70
+ runtime: WhatsAppRuntime,
71
+ msg: WAMessage,
72
+ ): Promise<AdmittedMessage | null> {
73
+ const jid = msg.key.remoteJid;
74
+ // `fromMe` covers our own sends echoing back; status@broadcast is the
75
+ // Stories feed, which is not a conversation.
76
+ if (!jid || jid === "status@broadcast" || msg.key.fromMe) return null;
77
+
78
+ const isGroup = Boolean(isJidGroup(jid));
79
+ // The sender may be addressed by phone number or by LID depending on
80
+ // their privacy settings; resolve both before matching an allowlist
81
+ // that is written in phone numbers.
82
+ const senderJid = msg.key.participant || jid;
83
+ const identity = await resolveIdentity(
84
+ runtime.sock,
85
+ senderJid,
86
+ msg.key.participantAlt ?? msg.key.remoteJidAlt,
87
+ );
88
+
89
+ // ── Access gates: the allowlists are the entire permission model ──
90
+ if (isGroup) {
91
+ if (!(await isGroupAllowed(runtime, jid))) return null;
92
+ if (
93
+ runtime.settings.respondMode === "mention" &&
94
+ !isAddressedToSelf(runtime.selfIds, msg)
95
+ )
96
+ return null;
97
+ } else if (!identityAllowed(identity, runtime.allowedDms)) {
98
+ log(
99
+ "whatsapp",
100
+ `Ignoring DM from unlisted ${identity.ids.join("/") || bareId(jid)}`,
101
+ );
102
+ return null;
103
+ }
104
+ return { jid, isGroup, identity };
105
+ }
106
+
107
+ async function recordInbound(
108
+ runtime: WhatsAppRuntime,
109
+ msg: WAMessage,
110
+ admitted: AdmittedMessage,
111
+ ): Promise<RecordedMessage | null> {
112
+ const { jid, isGroup, identity } = admitted;
113
+ const text = extractText(msg).trim();
114
+ // Group chats key on the group JID; DMs key on the person, so the
115
+ // thread survives WhatsApp switching addressing form.
116
+ const chat = registerWhatsAppChat(
117
+ jid,
118
+ undefined,
119
+ isGroup ? undefined : canonicalId(identity),
120
+ );
121
+ const senderName = msg.pushName || canonicalId(identity) || "user";
122
+ const msgId = rememberMessage({
123
+ key: msg.key,
124
+ chatId: chat.chatId,
125
+ message: msg,
126
+ text,
127
+ senderName,
128
+ });
129
+
130
+ // Media is saved before the turn so the model can open the file by
131
+ // path in the same turn it's told about it.
132
+ const media = await saveInboundMedia(msg, chat.chatId, msgId, senderName);
133
+ if (!text && !media) return null; // reaction, receipt, or an unsupported type
134
+
135
+ recordMessageReceived();
136
+ if (runtime.settings.sendReadReceipts) {
137
+ runtime.sock?.readMessages([msg.key]).catch(() => {});
138
+ }
139
+
140
+ // Recorded for read_chat_history / search_chat_history, which the core
141
+ // serves from this store for frontends without a platform history API.
142
+ const replyToWaId =
143
+ msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
144
+ const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
145
+ const platformTs = Number(msg.messageTimestamp) * 1000;
146
+ pushMessage(chat.chatId, {
147
+ msgId,
148
+ senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
149
+ senderName,
150
+ senderHandle: canonicalId(identity),
151
+ text,
152
+ // The platform timestamp, so a catch-up message recorded late still
153
+ // reads in true order; Date.now() only when Baileys omits it.
154
+ timestamp:
155
+ Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
156
+ ...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
157
+ ...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
158
+ });
159
+ return { ...admitted, chat, msgId, text, media, senderName, platformTs };
160
+ }
161
+
162
+ /** `/reset` and `/help`. True when the message was a command. */
163
+ async function handleSlashCommand(
164
+ runtime: WhatsAppRuntime,
165
+ inbound: RecordedMessage,
166
+ ): Promise<boolean> {
167
+ const { chat, senderName } = inbound;
168
+ const { gateway } = runtime;
169
+ const trimmed = inbound.text.toLowerCase();
170
+ if (trimmed === "/reset") {
171
+ await performSessionReset(
172
+ chat.chatId,
173
+ resolveChatBackend(chat.chatId, gateway.backend),
174
+ // The local history store is WhatsApp's only chat record — a
175
+ // reset clears the model's session, not the conversation log.
176
+ { keepHistory: true },
177
+ );
178
+ log("whatsapp", `Session reset by ${senderName}`);
179
+ const sock = runtime.sock;
180
+ if (sock) {
181
+ await sendText({ sock, gateway }, chat, "Session cleared.").catch(
182
+ () => {},
183
+ );
184
+ }
185
+ recordMessageProcessed();
186
+ return true;
187
+ }
188
+ if (trimmed === "/help") {
189
+ const sock = runtime.sock;
190
+ if (sock) {
191
+ await sendText(
192
+ { sock, gateway },
193
+ chat,
194
+ "*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
195
+ ).catch(() => {});
196
+ }
197
+ recordMessageProcessed();
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ /**
204
+ * Catch-up messages (queued while the daemon was down) get a reply
205
+ * turn only while fresh; stale ones are already recorded and the next
206
+ * live turn reads them from history.
207
+ */
208
+ function catchUpDeservesReply(inbound: RecordedMessage): boolean {
209
+ const { chat, senderName } = inbound;
210
+ if (!shouldReplyToCatchUp(inbound.platformTs)) {
211
+ log(
212
+ "whatsapp",
213
+ `[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
214
+ );
215
+ recordMessageProcessed();
216
+ return false;
217
+ }
218
+ log(
219
+ "whatsapp",
220
+ `[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
221
+ );
222
+ return true;
223
+ }
224
+
225
+ async function onTurnEvent(
226
+ runtime: WhatsAppRuntime,
227
+ chat: WhatsAppChatInfo,
228
+ event: AgentEvent,
229
+ ): Promise<void> {
230
+ switch (event.type) {
231
+ case "tool_call": {
232
+ const input = toolInputToRecord(event.name, event.input);
233
+ const detail = (input.description ??
234
+ input.command ??
235
+ input.action ??
236
+ input.query ??
237
+ "") as string;
238
+ log(
239
+ "whatsapp",
240
+ ` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
241
+ );
242
+ break;
243
+ }
244
+ // Progress prose and the end-of-turn trailing-text fallback.
245
+ // Without this, prose-only turns are silently dropped.
246
+ case "assistant_message": {
247
+ const sock = runtime.sock;
248
+ if (!event.text.trim() || !sock) break;
249
+ try {
250
+ await sendText({ sock, gateway: runtime.gateway }, chat, event.text);
251
+ } catch (err) {
252
+ logError(
253
+ "whatsapp",
254
+ `onEvent delivery failed: ${err instanceof Error ? err.message : err}`,
255
+ );
256
+ }
257
+ break;
258
+ }
259
+ }
260
+ }
261
+
262
+ async function runInboundTurn(
263
+ runtime: WhatsAppRuntime,
264
+ inbound: RecordedMessage,
265
+ ): Promise<void> {
266
+ const { chat, text, media, msgId, senderName, identity, isGroup, jid } =
267
+ inbound;
268
+ const preview = text || `(${media?.type ?? "media"})`;
269
+ log(
270
+ "whatsapp",
271
+ `[${chat.chatId}] [${senderName}]: ${preview.slice(0, 80)}${preview.length > 80 ? "..." : ""}`,
272
+ );
273
+ appendDailyLog(senderName, preview, {
274
+ chatTitle: chat.title,
275
+ username: canonicalId(identity) ?? bareId(jid),
276
+ });
277
+
278
+ // The model addresses messages by numeric id (react/reply/edit), so the
279
+ // id travels with the text the same way the other frontends do it.
280
+ const mediaNote = media
281
+ ? `\n[attached ${media.type}: ${media.filePath}]`
282
+ : "";
283
+ const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
284
+
285
+ const runTurn = () =>
286
+ execute({
287
+ chatId: chat.chatId,
288
+ numericChatId: chat.numericChatId,
289
+ prompt,
290
+ senderName,
291
+ isGroup,
292
+ source: "message",
293
+ onEvent: (event) => onTurnEvent(runtime, chat, event),
294
+ });
295
+
296
+ await runTurnWithRecovery({
297
+ chatId: chat.chatId,
298
+ senderName,
299
+ runTurn,
300
+ sendErrorText: async (text) => {
301
+ const sock = runtime.sock;
302
+ if (!sock) return;
303
+ try {
304
+ await sendText({ sock, gateway: runtime.gateway }, chat, text);
305
+ } catch (sendErr) {
306
+ logError(
307
+ "whatsapp",
308
+ `error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
309
+ );
310
+ }
311
+ },
312
+ });
313
+ }
314
+
315
+ export async function handleInbound(
316
+ runtime: WhatsAppRuntime,
317
+ msg: WAMessage,
318
+ options: InboundOptions = {},
319
+ ): Promise<void> {
320
+ const admitted = await admitInbound(runtime, msg);
321
+ if (!admitted) return;
322
+ const inbound = await recordInbound(runtime, msg, admitted);
323
+ if (!inbound) return;
324
+ if (await handleSlashCommand(runtime, inbound)) return;
325
+ if (options.catchUp && !catchUpDeservesReply(inbound)) return;
326
+ await runInboundTurn(runtime, inbound);
327
+ }