talon-agent 5.26.2 → 5.26.4

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 (40) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/remote-server/sessions.ts +82 -13
  4. package/src/backend/runtime/turn/handle-retry.ts +6 -5
  5. package/src/cli/commands/backup.ts +7 -3
  6. package/src/cli/commands/history.ts +152 -0
  7. package/src/cli/index.ts +6 -0
  8. package/src/core/backup/archive/verify.ts +53 -0
  9. package/src/core/backup/plan.ts +3 -0
  10. package/src/core/backup/retention/policy.ts +195 -0
  11. package/src/core/backup/scheduler.ts +10 -4
  12. package/src/core/backup/snapshot.ts +3 -0
  13. package/src/core/backup/status.ts +7 -0
  14. package/src/core/backup/store.ts +20 -25
  15. package/src/core/backup/types.ts +14 -0
  16. package/src/core/backup/upload.ts +37 -20
  17. package/src/core/config/index.ts +26 -1
  18. package/src/core/errors.ts +13 -2
  19. package/src/frontend/discord/admin.ts +2 -2
  20. package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
  21. package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
  22. package/src/frontend/native/chats/chats.ts +14 -2
  23. package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
  24. package/src/frontend/native/chats/reset.ts +39 -17
  25. package/src/frontend/native/commands/session.ts +2 -2
  26. package/src/frontend/native/surface/models.ts +3 -3
  27. package/src/frontend/native/turn/turn-meta.ts +0 -6
  28. package/src/frontend/presentation/backup-panel.ts +7 -1
  29. package/src/frontend/presentation/model-commands.ts +8 -11
  30. package/src/frontend/presentation/session-status.ts +12 -13
  31. package/src/frontend/presentation/text-commands.ts +6 -9
  32. package/src/frontend/telegram/admin/sessions.ts +2 -1
  33. package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
  34. package/src/frontend/whatsapp/commands.ts +5 -8
  35. package/src/storage/history.ts +167 -17
  36. package/src/storage/repositories/history-repo.ts +75 -7
  37. package/src/storage/sql/history.sql +45 -4
  38. package/src/storage/sql/schema.sql +13 -0
  39. package/src/storage/sql/statements.generated.ts +49 -5
  40. package/src/storage/turn-meta.ts +1 -1
@@ -34,6 +34,11 @@ import { dirs } from "../../util/paths.js";
34
34
  import { passphraseProblem } from "./passphrase.js";
35
35
  import { buildSnapshot } from "./snapshot.js";
36
36
  import { listLocalManifests, pruneLocal, reconcileIndex } from "./store.js";
37
+ import {
38
+ describeRetention,
39
+ localRetention,
40
+ remoteRetention,
41
+ } from "./retention/policy.js";
37
42
  import { discoverTargets, selectTargets } from "./targets.js";
38
43
  import { pruneRemote, uploadSnapshot } from "./upload.js";
39
44
  import type { BackupSettings, Manifest, SnapshotKind } from "./types.js";
@@ -162,7 +167,7 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
162
167
  parts: manifest.parts.length,
163
168
  durationMs: Date.now() - started,
164
169
  });
165
- await _backupDeps.pruneLocal(settings.keepLocal, state.home);
170
+ await _backupDeps.pruneLocal(localRetention(settings), state.home);
166
171
  if (!request.localOnly) {
167
172
  const targets = selectTargets(
168
173
  await _backupDeps.discover(),
@@ -170,7 +175,7 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
170
175
  );
171
176
  if (targets.length > 0) {
172
177
  await _backupDeps.upload(manifest, targets, state.home);
173
- await _backupDeps.pruneRemote(targets, settings.keepRemote);
178
+ await _backupDeps.pruneRemote(targets, remoteRetention(settings));
174
179
  }
175
180
  }
176
181
  backoff.succeed();
