talon-agent 5.26.1 → 5.26.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.
Files changed (43) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app.ts +42 -1
  4. package/src/bootstrap.ts +171 -57
  5. package/src/cli/commands/backup.ts +4 -1
  6. package/src/cli/commands/history.ts +152 -0
  7. package/src/cli/index.ts +6 -0
  8. package/src/core/backup/boot/version-checkpoint.ts +199 -0
  9. package/src/core/backup/index.ts +3 -0
  10. package/src/core/backup/passphrase.ts +55 -2
  11. package/src/core/backup/restore.ts +32 -4
  12. package/src/core/backup/scheduler.ts +125 -13
  13. package/src/core/backup/status.ts +26 -3
  14. package/src/core/update/self-update.ts +144 -47
  15. package/src/frontend/discord/admin.ts +2 -2
  16. package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
  17. package/src/frontend/discord/commands/admin.ts +22 -3
  18. package/src/frontend/discord/commands/definitions.ts +7 -0
  19. package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
  20. package/src/frontend/native/chats/chats.ts +14 -2
  21. package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
  22. package/src/frontend/native/chats/reset.ts +39 -17
  23. package/src/frontend/native/commands/session.ts +2 -2
  24. package/src/frontend/native/surface/models.ts +3 -3
  25. package/src/frontend/native/turn/turn-meta.ts +0 -6
  26. package/src/frontend/presentation/model-commands.ts +8 -11
  27. package/src/frontend/presentation/session-status.ts +12 -13
  28. package/src/frontend/presentation/text-commands.ts +6 -9
  29. package/src/frontend/telegram/admin/sessions.ts +2 -1
  30. package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
  31. package/src/frontend/telegram/commands/admin.ts +21 -3
  32. package/src/frontend/whatsapp/commands.ts +5 -8
  33. package/src/storage/backup/index.ts +1 -1
  34. package/src/storage/chat-settings.ts +13 -0
  35. package/src/storage/db.ts +5 -0
  36. package/src/storage/history.ts +167 -17
  37. package/src/storage/media-index.ts +4 -3
  38. package/src/storage/repositories/history-repo.ts +75 -7
  39. package/src/storage/sessions.ts +60 -1
  40. package/src/storage/sql/history.sql +45 -4
  41. package/src/storage/sql/schema.sql +13 -0
  42. package/src/storage/sql/statements.generated.ts +49 -5
  43. package/src/storage/turn-meta.ts +1 -1
package/README.md CHANGED
@@ -468,6 +468,7 @@ talon kill Abort a killable task by id
468
468
  talon events Tail the event bus (-f follows, --history [N] reads the journal)
469
469
  talon plugin Manage plugins (install / enable / disable / remove)
470
470
  talon skill Manage skills (install / enable / disable / remove)
471
+ talon history Chat history kept by Talon (show / hidden / purge — docs/chat-history.md)
471
472
  talon config View or edit configuration
472
473
  talon logs Tail structured log file
473
474
  talon doctor Validate environment and dependencies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.26.1",
3
+ "version": "5.26.3",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
package/src/app.ts CHANGED
@@ -167,8 +167,49 @@ const restoreReport = await withConfigGuard(() =>
167
167
  bootPhase("staged restore", applyStagedRestore),
168
168
  );
169
169
 
