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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/app.ts +42 -1
- package/src/bootstrap.ts +171 -57
- package/src/cli/commands/backup.ts +4 -1
- package/src/cli/commands/history.ts +152 -0
- package/src/cli/index.ts +6 -0
- package/src/core/backup/boot/version-checkpoint.ts +199 -0
- package/src/core/backup/index.ts +3 -0
- package/src/core/backup/passphrase.ts +55 -2
- package/src/core/backup/restore.ts +32 -4
- package/src/core/backup/scheduler.ts +125 -13
- package/src/core/backup/status.ts +26 -3
- package/src/core/update/self-update.ts +144 -47
- package/src/frontend/discord/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
- package/src/frontend/discord/commands/admin.ts +22 -3
- package/src/frontend/discord/commands/definitions.ts +7 -0
- package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
- package/src/frontend/native/chats/chats.ts +14 -2
- package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
- package/src/frontend/native/chats/reset.ts +39 -17
- package/src/frontend/native/commands/session.ts +2 -2
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/turn/turn-meta.ts +0 -6
- package/src/frontend/presentation/model-commands.ts +8 -11
- package/src/frontend/presentation/session-status.ts +12 -13
- package/src/frontend/presentation/text-commands.ts +6 -9
- package/src/frontend/telegram/admin/sessions.ts +2 -1
- package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
- package/src/frontend/telegram/commands/admin.ts +21 -3
- package/src/frontend/whatsapp/commands.ts +5 -8
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/chat-settings.ts +13 -0
- package/src/storage/db.ts +5 -0
- package/src/storage/history.ts +167 -17
- package/src/storage/media-index.ts +4 -3
- package/src/storage/repositories/history-repo.ts +75 -7
- package/src/storage/sessions.ts +60 -1
- package/src/storage/sql/history.sql +45 -4
- package/src/storage/sql/schema.sql +13 -0
- package/src/storage/sql/statements.generated.ts +49 -5
- 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
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", () =>
|
|
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 {
|
|
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
|
-
|
|
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]) =>
|
|
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<
|
|
227
|
-
const
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
374
|
-
// since
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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({
|
|
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(),
|