@@ -379,8 +384,9 @@ export async function initBackup(options: {
379
384
  log(
380
385
  "backup",
381
386
  `Scheduled every ${options.settings.intervalHours}h; first run in ` +
382
- `${Math.round(delay / 60_000)}m (keep ${options.settings.keepLocal} local, ` +
383
- `${options.settings.keepRemote} remote)`,
387
+ `${Math.round(delay / 60_000)}m (retention ` +
388
+ `${describeRetention(localRetention(options.settings))}; ` +
389
+ `${options.settings.keepRemote} newest per remote target)`,
384
390
  );
385
391
  }
386
392
 
@@ -54,6 +54,7 @@ import {
54
54
  import { signManifest } from "./archive/manifest-auth.js";
55
55
  import { TarWriter } from "./archive/tar.js";
56
56
  import { createCompressor } from "./archive/zstd.js";
57
+ import { verifyWrittenParts } from "./archive/verify.js";
57
58
  import {
58
59
  collectTree,
59
60
  excludeForRoot,
@@ -755,6 +756,7 @@ export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
755
756
  }
756
757
  }
757
758
  await removeScratch(dir);
759
+ const verifiedAt = await verifyWrittenParts(dir, parts, passphrase);
758
760
 
759
761
  const gitHead = await readGitHead(process.cwd());
760
762
  const manifest: Manifest = {
@@ -776,6 +778,7 @@ export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
776
778
  ...(palaceHash ? { palaceHash } : {}),
777
779
  sizeBytes: parts.reduce((sum, part) => sum + part.bytes, 0),
778
780
  remote: {},
781
+ verifiedAt,
779
782
  };
780
783
  if (passphrase) manifest.auth = await signManifest(manifest, passphrase);
781
784
  await writeManifest(manifest, home);
@@ -53,6 +53,10 @@ export type BackupStatus = {
53
53
  type BackupPolicy = {
54
54
  keepLocal: number;
55
55
  keepRemote: number;
56
+ /** Absent on a policy built before tiered retention (older daemons). */
57
+ keepDaily?: number;
58
+ keepWeekly?: number;
59
+ keepCheckpoints?: number;
56
60
  /**
57
61
  * Snapshots are written encrypted: `backup.encryption` is configured or
58
62
  * the passphrase comes from the environment (see passphrase.ts). Off
@@ -69,6 +73,9 @@ function describePolicy(
69
73
  return {
70
74
  keepLocal: settings.keepLocal,
71
75
  keepRemote: settings.keepRemote,
76
+ keepDaily: settings.keepDaily,
77
+ keepWeekly: settings.keepWeekly,
78
+ keepCheckpoints: settings.keepCheckpoints,
72
79
  encrypted:
73
80
  settings.encryption !== undefined || Boolean(env[PASSPHRASE_ENV]?.trim()),
74
81
  };
@@ -33,6 +33,11 @@ import {
33
33
  updateBackupManifest,
34
34
  } from "../../storage/backup/index.js";
35
35
  import { pathExists } from "./sources/sessions.js";
36
+ import {
37
+ describeRetention,
38
+ planRetention,
39
+ type RetentionPolicy,
40
+ } from "./retention/policy.js";
36
41
  import type {
37
42
  Manifest,
38
43
  RemoteState,
@@ -236,26 +241,6 @@ export async function setSnapshotPinned(
236
241
 
237
242
  // ── Retention ───────────────────────────────────────────────────────────────
238
243
 
239
- /**
240
- * Which snapshots to drop so that at most `keep` unpinned ones remain.
241
- * Pure, and the order is the one the caller sees: newest first, pinned
242
- * snapshots kept unconditionally and not counted against the budget —
243
- * pinning is the user saying "this one outlives the policy".
244
- */
245
- export function selectPrunable<
246
- T extends { id: string; createdAt: number; pinned: boolean },
247
- >(snapshots: readonly T[], keep: number): T[] {
248
- const ordered = [...snapshots].sort((a, b) => b.createdAt - a.createdAt);
249
- const doomed: T[] = [];
250
- let kept = 0;
251
- for (const snapshot of ordered) {
252
- if (snapshot.pinned) continue;
253
- kept += 1;
254
- if (kept > keep) doomed.push(snapshot);
255
- }
256
- return doomed;
257
- }
258
-
259
244
  /** Delete a snapshot directory and its index rows. */
260
245
  async function removeSnapshot(
261
246
  id: string,
@@ -266,14 +251,24 @@ async function removeSnapshot(
266
251
  deleteBackup(id);
267
252
  }
268
253
 
269
- /** Apply the local retention policy. Returns the ids removed. */
254
+ /**
255
+ * Apply the local retention policy (see retention/policy.ts). Returns the ids
256
+ * removed. A directory whose manifest cannot be read never reaches the
257
+ * listing, so it is never pruned.
258
+ */
270
259
  export async function pruneLocal(
271
- keep: number,
260
+ policy: RetentionPolicy,
272
261
  home: string = dirs.root,
273
262
  ): Promise<string[]> {
274
- const doomed = selectPrunable(await listLocalManifests(home), keep);
263
+ const plan = planRetention(await listLocalManifests(home), policy);
264
+ for (const manifest of plan.skipped) {
265
+ logWarn(
266
+ "backup",
267
+ `Not pruning ${manifest.id}: its manifest has no usable createdAt`,
268
+ );
269
+ }
275
270
  const removed: string[] = [];
276
- for (const manifest of doomed) {
271
+ for (const manifest of plan.prune) {
277
272
  try {
278
273
  await removeSnapshot(manifest.id, home);
279
274
  removed.push(manifest.id);
@@ -284,7 +279,7 @@ export async function pruneLocal(
284
279
  if (removed.length > 0) {
285
280
  log(
286
281
  "backup",
287
- `Pruned ${removed.length} local snapshot(s) (keepLocal=${keep})`,
282
+ `Pruned ${removed.length} local snapshot(s) (${describeRetention(policy)})`,
288
283
  );
289
284
  }
290
285
  return removed;
@@ -123,6 +123,14 @@ export type Manifest = {
123
123
  /** Total bytes of all parts. */
124
124
  sizeBytes: number;
125
125
  remote: Record<string, RemoteState>;
126
+ /**
127
+ * Epoch ms at which every part was read back and matched its digest
128
+ * (and, when encrypted, decrypted end to end) — see archive/verify.ts. Set
129
+ * before signing, so the MAC covers it. Absent on snapshots written
130
+ * before verification existed. Retention never prunes the newest
131
+ * verified snapshot.
132
+ */
133
+ verifiedAt?: number;
126
134
  /** MAC under the backup passphrase; absent on plaintext/legacy snapshots. */
127
135
  auth?: ManifestAuth;
128
136
  };
@@ -146,6 +154,12 @@ export type BackupSettings = {
146
154
  intervalHours: number;
147
155
  keepLocal: number;
148
156
  keepRemote: number;
157
+ /** Newest snapshot per day, for this many days (0 = off). */
158
+ keepDaily: number;
159
+ /** Newest snapshot per ISO week, for this many weeks (0 = off). */
160
+ keepWeekly: number;
161
+ /** Unpinned checkpoints kept, apart from the scheduled snapshots. */
162
+ keepCheckpoints: number;
149
163
  includePalace: boolean;
150
164
  /** WhatsApp auth + userbot session: see {@link LoginSessionsPolicy}. */
151
165
  loginSessions: LoginSessionsPolicy;
@@ -30,11 +30,11 @@ import {
30
30
  } from "../../storage/backup/index.js";
31
31
  import { isEncryptedFile } from "./archive/crypt.js";
32
32
  import {
33
- partPath,
34
- reindexSnapshot,
35
- selectPrunable,
36
- writeManifest,
37
- } from "./store.js";
33
+ describeRetention,
34
+ planRetention,
35
+ type RetentionPolicy,
36
+ } from "./retention/policy.js";
37
+ import { partPath, reindexSnapshot, writeManifest } from "./store.js";
38
38
  import type { BackupTarget } from "./targets.js";
39
39
  import type { Manifest, RemoteState, SnapshotPart } from "./types.js";
40
40
 
@@ -182,14 +182,19 @@ async function uploadToAll(
182
182
  }
183
183
 
184
184
  /**
185
- * Apply the remote retention policy on each target. Same rule as local:
186
- * the newest `keep` unpinned snapshots survive, pinned ones always do.
187
- * A target that cannot list is skipped — deleting on a partial listing is
188
- * how a retention pass turns into data loss.
185
+ * Apply the remote retention policy on each target — the same tiers as
186
+ * local (see retention/policy.ts), with `keepRemote` as the newest-N tier.
187
+ *
188
+ * Two refusals keep a retention pass from turning into data loss:
189
+ * - A target that cannot list is skipped: deleting on a partial
190
+ * listing is how that happens.
191
+ * - An entry whose manifest is missing, unreadable, or has no
192
+ * createdAt is never pruned. Without an age it cannot be ranked,
193
+ * and treating it as the oldest would delete it first.
189
194
  */
190
195
  export async function pruneRemote(
191
196
  targets: readonly BackupTarget[],
192
- keep: number,
197
+ policy: RetentionPolicy,
193
198
  ): Promise<void> {
194
199
  for (const target of targets) {
195
200
  if (!target.ready) continue;
@@ -203,15 +208,27 @@ export async function pruneRemote(
203
208
  );
204
209
  continue;
205
210
  }
206
- const doomed = selectPrunable(
207
- snapshots.map((entry) => ({
208
- id: entry.snapshotId,
209
- createdAt: entry.manifest?.createdAt ?? 0,
210
- pinned: entry.manifest?.pinned === true,
211
- })),
212
- keep,
211
+ const plan = planRetention(
212
+ snapshots.map((entry) => {
213
+ const manifest = entry.manifest as Partial<Manifest> | undefined;
214
+ return {
215
+ id: entry.snapshotId,
216
+ createdAt: manifest?.createdAt as number,
217
+ pinned: manifest?.pinned === true,
218
+ kind: manifest?.kind,
219
+ verifiedAt: manifest?.verifiedAt,
220
+ };
221
+ }),
222
+ policy,
213
223
  );
214
- for (const victim of doomed) {
224
+ if (plan.skipped.length > 0) {
225
+ logWarn(
226
+ "backup",
227
+ `Not pruning ${plan.skipped.length} snapshot(s) on ${target.id} whose manifest ` +
228
+ `is unreadable or has no createdAt: ${plan.skipped.map((s) => s.id).join(", ")}`,
229
+ );
230
+ }
231
+ for (const victim of plan.prune) {
215
232
  try {
216
233
  await target.remove(victim.id);
217
234
  deleteBackupRemote(victim.id, target.id);
@@ -222,10 +239,10 @@ export async function pruneRemote(
222
239
  );
223
240
  }
224
241
  }
225
- if (doomed.length > 0) {
242
+ if (plan.prune.length > 0) {
226
243
  log(
227
244
  "backup",
228
- `Pruned ${doomed.length} snapshot(s) from ${target.id} (keepRemote=${keep})`,
245
+ `Pruned ${plan.prune.length} snapshot(s) from ${target.id} (${describeRetention(policy)})`,
229
246
  );
230
247
  }
231
248
  }
@@ -679,7 +679,8 @@ const configSchema = z.object({
679
679
  * Backups & checkpoints (docs/backups.md). Talon's only safety net, so
680
680
  * it is on by default: every `intervalHours` it writes a snapshot of
681
681
  * the identity, state, database and memory under ~/.talon/backups/,
682
- * keeps `keepLocal` of them, and uploads to whatever remote targets
682
+ * keeps them on a tiered schedule (newest `keepLocal`, one a day for
683
+ * `keepDaily` days, one a week for `keepWeekly` weeks), and uploads to whatever remote targets
683
684
  * are registered (`targets: []` keeps everything local).
684
685
  *
685
686
  * - `workspaceInclude` — the workspace is mostly bulk that can be
@@ -716,6 +717,30 @@ const configSchema = z.object({
716
717
  .min(1)
717
718
  .max(1000)
718
719
  .default(DEFAULT_BACKUP_SETTINGS.keepRemote),
720
+ /**
721
+ * Tiered retention on top of the newest `keepLocal`/`keepRemote`
722
+ * (docs/backups.md#retention): the newest snapshot of each of the
723
+ * last `keepDaily` days and `keepWeekly` weeks, plus up to
724
+ * `keepCheckpoints` unpinned checkpoints counted on their own.
725
+ */
726
+ keepDaily: z
727
+ .number()
728
+ .int()
729
+ .min(0)
730
+ .max(1000)
731
+ .default(DEFAULT_BACKUP_SETTINGS.keepDaily),
732
+ keepWeekly: z
733
+ .number()
734
+ .int()
735
+ .min(0)
736
+ .max(1000)
737
+ .default(DEFAULT_BACKUP_SETTINGS.keepWeekly),
738
+ keepCheckpoints: z
739
+ .number()
740
+ .int()
741
+ .min(1)
742
+ .max(1000)
743
+ .default(DEFAULT_BACKUP_SETTINGS.keepCheckpoints),
719
744
  includePalace: z.boolean().default(DEFAULT_BACKUP_SETTINGS.includePalace),
720
745
  /**
721
746
  * WhatsApp auth + the userbot's Telegram login. "local" (default)
@@ -139,6 +139,17 @@ function errorCodes(err: unknown, depth = 0): string[] {
139
139
 
140
140
  // ── Classify any error ──────────────────────────────────────────────────────
141
141
 
142
+ /**
143
+ * A backend session that expired or no longer exists. Claude's CLI answers
144
+ * a resume of a session whose transcript is missing with "No conversation
145
+ * found with session ID: …" — a definite not-found; without the match
146
+ * every later turn in the chat fails the same way. The reset that follows
147
+ * archives the old id (resetSession), so a transcript that turns up again
148
+ * can be re-attached.
149
+ */
150
+ const SESSION_EXPIRED_RE =
151
+ /session.*expired|expired.*session|invalid.*resume|no conversation found/i;
152
+
142
153
  /**
143
154
  * Wrap or classify any thrown value into a TalonError.
144
155
  * Call this at module boundaries (backend catch, bridge catch) to convert
@@ -259,8 +270,8 @@ export function classify(err: unknown): TalonError {
259
270
  });
260
271
  }
261
272
 
262
- // Session expired
263
- if (/session.*expired|expired.*session|invalid.*resume/i.test(msg)) {
273
+ // Session expired (or gone — see SESSION_EXPIRED_RE)
274
+ if (SESSION_EXPIRED_RE.test(msg)) {
264
275
  return new TalonError(msg, {
265
276
  reason: "session_expired",
266
277
  retryable: false,
@@ -12,7 +12,7 @@ import { tailFile } from "../../util/tail-file.js";
12
12
  import type { TalonConfig } from "../../core/config/index.js";
13
13
  import type { Gateway } from "../../core/engine/gateway.js";
14
14
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
15
- import { clearHistory } from "../../storage/history.js";
15
+ import { markContextCleared } from "../../storage/history.js";
16
16
  import { todayLogDate } from "../../storage/daily-log.js";
17
17
  import { getChatSettings } from "../../storage/chat-settings.js";
18
18
  import {
@@ -87,7 +87,7 @@ export async function handleAdminSubcommand(
87
87
  const target = rest[0];
88
88
  if (!target) return send("Usage: /admin kill <chatId>");
89
89
  resetSession(target);
90
- clearHistory(target);
90
+ markContextCleared(target); // soft reset: history rows are kept
91
91
  gateway?.backend?.sessions?.resetChat?.(target);
92
92
  return send(`Session ${target} reset.`);
93
93
  }
@@ -20,7 +20,6 @@ import {
20
20
  rebindChat,
21
21
  } from "../../../../core/engine/backend-controller/index.js";
22
22
  import { resetSession } from "../../../../storage/sessions.js";
23
- import { clearHistory } from "../../../../storage/history.js";
24
23
  import { resetPulseCheckpoint } from "../../../../core/background/pulse/pulse.js";
25
24
  import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
26
25
  import { logError } from "../../../../util/log.js";
@@ -109,12 +108,13 @@ export async function handleBackendSelect(
109
108
  }
110
109
 
111
110
  // Only now is the switch known to hold. Session state doesn't port across
112
- // backends, so it goes — but each backend's remembered model pick stays.
111
+ // backends, so it goes — but each backend's remembered model pick stays,
112
+ // and chat history is never touched (a switch changes who answers, not
113
+ // what was said).
113
114
  // A re-pick of the backend already in use clears nothing: the retry after
114
115
  // a timed-out interaction must not cost the session a second time.
115
116
  if (!alreadyThere) {
116
- resetSession(chatId);
117
- clearHistory(chatId);
117
+ resetSession(chatId, "backend-switch");
118
118
  resetPulseCheckpoint(chatId);
119
119
  previous?.sessions?.resetChat?.(chatId);
120
120
  }
@@ -6,7 +6,6 @@
6
6
  import { toClientChat } from "./chat-wire.js";
7
7
  import type { ClientChat } from "../protocol.js";
8
8
  import type { NativeRuntime } from "../runtime.js";
9
- import { clearTurnMeta } from "../turn/turn-meta.js";
10
9
 
11
10
  export function createChat(runtime: NativeRuntime, title?: string): ClientChat {
12
11
  const entry = runtime.chats.create(title);
@@ -27,10 +26,14 @@ export function renameChat(
27
26
  return chat;
28
27
  }
29
28
 
29
+ /**
30
+ * Soft delete (see NativeChats.remove): the chat disappears from every
31
+ * client, but its history rows and their turn meta are kept for the
32
+ * operator — `talon history purge` is the only hard delete.
33
+ */
30
34
  export function deleteChat(runtime: NativeRuntime, chatId: string): boolean {
31
35
  const ok = runtime.chats.remove(chatId);
32
36
  if (ok) {
33
- clearTurnMeta(chatId);
34
37
  runtime.contextByChat.delete(chatId);
35
38
  runtime.queuedByChat.delete(chatId);
36
39
  runtime.broadcast({ kind: "chat_deleted", chatId });
@@ -17,7 +17,11 @@ import {
17
17
  setSessionName,
18
18
  deleteSession,
19
19
  } from "../../../storage/sessions.js";
20
- import { getRecentHistory, clearHistory } from "../../../storage/history.js";
20
+ import {
21
+ getRecentHistory,
22
+ hideChatHistory,
23
+ isChatHistoryHidden,
24
+ } from "../../../storage/history.js";
21
25
  import { previewOf } from "../protocol.js";
22
26
 
23
27
  export type ChatEntry = {
@@ -55,6 +59,8 @@ export class NativeChats {
55
59
  restore(): void {
56
60
  for (const { chatId, info } of getAllSessions()) {
57
61
  if (!isNativeChatId(chatId)) continue;
62
+ // Deleted by the user: its rows are kept, the chat stays gone.
63
+ if (isChatHistoryHidden(chatId)) continue;
58
64
  const recent = getRecentHistory(chatId, 1);
59
65
  const entry: ChatEntry = {
60
66
  id: chatId,
@@ -133,13 +139,19 @@ export class NativeChats {
133
139
  return entry;
134
140
  }
135
141
 
142
+ /**
143
+ * Delete the chat from the app. A soft delete: the chat leaves every list
144
+ * and its session goes, but its history rows are only hidden, never
145
+ * deleted — the operator can still find them, and only an explicit
146
+ * `talon history purge` removes them for good.
147
+ */
136
148
  remove(id: string): boolean {
137
149
  const entry = this.byId.get(id);
138
150
  if (!entry) return false;
139
151
  this.byId.delete(id);
140
152
  this.byNum.delete(entry.numericId);
141
153
  deleteSession(id);
142
- clearHistory(id);
154
+ hideChatHistory(id);
143
155
  return true;
144
156
  }
145
157
 
@@ -28,7 +28,10 @@ function sweepEmptyChats(runtime: NativeRuntime): void {
28
28
  for (const entry of runtime.chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
29
29
  if (isBusy(runtime, entry.id) || runtime.queuedByChat.has(entry.id))
30
30
  continue;
31
- if (getRecentHistory(entry.id, 1).length > 0) continue;
31
+ // Any stored row at all — even from before a reset — means the chat
32
+ // was used; never sweep it.
33
+ if (getRecentHistory(entry.id, 1, { includeCleared: true }).length > 0)
34
+ continue;
32
35
  if (deleteChat(runtime, entry.id)) {
33
36
  log("native", `Swept empty chat ${entry.id}`);
34
37
  }
@@ -1,41 +1,63 @@
1
1
  /**
2
- * Chat reset — drop a chat's conversation (session, history, turn meta,
3
- * cached readouts, pulse checkpoint) while keeping the chat itself.
2
+ * Chat reset and backend hand-off — what a native chat forgets when its
3
+ * conversation restarts. Neither path deletes chat history: a reset is a
4
+ * soft reset (the context floor moves, the rows stay searchable), and a
5
+ * backend switch leaves history and the transcript exactly as they were.
4
6
  */
5
7
 
6
8
  import { resetSession } from "../../../storage/sessions.js";
7
- import { clearHistory } from "../../../storage/history.js";
9
+ import { markContextCleared } from "../../../storage/history.js";
8
10
  import { resetPulseCheckpoint } from "../../../core/background/pulse/pulse.js";
9
11
  import { getBackendForChat } from "../../../core/engine/backend-controller/index.js";
10
12
  import { broadcastChatUpdated } from "./chat-wire.js";
11
13
  import { emitSystem } from "../turn/emit.js";
12
14
  import type { NativeRuntime } from "../runtime.js";
13
- import { clearTurnMeta } from "../turn/turn-meta.js";
14
15
 
15
- /**
16
- * Forget everything the conversation accumulated. Shared by the explicit
17
- * reset and a backend switch (sessions aren't portable across backends).
18
- */
19
- export function wipeChatConversation(
16
+ /** Drop the per-process state tied to the chat's backend session. */
17
+ function dropSessionState(
20
18
  runtime: NativeRuntime,
21
19
  chatId: string,
20
+ reason: string,
22
21
  ): void {
23
- resetSession(chatId);
24
- clearHistory(chatId);
25
- clearTurnMeta(chatId);
22
+ resetSession(chatId, reason);
26
23
  runtime.contextByChat.delete(chatId);
27
24
  runtime.queuedByChat.delete(chatId);
28
25
  resetPulseCheckpoint(chatId);
29
26
  }
30
27
 
28
+ /**
29
+ * A backend switch: session ids aren't portable across backends, so the
30
+ * session goes (its id is archived by resetSession). History, turn meta
31
+ * and the transcript stay — a switch changes who answers, not what was
32
+ * said.
33
+ */
34
+ export function handOffChatBackend(
35
+ runtime: NativeRuntime,
36
+ chatId: string,
37
+ ): void {
38
+ dropSessionState(runtime, chatId, "backend-switch");
39
+ }
40
+
41
+ /**
42
+ * An explicit reset: the session goes and the chat's context starts fresh
43
+ * (a soft reset — every history row and its turn meta is kept, searchable
44
+ * and recoverable; the transcript and the bot's history tools start after
45
+ * the reset point).
46
+ */
47
+ function resetChatContext(runtime: NativeRuntime, chatId: string): void {
48
+ dropSessionState(runtime, chatId, "reset");
49
+ markContextCleared(chatId);
50
+ }
51
+
31
52
  export function resetChat(runtime: NativeRuntime, chatId: string): boolean {
32
53
  const entry = runtime.chats.get(chatId);
33
54
  if (!entry) return false;
34
- // Full reset, matching /reset on the other frontends: session,
35
- // history (the app re-fetches its transcript from us), pulse
36
- // checkpoint, and any in-process backend memory. Warm the fresh
37
- // session in the background — the bridge handler is sync.
38
- wipeChatConversation(runtime, chatId);
55
+ // Full reset, matching /reset on the other frontends: session, the
56
+ // chat's context (soft — the app re-fetches a transcript that starts
57
+ // after the reset; nothing is deleted), pulse checkpoint, and any
58
+ // in-process backend memory. Warm the fresh session in the background —
59
+ // the bridge handler is sync.
60
+ resetChatContext(runtime, chatId);
39
61
  let backend = null;
40
62
  try {
41
63
  backend = getBackendForChat(chatId);
@@ -28,7 +28,7 @@ import {
28
28
  statusCommandReply,
29
29
  } from "../../presentation/text-commands.js";
30
30
  import { broadcastChatUpdated } from "../chats/chat-wire.js";
31
- import { resetChat, wipeChatConversation } from "../chats/reset.js";
31
+ import { resetChat, handOffChatBackend } from "../chats/reset.js";
32
32
  import { setBackend } from "../surface/models.js";
33
33
  import { broadcastStatus } from "../surface/status.js";
34
34
  import { interruptTurn } from "../turn/turn.js";
@@ -48,7 +48,7 @@ async function model(ctx: NativeCommandContext): Promise<void> {
48
48
  },
49
49
  resetBackend: async () => {
50
50
  const outcome = await resetChatBackend(entry.id, deps);
51
- wipeChatConversation(runtime, entry.id);
51
+ handOffChatBackend(runtime, entry.id);
52
52
  broadcastStatus(runtime);
53
53
  return outcome.text;
54
54
  },
@@ -25,7 +25,7 @@ import { getActiveReasoningLevels } from "../../presentation/reasoning-levels.js
25
25
  import { broadcastChatUpdated } from "../chats/chat-wire.js";
26
26
  import { emitSystem } from "../turn/emit.js";
27
27
  import type { BackendOption, ModelOption } from "../protocol.js";
28
- import { wipeChatConversation } from "../chats/reset.js";
28
+ import { handOffChatBackend } from "../chats/reset.js";
29
29
  import type { NativeRuntime } from "../runtime.js";
30
30
  import { broadcastStatus } from "./status.js";
31
31
 
@@ -182,11 +182,11 @@ export async function setBackend(
182
182
  }
183
183
 
184
184
  setChatBackend(chatId, target);
185
- wipeChatConversation(runtime, chatId);
185
+ handOffChatBackend(runtime, chatId);
186
186
  emitSystem(
187
187
  runtime,
188
188
  entry,
189
- `Switched to ${target} — starting a fresh conversation.`,
189
+ `Switched to ${target} — new session, chat history kept.`,
190
190
  );
191
191
  broadcastChatUpdated(runtime, entry);
192
192
  broadcastStatus(runtime);
@@ -9,7 +9,6 @@
9
9
  import {
10
10
  recordTurnMeta as storeRecordTurnMeta,
11
11
  getTurnMeta as storeGetTurnMeta,
12
- clearTurnMeta as storeClearTurnMeta,
13
12
  } from "../../../storage/turn-meta.js";
14
13
  import type { ClientToolCall } from "../protocol.js";
15
14
 
@@ -33,8 +32,3 @@ export function recordTurnMeta(
33
32
  export function getTurnMeta(chatId: string, msgId: string): TurnMeta | null {
34
33
  return storeGetTurnMeta<TurnMeta>(chatId, msgId);
35
34
  }
36
-
37
- /** Forget a chat entirely (chat deleted / history cleared). */
38
- export function clearTurnMeta(chatId: string): void {
39
- storeClearTurnMeta(chatId);
40
- }
@@ -180,7 +180,13 @@ function storageLines(f: ReportFormatter, status: BackupStatus): string[] {
180
180
  if (policy) {
181
181
  lines.push(
182
182
  `${f.bold("Retention:")} newest ${policy.keepLocal} kept here, ` +
183
- `${policy.keepRemote} per target · pinned ones are never pruned`,
183
+ `${policy.keepRemote} per target` +
184
+ (policy.keepDaily ? ` · 1/day for ${policy.keepDaily}d` : "") +
185
+ (policy.keepWeekly ? ` · 1/week for ${policy.keepWeekly}w` : "") +
186
+ (policy.keepCheckpoints
187
+ ? ` · ${policy.keepCheckpoints} checkpoints`
188
+ : "") +
189
+ ` · pinned ones and the last verified are never pruned`,
184
190
  );
185
191
  lines.push(
186
192
  policy.encrypted