170
+ /**
171
+ * The first boot of a new version (a Docker/TrueNAS image pull, an npm or
172
+ * binary upgrade — anything but `/update`, which checkpoints itself) takes
173
+ * a pinned `pre-upgrade <old>→<new>` checkpoint HERE: after a staged
174
+ * restore, before bootstrap opens the stores and the backend reconcile or
175
+ * any migration runs against them. A failed checkpoint alerts the admin
176
+ * and still boots, but with the destructive boot steps skipped.
177
+ *
178
+ * Never throws.
179
+ */
180
+ async function checkpointIfUpgraded(): Promise<{ safe: boolean }> {
181
+ try {
182
+ const { checkpointOnVersionChange } =
183
+ await import("./core/backup/index.js");
184
+ const { loadConfig } = await import("./core/config/index.js");
185
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
186
+ const { talonVersion } = await import("./util/version.js");
187
+ const result = await checkpointOnVersionChange({
188
+ settings: resolveBackupSettings(loadConfig().backup),
189
+ version: talonVersion(),
190
+ });
191
+ return { safe: result.status !== "failed" };
192
+ } catch (err) {
193
+ if (err instanceof ConfigFileError) throw err;
194
+ logError("backup", "Pre-upgrade version check failed", err);
195
+ const { raiseAlert } = await import("./core/frontend-runtime/alerts.js");
196
+ raiseAlert(
197
+ "backup.upgrade-checkpoint",
198
+ `The boot-time version check / pre-upgrade checkpoint crashed: ${String(err)}. Booted without it; boot-time cleanup skipped.`,
199
+ { severity: "critical" },
200
+ );
201
+ return { safe: false };
202
+ }
203
+ }
204
+
205
+ const upgrade = await withConfigGuard(() =>
206
+ bootPhase("upgrade checkpoint", checkpointIfUpgraded),
207
+ );
208
+
170
209
  const { config } = await withConfigGuard(() =>
171
- bootPhase("bootstrap", () => bootstrap()),
210
+ bootPhase("bootstrap", () =>
211
+ bootstrap({ skipDestructiveSteps: !upgrade.safe }),
212
+ ),
172
213
  );
173
214
 
174
215
  // Record this process as the daemon. The gateway port is appended once
package/src/bootstrap.ts CHANGED
@@ -15,7 +15,7 @@ import { loadSessions, resetSession } from "./storage/sessions.js";
15
15
  import { loadChatSettings } from "./storage/chat-settings.js";
16
16
  import { loadCronJobs } from "./storage/cron.js";
17
17
  import { loadTriggers } from "./storage/triggers.js";
18
- import { clearHistory, loadHistory } from "./storage/history.js";
18
+ import { loadHistory } from "./storage/history.js";
19
19
  import { loadMediaIndex } from "./storage/media-index.js";
20
20
  import { cleanupOldLogs } from "./storage/daily-log.js";
