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
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.2",
3
+ "version": "5.26.4",
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",
@@ -15,6 +15,7 @@ import {
15
15
  setSessionId,
16
16
  } from "../../storage/sessions.js";
17
17
  import { log, logWarn } from "../../util/log.js";
18
+ import { TalonError } from "../../core/errors.js";
18
19
  import type { RemoteAgentClient, RemotePermissionRule } from "./client.js";
19
20
  import type { RemoteServerState } from "./state.js";
20
21
  import {
@@ -73,32 +74,100 @@ export function buildPermissionRuleset(chatId: string): RemotePermissionRule[] {
73
74
  ];
74
75
  }
75
76
 
77
+ /** Pull an HTTP status out of whatever shape the SDK client threw. */
78
+ function errorStatus(err: unknown): number | undefined {
79
+ if (typeof err !== "object" || err === null) return undefined;
80
+ const e = err as Record<string, unknown>;
81
+ const cause = e.cause as Record<string, unknown> | undefined;
82
+ const response = e.response as Record<string, unknown> | undefined;
83
+ for (const candidate of [
84
+ e.status,
85
+ e.statusCode,
86
+ response?.status,
87
+ cause?.status,
88
+ ]) {
89
+ if (typeof candidate === "number") return candidate;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** The error's `name`, from the thrown object or the parsed body behind it. */
95
+ function errorNames(err: unknown): string[] {
96
+ if (typeof err !== "object" || err === null) return [];
97
+ const e = err as Record<string, unknown>;
98
+ const cause = e.cause as Record<string, unknown> | undefined;
99
+ const body = (cause?.body ?? e.body) as Record<string, unknown> | undefined;
100
+ return [e.name, body?.name, (e.data as Record<string, unknown>)?.name].filter(
101
+ (name): name is string => typeof name === "string",
102
+ );
103
+ }
104
+
105
+ /**
106
+ * True only when the server definitely says the session does not exist:
107
+ * an HTTP 404, or the server's `NotFoundError`. Everything else — a
108
+ * refused connection while the server is still starting, a 5xx, a
109
+ * timeout, a body that would not parse — is not proof the session is
110
+ * gone, and must not cost the chat its session.
111
+ */
112
+ export function isRemoteSessionNotFound(err: unknown): boolean {
113
+ if (errorStatus(err) === 404) return true;
114
+ return errorNames(err).includes("NotFoundError");
115
+ }
116
+
117
+ /** Waits between `session.get` attempts on a transient failure. */
118
+ const RESUME_RETRY_DELAYS_MS: readonly number[] = [500, 1_500];
119
+
76
120
  /**
77
121
  * Ensure a session exists for this chat on the remote agent server.
78
122
  *
79
123
  * Resumes the stored session id if `session.get` confirms it's still
80
- * alive. If the stored id is stale (any failure from `session.get`),
81
- * resets local state and creates a fresh session, returning the new
82
- * id. The fresh session is created with Talon's standard permission
83
- * ruleset (see {@link buildPermissionRuleset}).
124
+ * alive. Only a definite not-found (see {@link isRemoteSessionNotFound})
125
+ * resets the chat — archiving the old id — and creates a fresh session
126
+ * with Talon's standard permission ruleset (see
127
+ * {@link buildPermissionRuleset}). Any other failure is retried a couple
128
+ * of times and then fails the turn with the session left in place: a
129
+ * server that is still starting after an update must not wipe every
130
+ * chat's session mapping.
84
131
  */
85
132
  export async function ensureRemoteSession<TClient extends RemoteAgentClient>(
86
133
  client: TClient,
87
134
  state: RemoteServerState<TClient>,
88
135
  chatId: string,
136
+ retryDelaysMs: readonly number[] = RESUME_RETRY_DELAYS_MS,
89
137
  ): Promise<string> {
90
138
  const session = getSession(chatId);
91
139
 
92
140
  if (session.sessionId) {
93
- try {
94
- await client.session.get({ sessionID: session.sessionId });
95
- return session.sessionId;
96
- } catch {
97
- logWarn(
98
- "agent",
99
- `[${chatId}] Session ${session.sessionId} expired, creating new`,
100
- );
101
- resetSession(chatId);
141
+ const sessionId = session.sessionId;
142
+ for (let attempt = 0; ; attempt++) {
143
+ try {
144
+ await client.session.get({ sessionID: sessionId });
145
+ return sessionId;
146
+ } catch (err) {
147
+ if (isRemoteSessionNotFound(err)) {
148
+ logWarn(
149
+ "agent",
150
+ `[${chatId}] ${state.label} session ${sessionId} not found on the server, creating new`,
151
+ );
152
+ resetSession(chatId, "remote_session_not_found");
153
+ break;
154
+ }
155
+ const delay = retryDelaysMs[attempt];
156
+ const detail = err instanceof Error ? err.message : String(err);
157
+ if (delay === undefined) {
158
+ throw new TalonError(
159
+ `Could not check ${state.label} session ${sessionId} (${detail}); ` +
160
+ `kept it — try again once the server is up`,
161
+ { reason: "network", retryable: true, cause: err },
162
+ );
163
+ }
164
+ logWarn(
165
+ "agent",
166
+ `[${chatId}] ${state.label} session check failed (${detail}); ` +
167
+ `retrying in ${delay}ms, session kept`,
168
+ );
169
+ await new Promise((resolve) => setTimeout(resolve, delay));
170
+ }
102
171
  }
103
172
  }
104
173
 
@@ -111,7 +111,8 @@ export interface ApplyRetryDecisionResult {
111
111
  *
112
112
  * Side effects (when a retry fires):
113
113
  * - `incrementCounter('errors.<reason>')` exactly once per call.
114
- * - `resetSession(chatId)` before recursion.
114
+ * - `resetSession(chatId, reason)` before recursion (the old id is
115
+ * archived under that reason).
115
116
  * - For `fallback_model`: the fallback model id is spread into the
116
117
  * recursion's params (`params.model` outranks chat settings).
117
118
  *
@@ -156,7 +157,7 @@ export async function applyRetryDecision(
156
157
  "agent",
157
158
  `[${chatId}] ${prefix}${decision.reason}, resetting ${resetNoun} and retrying`,
158
159
  );
159
- resetSession(chatId);
160
+ resetSession(chatId, decision.reason);
160
161
  return { retry: await recurseWithRetried(params), classified };
161
162
  }
162
163
 
@@ -165,7 +166,7 @@ export async function applyRetryDecision(
165
166
  "agent",
166
167
  `[${chatId}] ${classified.reason}, falling back to ${decision.fallbackModelId}`,
167
168
  );
168
- resetSession(chatId);
169
+ resetSession(chatId, `fallback_model:${decision.fallbackModelId}`);
169
170
  return {
170
171
  retry: await recurseWithRetried({
171
172
  ...params,
@@ -270,7 +271,7 @@ export async function* applyRetryDecisionStream(
270
271
  "agent",
271
272
  `[${chatId}] ${prefix}${decision.reason}, resetting ${resetNoun} and retrying`,
272
273
  );
273
- resetSession(chatId);
274
+ resetSession(chatId, decision.reason);
274
275
  yield* buildRetryStream();
275
276
  return { retried: true, classified };
276
277
  }
@@ -280,7 +281,7 @@ export async function* applyRetryDecisionStream(
280
281
  "agent",
281
282
  `[${chatId}] ${classified.reason}, falling back to ${decision.fallbackModelId}`,
282
283
  );
283
- resetSession(chatId);
284
+ resetSession(chatId, `fallback_model:${decision.fallbackModelId}`);
284
285
  yield* buildRetryStream(decision.fallbackModelId);
285
286
  return { retried: true, classified };
286
287
  }
@@ -46,6 +46,10 @@ import {
46
46
  import { resolveBackupSettings } from "../../core/backup/plan.js";
47
47
  import { buildSnapshot } from "../../core/backup/snapshot.js";
48
48
  import { pruneLocal, reconcileIndex } from "../../core/backup/store.js";
49
+ import {
50
+ describeRetention,
51
+ localRetention,
52
+ } from "../../core/backup/retention/policy.js";
49
53
  import { generatePassphraseFile } from "../../core/backup/passphrase.js";
50
54
  import { dirs } from "../../util/paths.js";
51
55
  import { fetchGateway } from "../daemon-api.js";
@@ -170,7 +174,7 @@ async function backupNow(flags: Flags): Promise<void> {
170
174
  pinned: pin,
171
175
  settings,
172
176
  });
173
- await pruneLocal(settings.keepLocal);
177
+ await pruneLocal(localRetention(settings));
174
178
  console.log(
175
179
  ` ${pc.green("●")} ${manifest.id} — ${manifest.parts.length} part(s), ` +
176
180
  `${formatBytes(manifest.sizeBytes)}\n`,
@@ -246,10 +250,10 @@ async function backupPin(id: string, pinned: boolean): Promise<void> {
246
250
 
247
251
  async function backupPrune(): Promise<void> {
248
252
  const settings = resolveBackupSettings(loadConfig().backup);
249
- const removed = await pruneLocal(settings.keepLocal);
253
+ const removed = await pruneLocal(localRetention(settings));
250
254
  console.log(
251
255
  removed.length === 0
252
- ? ` ${pc.dim(`Nothing to prune (keepLocal=${settings.keepLocal}).`)}\n`
256
+ ? ` ${pc.dim(`Nothing to prune (${describeRetention(localRetention(settings))}).`)}\n`
253
257
  : ` ${pc.green("●")} Pruned ${removed.length}: ${removed.join(", ")}\n`,
254
258
  );
255
259
  }
@@ -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(),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Read-back verification of a freshly written snapshot.
3
+ *
4
+ * A digest taken off the bytes on their way to disk says what was sent,
5
+ * not what landed. Before a snapshot is allowed to count as good, every
6
+ * part is read back from disk and re-hashed, and an encrypted part is
7
+ * decrypted end to end (and discarded) so a broken encryptor cannot
8
+ * produce a snapshot nobody can open. The result is `verifiedAt` in the
9
+ * manifest, and retention never prunes the newest snapshot that has one
10
+ * (see retention/policy.ts).
11
+ *
12
+ * Runs before the manifest is signed, so `verifiedAt` is covered by the
13
+ * MAC like every other field written at build time.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { TalonError } from "../../errors.js";
18
+ import { isEncryptedFile, verifyDecryptable } from "./crypt.js";
19
+ import { sha256File } from "./digest.js";
20
+ import type { SnapshotPart } from "../types.js";
21
+
22
+ /**
23
+ * Throws a TalonError naming the first part that does not read back.
24
+ * Returns the time verification finished.
25
+ */
26
+ export async function verifyWrittenParts(
27
+ dir: string,
28
+ parts: readonly SnapshotPart[],
29
+ passphrase: string | null,
30
+ now: () => number = Date.now,
31
+ ): Promise<number> {
32
+ for (const part of parts) {
33
+ const path = join(dir, part.name);
34
+ const actual = await sha256File(path);
35
+ if (actual !== part.sha256) {
36
+ throw new TalonError(
37
+ `Part ${part.name} does not read back (sha256 mismatch)`,
38
+ { reason: "unknown" },
39
+ );
40
+ }
41
+ if (passphrase && (await isEncryptedFile(path))) {
42
+ try {
43
+ await verifyDecryptable(path, passphrase);
44
+ } catch (err) {
45
+ throw new TalonError(
46
+ `Part ${part.name} cannot be decrypted after writing: ${err instanceof Error ? err.message : String(err)}`,
47
+ { reason: "unknown", cause: err },
48
+ );
49
+ }
50
+ }
51
+ }
52
+ return now();
53
+ }
@@ -51,6 +51,9 @@ export const DEFAULT_BACKUP_SETTINGS = {
51
51
  intervalHours: 6,
52
52
  keepLocal: 12,
53
53
  keepRemote: 30,
54
+ keepDaily: 7,
55
+ keepWeekly: 4,
56
+ keepCheckpoints: 10,
54
57
  includePalace: true,
55
58
  loginSessions: "local",
56
59
  includeSessions: true,
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Snapshot retention — which snapshots a prune pass may delete.
3
+ *
4
+ * Keeping only the newest N is not a safety net: if something silently
5
+ * wipes memory, the next N scheduled snapshots faithfully back up the
6
+ * damage and every good copy ages out within N × intervalHours (three
7
+ * days at the defaults). So retention is tiered, the way restic and
8
+ * borg do it:
9
+ *
10
+ * - `keepLast` the newest N scheduled snapshots (`keepLocal` /
11
+ * `keepRemote` in config — their old meaning).
12
+ * - `keepDaily` the newest snapshot of each of the last D days that
13
+ * have one.
14
+ * - `keepWeekly` the newest snapshot of each of the last W ISO weeks
15
+ * that have one.
16
+ *
17
+ * Days and weeks are counted over snapshots that exist, not over the
18
+ * calendar, so a machine that was off for a month does not lose its
19
+ * history to the clock.
20
+ *
21
+ * Around the tiers sit three guard rails:
22
+ *
23
+ * - Pinned snapshots are kept and count against nothing.
24
+ * - Checkpoints (manual, pre-update, pre-upgrade, pre-restore) have
25
+ * their own cap, `keepCheckpoints`, and never displace a scheduled
26
+ * snapshot — nor are they displaced by one.
27
+ * - The newest snapshot that passed verification is always kept, so a
28
+ * run of corrupt snapshots can never prune the last known-good one.
29
+ *
30
+ * Entries whose `createdAt` is not a real timestamp (an unreadable or
31
+ * partial remote manifest) are never pruned: without an age there is no
32
+ * honest way to rank them, and deleting what you cannot read is how a
33
+ * retention pass becomes data loss. They are reported as `skipped` so
34
+ * the caller can warn.
35
+ *
36
+ * Pure: no clock, no filesystem.
37
+ */
38
+
39
+ /** The tiers one prune pass applies. */
40
+ export type RetentionPolicy = {
41
+ /** Newest N scheduled snapshots. */
42
+ keepLast: number;
43
+ /** Newest snapshot per day, for the last N days that have one. 0 = off. */
44
+ keepDaily: number;
45
+ /** Newest snapshot per ISO week, for the last N weeks that have one. 0 = off. */
46
+ keepWeekly: number;
47
+ /** Newest N unpinned checkpoints, counted apart from scheduled snapshots. */
48
+ keepCheckpoints: number;
49
+ };
50
+
51
+ /** What retention needs to know about one snapshot. */
52
+ export type RetentionEntry = {
53
+ id: string;
54
+ createdAt: number;
55
+ pinned: boolean;
56
+ /** "backup" | "checkpoint"; anything else is treated as "backup". */
57
+ kind?: string;
58
+ /** Epoch ms of a successful post-write verification, if there was one. */
59
+ verifiedAt?: number;
60
+ };
61
+
62
+ /** Why a snapshot survived, for logs and tests. */
63
+ type KeepReason =
64
+ "pinned" | "last" | "daily" | "weekly" | "checkpoint" | "verified";
65
+
66
+ export type RetentionPlan<T> = {
67
+ /** Snapshots to delete, newest first. */
68
+ prune: T[];
69
+ /** id → every reason it was kept. */
70
+ keep: Map<string, KeepReason[]>;
71
+ /** Entries without a usable createdAt — kept, and worth a warning. */
72
+ skipped: T[];
73
+ };
74
+
75
+ /** Settings shape the policy is derived from (a subset of BackupSettings). */
76
+ type RetentionSettings = {
77
+ keepLocal: number;
78
+ keepRemote: number;
79
+ keepDaily: number;
80
+ keepWeekly: number;
81
+ keepCheckpoints: number;
82
+ };
83
+
84
+ export function localRetention(settings: RetentionSettings): RetentionPolicy {
85
+ return {
86
+ keepLast: settings.keepLocal,
87
+ keepDaily: settings.keepDaily,
88
+ keepWeekly: settings.keepWeekly,
89
+ keepCheckpoints: settings.keepCheckpoints,
90
+ };
91
+ }
92
+
93
+ export function remoteRetention(settings: RetentionSettings): RetentionPolicy {
94
+ return { ...localRetention(settings), keepLast: settings.keepRemote };
95
+ }
96
+
97
+ /** One-line description for logs: `last=12 daily=7 weekly=8 checkpoints=10`. */
98
+ export function describeRetention(policy: RetentionPolicy): string {
99
+ return (
100
+ `last=${policy.keepLast} daily=${policy.keepDaily} ` +
101
+ `weekly=${policy.keepWeekly} checkpoints=${policy.keepCheckpoints}`
102
+ );
103
+ }
104
+
105
+ /** A createdAt a snapshot can be ranked by. */
106
+ function hasUsableTimestamp(entry: { createdAt: unknown }): boolean {
107
+ return (
108
+ typeof entry.createdAt === "number" &&
109
+ Number.isFinite(entry.createdAt) &&
110
+ entry.createdAt > 0
111
+ );
112
+ }
113
+
114
+ /** `2026-09-30` in UTC. */
115
+ function dayKey(at: number): string {
116
+ return new Date(at).toISOString().slice(0, 10);
117
+ }
118
+
119
+ /** ISO-8601 week, UTC: `2026-W40`. */
120
+ function weekKey(at: number): string {
121
+ const date = new Date(at);
122
+ const day = date.getUTCDay() || 7; // Mon=1 … Sun=7
123
+ // The Thursday of this week decides which year the week belongs to.
124
+ const thursday = Date.UTC(
125
+ date.getUTCFullYear(),
126
+ date.getUTCMonth(),
127
+ date.getUTCDate() + 4 - day,
128
+ );
129
+ const year = new Date(thursday).getUTCFullYear();
130
+ const week = Math.ceil(
131
+ ((thursday - Date.UTC(year, 0, 1)) / 86_400_000 + 1) / 7,
132
+ );
133
+ return `${year}-W${String(week).padStart(2, "0")}`;
134
+ }
135
+
136
+ /** Keep the newest entry of each of the first `limit` distinct buckets. */
137
+ function keepPerBucket<T extends RetentionEntry>(
138
+ ordered: readonly T[],
139
+ limit: number,
140
+ bucketOf: (at: number) => string,
141
+ reason: KeepReason,
142
+ mark: (entry: T, reason: KeepReason) => void,
143
+ ): void {
144
+ if (limit <= 0) return;
145
+ const seen = new Set<string>();
146
+ for (const entry of ordered) {
147
+ const bucket = bucketOf(entry.createdAt);
148
+ if (seen.has(bucket)) continue;
149
+ seen.add(bucket);
150
+ mark(entry, reason);
151
+ if (seen.size >= limit) return;
152
+ }
153
+ }
154
+
155
+ /** Decide what survives. See the module comment for the rules. */
156
+ export function planRetention<T extends RetentionEntry>(
157
+ snapshots: readonly T[],
158
+ policy: RetentionPolicy,
159
+ ): RetentionPlan<T> {
160
+ const keep = new Map<string, KeepReason[]>();
161
+ const mark = (entry: T, reason: KeepReason): void => {
162
+ const reasons = keep.get(entry.id);
163
+ if (reasons) reasons.push(reason);
164
+ else keep.set(entry.id, [reason]);
165
+ };
166
+
167
+ const skipped = snapshots.filter((entry) => !hasUsableTimestamp(entry));
168
+ const ranked = snapshots
169
+ .filter(hasUsableTimestamp)
170
+ .sort((a, b) => b.createdAt - a.createdAt);
171
+
172
+ for (const entry of ranked) if (entry.pinned) mark(entry, "pinned");
173
+
174
+ const unpinned = ranked.filter((entry) => !entry.pinned);
175
+ const checkpoints = unpinned.filter((entry) => entry.kind === "checkpoint");
176
+ const scheduled = unpinned.filter((entry) => entry.kind !== "checkpoint");
177
+
178
+ for (const entry of checkpoints.slice(0, Math.max(0, policy.keepCheckpoints)))
179
+ mark(entry, "checkpoint");
180
+ for (const entry of scheduled.slice(0, Math.max(0, policy.keepLast)))
181
+ mark(entry, "last");
182
+ keepPerBucket(scheduled, policy.keepDaily, dayKey, "daily", mark);
183
+ keepPerBucket(scheduled, policy.keepWeekly, weekKey, "weekly", mark);
184
+
185
+ const lastVerified = ranked.find(
186
+ (entry) => typeof entry.verifiedAt === "number" && entry.verifiedAt > 0,
187
+ );
188
+ if (lastVerified) mark(lastVerified, "verified");
189
+
190
+ return {
191
+ prune: ranked.filter((entry) => !keep.has(entry.id)),
192
+ keep,
193
+ skipped,
194
+ };
195
+ }