talon-agent 5.25.0 → 5.26.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 (66) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/agy/one-shot.ts +34 -3
  4. package/src/backend/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
@@ -5,8 +5,11 @@ import { resolveModelId as resolveModelName } from "../../../core/models/catalog
5
5
  import {
6
6
  getChatSettings,
7
7
  setChatModel,
8
- setChatEffort,
9
8
  } from "../../../storage/chat-settings.js";
9
+ import {
10
+ describeChatEffort,
11
+ setChatEffortLevel,
12
+ } from "../../presentation/model-commands.js";
10
13
  import type { Command, CommandContext } from "../command-registry.js";
11
14
 
12
15
  type BackendRef = CommandContext["backend"];
@@ -178,20 +181,26 @@ export const modelCommand: Command = {
178
181
  export const effortCommand: Command = {
179
182
  name: "effort",
180
183
  argHint: "[lvl]",
181
- description: "Thinking effort (off/low/medium/high/max)",
184
+ description: "Thinking effort for the active model",
182
185
  async handler(args, ctx) {
183
- if (!args) {
186
+ // Same resolution and validation as every chat frontend: the levels
187
+ // the active model registers, plus adaptive/reset/default.
188
+ const deps = {
189
+ config: ctx.config,
190
+ gateway: { backend: ctx.backend ?? null },
191
+ };
192
+ const arg = args.trim();
193
+ if (!arg) {
194
+ const { current, levels } = await describeChatEffort(ctx.chatId(), deps);
184
195
  ctx.renderer.writeSystem(
185
- `Effort: ${getChatSettings(ctx.chatId()).effort ?? "adaptive"}`,
196
+ levels.length
197
+ ? `Effort: ${current} (${levels.join(", ")}, adaptive)`
198
+ : `Effort: ${current}`,
186
199
  );
187
200
  } else {
188
- setChatEffort(
189
- ctx.chatId(),
190
- args === "adaptive"
191
- ? undefined
192
- : (args as "off" | "low" | "medium" | "high" | "max"),
193
- );
194
- ctx.renderer.writeSystem(`Effort → ${args}`);
201
+ const outcome = await setChatEffortLevel(ctx.chatId(), arg, deps);
202
+ if (outcome.ok) ctx.renderer.writeSystem(outcome.text);
203
+ else ctx.renderer.writeError(outcome.text);
195
204
  }
196
205
  ctx.reprompt();
197
206
  },
@@ -5,8 +5,9 @@
5
5
  * WhatsApp has no inline buttons, so everything Telegram does with a
6
6
  * picker is done here with typed arguments: `/model` prints a numbered
7
7
  * catalog, `/model 3` (or `/model <id>`, `/model <backend>`) picks from
8
- * it. The state changes come from frontend/presentation/model-commands.ts;
9
- * this module only parses, authorises, and renders.
8
+ * it. The replies are shared with the native bridge
9
+ * (frontend/presentation/text-commands.ts); this module only parses,
10
+ * authorises, and delivers.
10
11
  *
11
12
  * Replies are written in Markdown — `sendText` translates them into
12
13
  * WhatsApp's dialect and splits long listings into bubbles.
@@ -14,38 +15,17 @@
14
15
 
15
16
  import { log } from "../../util/log.js";
16
17
  import { recordMessageProcessed } from "../../util/watchdog.js";
17
- import { getChatSettings } from "../../storage/chat-settings.js";
18
- import { isPulseEnabled } from "../../core/background/pulse/pulse.js";
18
+ import { performSessionReset } from "../presentation/session-status.js";
19
19
  import {
20
- DEFAULT_PULSE_INTERVAL_MS,
21
- formatBytes,
22
- formatDuration,
23
- formatModelLabel,
24
- formatTokenCount,
25
- formatUsd,
26
- } from "../presentation/format.js";
27
- import {
28
- collectSessionStatus,
29
- performSessionReset,
30
- type SessionStatusData,
31
- } from "../presentation/session-status.js";
32
- import {
33
- formatCacheTempLine,
34
- formatDaemonLine,
35
- } from "../presentation/status-context.js";
36
- import {
37
- describeChatEffort,
38
- describeChatModels,
39
- matchBackendArg,
40
- resetChatBackend,
41
- resetChatModel,
42
20
  resolveChatBackendPair,
43
- selectChatModel,
44
- setChatEffortLevel,
45
- switchChatBackend,
46
21
  type ModelCommandDeps,
47
- type ModelOverview,
48
22
  } from "../presentation/model-commands.js";
23
+ import {
24
+ effortCommandReply,
25
+ modelCommandReply,
26
+ settingsCommandReply,
27
+ statusCommandReply,
28
+ } from "../presentation/text-commands.js";
49
29
  import { sendText } from "./actions/send.js";
50
30
  import { identityAllowed, type Identity } from "./connection/identity.js";
51
31
  import type { WhatsAppChatInfo } from "./registry.js";
@@ -73,9 +53,6 @@ export type CommandInbound = {
73
53
  isGroup: boolean;
74
54
  };
75
55
 
76
- /** How many catalog entries `/model` prints before pointing at `/model <id>`. */
77
- const MAX_LISTED_MODELS = 40;
78
-
79
56
  /**
80
57
  * Parse a slash command out of message text. Group messages in mention
81
58
  * mode arrive as "@<number> /model", so leading @mentions are skipped.
@@ -112,120 +89,6 @@ export function canChangeSettings(
112
89
  return identityAllowed(inbound.identity, runtime.allowedDms);
113
90
  }
114
91
 
115
- // ── Renderers ───────────────────────────────────────────────────────────────
116
-
117
- function renderModelOverview(view: ModelOverview): string {
118
- const lines: string[] = [];
119
- lines.push(
120
- view.activeModel
121
- ? `**Model:** \`${view.activeModel}\`${
122
- view.activeDisplay && view.activeDisplay !== view.activeModel
123
- ? ` (${view.activeDisplay})`
124
- : ""
125
- }${view.hasModelOverride ? " — chat override" : ""}`
126
- : "**Model:** none selected — messages will be refused until you pick one",
127
- );
128
- lines.push(
129
- `**Backend:** ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " — chat override" : ""}`,
130
- );
131
- if (view.backends.length > 1) {
132
- lines.push(
133
- `Backends: ${view.backends.map((b) => `\`${b.id}\``).join(", ")} — \`/model <backend>\` switches (session restarts).`,
134
- );
135
- }
136
- if (view.choices.length === 0) {
137
- lines.push(
138
- "",
139
- "This backend has no browsable catalog; `/model <id>` sets one directly.",
140
- );
141
- } else {
142
- lines.push("", `**Models** (${view.choices.length}):`);
143
- for (const c of view.choices.slice(0, MAX_LISTED_MODELS)) {
144
- const marker = c.id === view.activeModel ? " ✓" : "";
145
- const free = c.free ? " · free" : "";
146
- lines.push(`${c.index}. ${c.displayName} — \`${c.id}\`${free}${marker}`);
147
- }
148
- if (view.choices.length > MAX_LISTED_MODELS) {
149
- lines.push(
150
- `… ${view.choices.length - MAX_LISTED_MODELS} more — \`/model <id>\` picks any of them.`,
151
- );
152
- }
153
- }
154
- lines.push(
155
- "",
156
- "`/model <number>` or `/model <id>` picks; `/model default` clears the chat's pick" +
157
- (view.hasBackendOverride
158
- ? "; `/model backend default` reverts the backend."
159
- : "."),
160
- );
161
- return lines.join("\n");
162
- }
163
-
164
- function renderSettings(
165
- view: ModelOverview,
166
- effort: { current: string; levels: string[] },
167
- pulseOn: boolean,
168
- pulseIntervalMs: number | undefined,
169
- freeOnly: boolean,
170
- ): string {
171
- const lines = [
172
- "**Settings**",
173
- `Model: \`${view.activeModel ?? "none selected"}\`${view.hasModelOverride ? " (chat override)" : ""}`,
174
- `Backend: ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " (chat override)" : ""}`,
175
- `Effort: ${effort.current}${effort.levels.length ? ` — levels: ${effort.levels.join(", ")}` : ""}`,
176
- `Pulse: ${pulseOn ? "on" : "off"} (every ${formatDuration(pulseIntervalMs ?? DEFAULT_PULSE_INTERVAL_MS)})`,
177
- ];
178
- if (freeOnly) lines.push("Free-only models: on");
179
- lines.push(
180
- "",
181
- "Change with `/model`, `/effort`; `/reset` starts a fresh session.",
182
- );
183
- return lines.join("\n");
184
- }
185
-
186
- function renderStatus(s: SessionStatusData): string {
187
- const used = s.context.known ? formatTokenCount(s.context.used) : "unknown";
188
- const max = s.context.max > 0 ? formatTokenCount(s.context.max) : "unknown";
189
- const pct = s.context.known ? `${s.context.pct}%` : "unknown";
190
- const lines = [
191
- `**Talon** · \`${formatModelLabel(s.activeModel)}\`${s.backendLabel ? ` · ${s.backendLabel}` : ""} · effort: ${s.effortName}${s.turnInProgress ? " · ⏳ turn running" : ""}`,
192
- "",
193
- `**Context** ${used} / ${max} (${pct})${s.context.warn ? " ⚠️ consider /reset" : ""}`,
194
- `\`${s.context.bar}\``,
195
- "",
196
- "**Session stats**",
197
- `Response: last ${s.lastResponseMs ? formatDuration(s.lastResponseMs) : "—"} · avg ${s.avgResponseMs ? formatDuration(s.avgResponseMs) : "—"} · best ${s.fastestMs ? formatDuration(s.fastestMs) : "—"}`,
198
- `Turns: ${s.turns}${s.turnsModelLabel ? ` (${formatModelLabel(s.turnsModelLabel)})` : ""}`,
199
- `Tokens: in ${formatTokenCount(s.inputTokens)} · out ${formatTokenCount(s.outputTokens)}${s.costUsd > 0 ? ` · cost ${formatUsd(s.costUsd)}` : ""}`,
200
- ];
201
- if (s.cache) {
202
- lines.push(
203
- `Cache: ${s.cache.hitPct}% hit · read ${formatTokenCount(s.cache.read)}${s.cache.showsWrite ? ` · write ${formatTokenCount(s.cache.write)}` : ""}`,
204
- );
205
- }
206
- if (s.cacheTemp) lines.push(formatCacheTempLine(s.cacheTemp));
207
- if (s.plan) {
208
- lines.push(
209
- "",
210
- `**Plan**${s.plan.plan ? ` ${s.plan.plan}` : ""}${s.plan.ageLabel ? ` (${s.plan.ageLabel})` : ""}`,
211
- ...s.plan.windows.map(
212
- (w) =>
213
- `\`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${w.resetLabel ? ` reset ${w.resetLabel}` : ""}`,
214
- ),
215
- );
216
- }
217
- lines.push(
218
- "",
219
- `**Pulse** ${s.pulseOn ? "on" : "off"}`,
220
- `**Workspace** ${formatBytes(s.diskBytes)}`,
221
- `**Session** ${s.sessionName ? `"${s.sessionName}" ` : ""}${s.sessionId ? `\`${s.sessionId.slice(0, 8)}…\`` : "(new)"} · ${s.sessionAge} old`,
222
- `**Uptime** ${s.uptime} · ${s.activeSessionCount} active session${s.activeSessionCount === 1 ? "" : "s"}`,
223
- `**Runtime** ${s.runtime} · ${formatBytes(s.rssBytes)} RSS`,
224
- formatDaemonLine(s.daemon),
225
- );
226
- return lines.join("\n");
227
- }
228
-
229
92
  const HELP_TEXT = [
230
93
  "**Commands**",
231
94
  "/model — list backends and models; `/model <n|id>` picks, `/model <backend>` switches",
@@ -238,71 +101,6 @@ const HELP_TEXT = [
238
101
 
239
102
  // ── Handlers ────────────────────────────────────────────────────────────────
240
103
 
241
- async function runModelCommand(
242
- chatId: string,
243
- arg: string,
244
- deps: ModelCommandDeps,
245
- ): Promise<string> {
246
- if (!arg) return renderModelOverview(await describeChatModels(chatId, deps));
247
- const lower = arg.toLowerCase();
248
- // `/reset` keeps history on WhatsApp because the local store is the only
249
- // chat record; a backend switch keeps it for the same reason.
250
- if (lower === "backend default" || lower === "backend reset") {
251
- return (await resetChatBackend(chatId, deps, { keepHistory: true })).text;
252
- }
253
- if (lower === "reset" || lower === "default") {
254
- return (await resetChatModel(chatId, deps)).text;
255
- }
256
- const backend = matchBackendArg(arg, deps.config);
257
- if (backend) {
258
- return (
259
- await switchChatBackend(chatId, backend, deps, { keepHistory: true })
260
- ).text;
261
- }
262
- return (await selectChatModel(chatId, arg, deps)).text;
263
- }
264
-
265
- async function runEffortCommand(
266
- chatId: string,
267
- arg: string,
268
- deps: ModelCommandDeps,
269
- ): Promise<string> {
270
- if (arg) return (await setChatEffortLevel(chatId, arg, deps)).text;
271
- const effort = await describeChatEffort(chatId, deps);
272
- if (effort.levels.length === 0) {
273
- return `No reasoning levels available for ${effort.activeModel ?? "the active model"} on backend ${effort.backendId}.`;
274
- }
275
- return `**Effort:** ${effort.current}\nLevels: ${effort.levels.join(", ")}, or adaptive — \`/effort <level>\` sets one.`;
276
- }
277
-
278
- async function runSettingsCommand(
279
- chatId: string,
280
- deps: ModelCommandDeps,
281
- ): Promise<string> {
282
- const [view, effort] = await Promise.all([
283
- describeChatModels(chatId, deps),
284
- describeChatEffort(chatId, deps),
285
- ]);
286
- const sets = getChatSettings(chatId);
287
- return renderSettings(
288
- view,
289
- effort,
290
- isPulseEnabled(chatId),
291
- sets.pulseIntervalMs,
292
- sets.freeOnly === true,
293
- );
294
- }
295
-
296
- async function runStatusCommand(
297
- chatId: string,
298
- deps: ModelCommandDeps,
299
- ): Promise<string> {
300
- const { backend, backendId } = resolveChatBackendPair(chatId, deps);
301
- return renderStatus(
302
- await collectSessionStatus(chatId, deps.config, backend, backendId),
303
- );
304
- }
305
-
306
104
  async function runResetCommand(
307
105
  chatId: string,
308
106
  senderName: string,
@@ -337,13 +135,15 @@ export async function executeWhatsAppCommand(
337
135
  }
338
136
  switch (cmd.name) {
339
137
  case "model":
340
- return runModelCommand(chatId, cmd.arg, deps);
138
+ // `/reset` keeps history on WhatsApp because the local store is the
139
+ // only chat record; a backend switch keeps it for the same reason.
140
+ return modelCommandReply(chatId, cmd.arg, deps, { keepHistory: true });
341
141
  case "effort":
342
- return runEffortCommand(chatId, cmd.arg, deps);
142
+ return effortCommandReply(chatId, cmd.arg, deps);
343
143
  case "settings":
344
- return runSettingsCommand(chatId, deps);
144
+ return settingsCommandReply(chatId, deps);
345
145
  case "status":
346
- return runStatusCommand(chatId, deps);
146
+ return statusCommandReply(chatId, deps);
347
147
  case "reset":
348
148
  return runResetCommand(chatId, inbound.senderName, deps);
349
149
  case "help":
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * One inbound message, from `messages.upsert` to the model's turn:
3
3
  * access gates → media saved to the workspace → history recorded →
4
- * slash commands (../commands.ts) → catch-up policy → `execute()`.
4
+ * slash commands (../commands.ts; skipped in the catch-up backlog) →
5
+ * catch-up policy → `execute()`.
5
6
  */
6
7
 
7
8
  import { isJidGroup, type WAMessage } from "baileys";
@@ -17,7 +18,7 @@ import {
17
18
  recordMessageReceived,
18
19
  } from "../../../util/watchdog.js";
19
20
  import { isAddressedToSelf, isGroupAllowed } from "../access.js";
20
- import { handleWhatsAppCommand } from "../commands.js";
21
+ import { handleWhatsAppCommand, parseWhatsAppCommand } from "../commands.js";
21
22
  import { sendText } from "../actions/send.js";
22
23
  import {
23
24
  bareId,
@@ -207,6 +208,32 @@ function catchUpDeservesReply(inbound: RecordedMessage): boolean {
207
208
  return true;
208
209
  }
209
210
 
211
+ /**
212
+ * A slash command in the catch-up backlog was issued against an earlier
213
+ * state of the chat — replaying `/reset` or `/model x` after a restart
214
+ * would silently undo whatever happened since. It stays recorded but does
215
+ * not run; while fresh, the sender is told so they can resend it.
216
+ */
217
+ async function skipCatchUpCommand(
218
+ runtime: WhatsAppRuntime,
219
+ inbound: RecordedMessage,
220
+ name: string,
221
+ ): Promise<void> {
222
+ const { chat, senderName } = inbound;
223
+ log(
224
+ "whatsapp",
225
+ `[${chat.chatId}] Skipped /${name} from ${senderName} (sent while offline)`,
226
+ );
227
+ recordMessageProcessed();
228
+ const sock = runtime.sock;
229
+ if (!sock || !shouldReplyToCatchUp(inbound.platformTs)) return;
230
+ await sendText(
231
+ { sock, gateway: runtime.gateway },
232
+ chat,
233
+ `Not run: /${name} was sent while offline. Send it again to run it.`,
234
+ ).catch(() => {});
235
+ }
236
+
210
237
  async function onTurnEvent(
211
238
  runtime: WhatsAppRuntime,
212
239
  chat: WhatsAppChatInfo,
@@ -307,6 +334,10 @@ export async function handleInbound(
307
334
  if (!admitted) return;
308
335
  const inbound = await recordInbound(runtime, msg, admitted);
309
336
  if (!inbound) return;
337
+ if (options.catchUp) {
338
+ const cmd = parseWhatsAppCommand(inbound.text);
339
+ if (cmd) return skipCatchUpCommand(runtime, inbound, cmd.name);
340
+ }
310
341
  if (await handleWhatsAppCommand(runtime, inbound)) return;
311
342
  if (options.catchUp && !catchUpDeservesReply(inbound)) return;
312
343
  await runInboundTurn(runtime, inbound);