21
21
  import {
@@ -90,6 +90,12 @@ function resolveFrontendByNumericId(
90
90
  export type BootstrapOptions = {
91
91
  /** Override frontend names for plugin loading (e.g. ["terminal"]). */
92
92
  frontendNames?: string[];
93
+ /**
94
+ * Skip the boot steps that delete data (expired daily logs and memory
95
+ * notes, expired media). Set when the pre-upgrade checkpoint failed:
96
+ * with no snapshot to fall back on, this boot deletes nothing.
97
+ */
98
+ skipDestructiveSteps?: boolean;
93
99
  };
94
100
 
95
101
  export type BootstrapResult = {
@@ -165,9 +171,16 @@ export async function bootstrap(
165
171
  loadCronJobs();
166
172
  loadTriggers();
167
173
  loadHistory();
168
- loadMediaIndex();
174
+ loadMediaIndex({ purgeExpired: !options.skipDestructiveSteps });
169
175
  });
170
- cleanupOldLogs();
176
+ if (options.skipDestructiveSteps) {
177
+ logWarn(
178
+ "bot",
179
+ "Skipping boot-time cleanup (old daily logs, expired media): no pre-upgrade checkpoint to fall back on",
180
+ );
181
+ } else {
182
+ cleanupOldLogs();
183
+ }
171
184
 
172
185
  return { config };
173
186
  }
@@ -195,89 +208,191 @@ type ChatBindingDeps = {
195
208
  getBackendIdForChat: (chatId: string) => string;
196
209
  getBackendForChat: (chatId: string) => Backend;
197
210
  isModelValidForBackend: (backend: Backend, model: string) => Promise<boolean>;
211
+ /**
212
+ * Tell the operator what the reconcile changed — once per boot, one
213
+ * message for every chat. Defaults to an operator alert.
214
+ */
215
+ notify?: (text: string) => void;
198
216
  };
199
217
 
200
218
  const CHAT_BINDING_CONCURRENCY = 8;
201
219
  const REBIND_RETRY_DELAY_MS = 1_500;
220
+ const ONE_MILLION_SUFFIX = /\s*\[1m\]$/i;
202
221
 
203
222
  /**
204
223
  * Re-establish every chat's stored backend/model override against the
205
224
  * backends this boot actually has. Chats are independent, so they are
206
225
  * reconciled `CHAT_BINDING_CONCURRENCY` at a time; a shared backend that
207
226
  * two chats need at once is initialised exactly once by the pool.
227
+ *
228
+ * Never deletes chat history. A stale override is corrected (remapped to
229
+ * the closest valid id, or cleared so the backend default serves) and the
230
+ * operator is told once, naming every chat that changed. Only a chat whose
231
+ * backend is gone gets a fresh backend session — its old session id is
232
+ * archived by `resetSession`, and its history rows stay.
208
233
  */
209
234
  export async function reconcileChatBindings(
210
235
  config: TalonConfig,
211
236
  deps: ChatBindingDeps,
212
237
  ): Promise<void> {
213
238
  const { getAllChatSettings } = await import("./storage/chat-settings.js");
239
+ const changes: string[] = [];
214
240
  await mapConcurrent(
215
241
  Object.entries(getAllChatSettings()),
216
242
  CHAT_BINDING_CONCURRENCY,
217
- ([cid, settings]) => reconcileChatBinding(cid, settings, config, deps),
243
+ async ([cid, settings]) => {
244
+ const change = await reconcileChatBinding(cid, settings, config, deps);
245
+ if (change) changes.push(`• ${cid}: ${change}`);
246
+ },
247
+ );
248
+ if (changes.length === 0) return;
249
+ const text =
250
+ `Boot reconcile adjusted ${changes.length} chat(s) whose pinned backend/model ` +
251
+ `is no longer available (chat history kept):\n${changes.sort().join("\n")}`;
252
+ logWarn("bot", text);
253
+ (deps.notify ?? defaultReconcileNotify)(text);
254
+ }
255
+
256
+ function defaultReconcileNotify(text: string): void {
257
+ void import("./core/frontend-runtime/alerts.js").then(({ raiseAlert }) =>
258
+ raiseAlert("chat-bindings.reconciled", text, { severity: "warn" }),
218
259
  );
219
260
  }
220
261
 
262
+ /**
263
+ * The closest valid id for a model the catalog no longer lists, or null.
264
+ * Today: `<id>[1m]` → `<id>` (SDK catalogs stopped enumerating the 1M
265
+ * context variants), so a chat keeps its model family instead of being
266
+ * dropped to the backend default.
267
+ */
268
+ async function remapModelAlias(
269
+ backend: Backend,
270
+ model: string,
271
+ deps: ChatBindingDeps,
272
+ ): Promise<string | null> {
273
+ if (!ONE_MILLION_SUFFIX.test(model)) return null;
274
+ const stem = model.replace(ONE_MILLION_SUFFIX, "").trim();
275
+ if (!stem) return null;
276
+ return (await deps.isModelValidForBackend(backend, stem)) ? stem : null;
277
+ }
278
+
279
+ /**
280
+ * Reconcile one chat. Returns a short description of what changed, or
281
+ * null when nothing did.
282
+ */
221
283
  async function reconcileChatBinding(
222
284
  cid: string,
223
285
  settings: { backend?: string; model?: string },
224
286
  config: TalonConfig,
225
287
  deps: ChatBindingDeps,
226
- ): Promise<void> {
227
- const { getAllChatSettings, setChatBackend, setChatModel } =
228
- await import("./storage/chat-settings.js");
229
- let resetVolatileState = false;
288
+ ): Promise<string | null> {
289
+ const changes: string[] = [];
230
290
  if (settings.backend) {
231
- if (!deps.isBackendAvailable(settings.backend, config)) {
232
- log(
233
- "bot",
234
- `Per-chat backend ${settings.backend} for ${cid} is no longer available — resetting chat to default backend`,
235
- );
236
- await deps.releaseChat(cid);
237
- setChatBackend(cid, undefined);
238
- setChatModel(cid, undefined);
239
- resetVolatileState = true;
240
- } else {
241
- let result = await deps.rebindChat(cid, settings.backend, config);
242
- if (!result.ok) {
243
- await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
244
- result = await deps.rebindChat(cid, settings.backend, config);
245
- }
246
- if (!result.ok) {
247
- log(
248
- "bot",
249
- `Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
250
- );
251
- }
252
- }
291
+ const change = await reconcileChatBackend(
292
+ cid,
293
+ settings.backend,
294
+ config,
295
+ deps,
296
+ );
297
+ if (change) changes.push(change);
253
298
  }
254
299
  const bindingMatchesSetting =
255
300
  !settings.backend || deps.getBackendIdForChat(cid) === settings.backend;
256
- const currentModel = getAllChatSettings()[cid]?.model;
257
- if (currentModel && bindingMatchesSetting) {
258
- const be = deps.getBackendForChat(cid);
259
- try {
260
- const valid = await deps.isModelValidForBackend(be, currentModel);
261
- if (!valid) {
262
- log(
263
- "bot",
264
- `Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
265
- );
266
- setChatModel(cid, undefined);
267
- resetVolatileState = true;
268
- }
269
- } catch (err) {
270
- log(
301
+ if (bindingMatchesSetting) {
302
+ const change = await reconcileChatModel(cid, deps);
303
+ if (change) changes.push(change);
304
+ }
305
+ return changes.length > 0 ? changes.join("; ") : null;
306
+ }
307
+
308
+ async function reconcileChatBackend(
309
+ cid: string,
310
+ backendId: string,
311
+ config: TalonConfig,
312
+ deps: ChatBindingDeps,
313
+ ): Promise<string | null> {
314
+ const { setChatBackend, clearLegacyChatModel } =
315
+ await import("./storage/chat-settings.js");
316
+ if (!deps.isBackendAvailable(backendId, config)) {
317
+ logWarn(
318
+ "bot",
319
+ `Per-chat backend ${backendId} for ${cid} is no longer available — serving the chat on the default backend (history kept)`,
320
+ );
321
+ await deps.releaseChat(cid);
322
+ setChatBackend(cid, undefined);
323
+ // The per-backend model picks stay: they are keyed by backend, so the
324
+ // default backend's pick (if any) still applies and the vanished
325
+ // backend's comes back with it. Only the unkeyed legacy slot goes.
326
+ clearLegacyChatModel(cid);
327
+ // The stored session belongs to the vanished backend and cannot resume
328
+ // on another one. resetSession archives its id; history is untouched.
329
+ resetSession(cid, "backend-unavailable");
330
+ return `backend ${backendId} unavailable → default backend`;
331
+ }
332
+ let result = await deps.rebindChat(cid, backendId, config);
333
+ if (!result.ok) {
334
+ await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
335
+ result = await deps.rebindChat(cid, backendId, config);
336
+ }
337
+ if (!result.ok) {
338
+ log(
339
+ "bot",
340
+ `Per-chat backend rebind failed for ${cid} → ${backendId}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
341
+ );
342
+ }
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * Check the chat's pinned model against the backend that serves it.
348
+ * The unkeyed legacy slot is remapped or cleared when stale; a per-backend
349
+ * pick is only ever remapped (the send-time resolver already falls back
350
+ * past a stale one, and a catalog that failed to load must not erase it).
351
+ * The backend session is kept either way: a resume under a different
352
+ * model is fine, and the conversation is the point.
353
+ */
354
+ async function reconcileChatModel(
355
+ cid: string,
356
+ deps: ChatBindingDeps,
357
+ ): Promise<string | null> {
358
+ const {
359
+ getAllChatSettings,
360
+ getChatModelForBackend,
361
+ setChatModelForBackend,
362
+ clearLegacyChatModel,
363
+ } = await import("./storage/chat-settings.js");
364
+ const backendId = deps.getBackendIdForChat(cid);
365
+ const legacy = getAllChatSettings()[cid]?.model;
366
+ const model = legacy ?? getChatModelForBackend(cid, backendId);
367
+ if (!model) return null;
368
+ const be = deps.getBackendForChat(cid);
369
+ try {
370
+ if (await deps.isModelValidForBackend(be, model)) return null;
371
+ const remapped = await remapModelAlias(be, model, deps);
372
+ if (remapped) {
373
+ setChatModelForBackend(cid, backendId, remapped);
374
+ if (legacy) clearLegacyChatModel(cid);
375
+ logWarn(
271
376
  "bot",
272
- `Per-chat model validation failed for ${cid} (${currentModel}): ${
273
- err instanceof Error ? err.message : String(err)
274
- } — keeping stored model`,
377
+ `Per-chat model ${model} for ${cid} is not in the ${backendId} catalog — remapped to ${remapped} (history and session kept)`,
275
378
  );
379
+ return `model ${model} → ${remapped}`;
276
380
  }
277
- }
278
- if (resetVolatileState) {
279
- resetSession(cid);
280
- clearHistory(cid);
381
+ if (!legacy) return null;
382
+ clearLegacyChatModel(cid);
383
+ logWarn(
384
+ "bot",
385
+ `Per-chat model ${model} for ${cid} is not valid for its backend — falling back to the backend default (history and session kept)`,
386
+ );
387
+ return `model ${model} unavailable → backend default`;
388
+ } catch (err) {
389
+ log(
390
+ "bot",
391
+ `Per-chat model validation failed for ${cid} (${model}): ${
392
+ err instanceof Error ? err.message : String(err)
393
+ } — keeping stored model`,
394
+ );
395
+ return null;
281
396
  }
282
397
  }
283
398
 
@@ -370,11 +485,10 @@ export async function initBackendAndDispatcher(
370
485
  );
371
486
 
372
487
  // Re-acquire any persisted per-chat backend/model overrides so chats
373
- // resume exactly where they were before restart. If a backend has
374
- // since been disabled/removed, or the stored model is no longer valid
375
- // for the backend that would serve it, clear the override and reset
376
- // volatile chat state so the next user message starts a fresh default
377
- // session instead of crashing on an orphaned model id.
488
+ // resume exactly where they were before restart. A backend that has
489
+ // since gone, or a stored model its backend no longer lists, is
490
+ // corrected (remapped or cleared to the default) — never by deleting
491
+ // the chat's history. See reconcileChatBindings.
378
492
  await bootPhase("chat bindings", () =>
379
493
  reconcileChatBindings(config, {
380
494
  isBackendAvailable,
@@ -271,7 +271,10 @@ async function backupStatus(): Promise<void> {
271
271
  renderResult(result);
272
272
  return;
273
273
  }
274
- const status = await collectBackupStatus({ withTargets: false });
274
+ const status = await collectBackupStatus({
275
+ withTargets: false,
276
+ settings: resolveBackupSettings(loadConfig().backup),
277
+ });
275
278
  console.log(
276
279
  `\n${formatBackupStatus(status)
277
280
  .split("\n")
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `talon history` — the operator's view of chat history the daemon keeps.
3
+ *
4
+ * Talon never deletes chat history on its own: /reset is a soft reset
5
+ * (a context marker), backend switches leave history alone, and deleting a
6
+ * chat in the app only hides it. `purge` is the one way to delete rows for
7
+ * real — host access only (it is not reachable from any chat), and it asks
8
+ * for the chat id to be typed back before touching anything.
9
+ */
10
+
11
+ import { createInterface } from "node:readline/promises";
12
+ import pc from "picocolors";
13
+ import {
14
+ getChatHistoryState,
15
+ getHistoryStats,
16
+ listHiddenChats,
17
+ purgeChatHistory,
18
+ } from "../../storage/history.js";
19
+ import { clearTurnMeta } from "../../storage/turn-meta.js";
20
+
21
+ const USAGE = [
22
+ ` Usage: ${pc.cyan("talon history <command>")}`,
23
+ "",
24
+ " Commands:",
25
+ ` ${pc.cyan("show <chatId>")} Row count, date range, reset/hidden state`,
26
+ ` ${pc.cyan("hidden")} Chats deleted in a client (rows kept)`,
27
+ ` ${pc.cyan("purge <chatId> [--yes]")} PERMANENTLY delete a chat's history`,
28
+ "",
29
+ " Resets, backend switches and chat deletion never delete history;",
30
+ ` ${pc.cyan("purge")} is the only command that does. Take a checkpoint first:`,
31
+ ` ${pc.cyan('talon backup now --checkpoint "before purge"')}`,
32
+ "",
33
+ ].join("\n");
34
+
35
+ /** Reads one line from stdin; injectable so tests can answer the prompt. */
36
+ export type HistoryCliIo = {
37
+ ask: (question: string) => Promise<string>;
38
+ print: (line: string) => void;
39
+ };
40
+
41
+ const defaultIo: HistoryCliIo = {
42
+ ask: async (question) => {
43
+ const rl = createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+ try {
48
+ return await rl.question(question);
49
+ } finally {
50
+ rl.close();
51
+ }
52
+ },
53
+ print: (line) => console.log(line),
54
+ };
55
+
56
+ function date(ms: number | undefined): string {
57
+ return ms ? new Date(ms).toISOString() : "—";
58
+ }
59
+
60
+ function cmdShow(chatId: string, io: HistoryCliIo): void {
61
+ const stats = getHistoryStats(chatId);
62
+ const state = getChatHistoryState(chatId);
63
+ io.print(` ${pc.bold(chatId)}`);
64
+ io.print(
65
+ ` ${stats.totalMessages} message(s), ${stats.uniqueUsers} sender(s), ` +
66
+ `${date(stats.oldestTimestamp || undefined)} → ${date(stats.newestTimestamp || undefined)}`,
67
+ );
68
+ if (state?.clearedAt !== undefined) {
69
+ io.print(` context reset at ${date(state.clearedAt)} (rows kept)`);
70
+ }
71
+ if (state?.hiddenAt !== undefined) {
72
+ io.print(` deleted in a client at ${date(state.hiddenAt)} (rows kept)`);
73
+ }
74
+ io.print("");
75
+ }
76
+
77
+ function cmdHidden(io: HistoryCliIo): void {
78
+ const chats = listHiddenChats();
79
+ if (chats.length === 0) {
80
+ io.print(` ${pc.dim("No hidden chats.")}\n`);
81
+ return;
82
+ }
83
+ for (const chat of chats) {
84
+ io.print(
85
+ ` ${pc.bold(chat.chatId)} ${chat.total} message(s) ${pc.dim(`hidden ${date(chat.hiddenAt)}`)}`,
86
+ );
87
+ }
88
+ io.print("");
89
+ }
90
+
91
+ /**
92
+ * Delete a chat's history for good. Without `--yes` the operator must type
93
+ * the chat id back; anything else aborts. Returns rows deleted (0 when
94
+ * aborted or empty).
95
+ */
96
+ async function cmdPurge(
97
+ chatId: string,
98
+ yes: boolean,
99
+ io: HistoryCliIo,
100
+ ): Promise<number> {
101
+ const { totalMessages } = getHistoryStats(chatId);
102
+ if (totalMessages === 0 && getChatHistoryState(chatId) === undefined) {
103
+ io.print(` ${pc.dim(`No history stored for ${chatId}.`)}\n`);
104
+ return 0;
105
+ }
106
+ if (!yes) {
107
+ io.print(
108
+ `\n This PERMANENTLY deletes ${pc.bold(String(totalMessages))} message(s) of chat ${pc.bold(chatId)}.\n` +
109
+ ` It cannot be undone except from a backup. Take a checkpoint first:\n` +
110
+ ` ${pc.cyan('talon backup now --checkpoint "before purge"')}\n`,
111
+ );
112
+ const answer = (await io.ask(` Type the chat id to confirm: `)).trim();
113
+ if (answer !== chatId) {
114
+ io.print(` ${pc.yellow("●")} Aborted — nothing deleted.\n`);
115
+ return 0;
116
+ }
117
+ }
118
+ const deleted = purgeChatHistory(chatId);
119
+ clearTurnMeta(chatId);
120
+ io.print(` ${pc.green("●")} Purged ${deleted} message(s) from ${chatId}.\n`);
121
+ return deleted;
122
+ }
123
+
124
+ /** Route a `talon history <command>` invocation. */
125
+ export async function runHistoryCommand(
126
+ args: readonly string[],
127
+ io: HistoryCliIo = defaultIo,
128
+ ): Promise<void> {
129
+ const positional = args.filter((a) => !a.startsWith("--"));
130
+ const yes = args.includes("--yes");
131
+ const chatId = positional[1];
132
+ try {
133
+ switch (positional[0]) {
134
+ case "show":
135
+ if (!chatId) io.print(` ${pc.red("✖")} show needs a chat id\n`);
136
+ else cmdShow(chatId, io);
137
+ break;
138
+ case "hidden":
139
+ cmdHidden(io);
140
+ break;
141
+ case "purge":
142
+ if (!chatId) io.print(` ${pc.red("✖")} purge needs a chat id\n`);
143
+ else await cmdPurge(chatId, yes, io);
144
+ break;
145
+ default:
146
+ io.print(USAGE);
147
+ }
148
+ } catch (err) {
149
+ io.print(` ${pc.red("✖")} ${err instanceof Error ? err.message : err}`);
150
+ process.exitCode = 1;
151
+ }
152
+ }
package/src/cli/index.ts CHANGED
@@ -37,6 +37,7 @@ import { showEvents } from "./events.js";
37
37
  import { runPluginCommand } from "./plugin.js";
38
38
  import { runSkillCommand } from "./skill.js";
39
39
  import { runMemoryCommand } from "./memory.js";
40
+ import { runHistoryCommand } from "./commands/history.js";
40
41
  import { mainMenu } from "./menu.js";
41
42
  import { runBackupCommand } from "./commands/backup.js";
42
43
  import { runMeshCommand } from "./commands/mesh.js";
@@ -62,6 +63,7 @@ const CLI_COMMANDS = [
62
63
  "plugin",
63
64
  "skill",
64
65
  "memory",
66
+ "history",
65
67
  "backup",
66
68
  "mesh",
67
69
  ];
@@ -109,6 +111,9 @@ function printHelp(): void {
109
111
  console.log(
110
112
  ` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
111
113
  );
114
+ console.log(
115
+ ` ${pc.cyan("history")} Chat history kept by Talon (show/hidden/purge)`,
116
+ );
112
117
  console.log(
113
118
  ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
114
119
  );
@@ -180,6 +185,7 @@ const COMMANDS: Record<string, CommandHandler> = {
180
185
  plugin: (args) => runPluginCommand(args),
181
186
  skill: (args) => runSkillCommand(args),
182
187
  memory: (args) => runMemoryCommand(args),
188
+ history: (args) => runHistoryCommand(args),
183
189
  "--version": () => console.log(pkg.version),
184
190
  "-v": () => console.log(pkg.version),
185
191
  "--help": () => printHelp(),