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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/remote-server/sessions.ts +82 -13
- package/src/backend/runtime/turn/handle-retry.ts +6 -5
- package/src/cli/commands/backup.ts +7 -3
- package/src/cli/commands/history.ts +152 -0
- package/src/cli/index.ts +6 -0
- package/src/core/backup/archive/verify.ts +53 -0
- package/src/core/backup/plan.ts +3 -0
- package/src/core/backup/retention/policy.ts +195 -0
- package/src/core/backup/scheduler.ts +10 -4
- package/src/core/backup/snapshot.ts +3 -0
- package/src/core/backup/status.ts +7 -0
- package/src/core/backup/store.ts +20 -25
- package/src/core/backup/types.ts +14 -0
- package/src/core/backup/upload.ts +37 -20
- package/src/core/config/index.ts +26 -1
- package/src/core/errors.ts +13 -2
- package/src/frontend/discord/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
- 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/backup-panel.ts +7 -1
- 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/whatsapp/commands.ts +5 -8
- package/src/storage/history.ts +167 -17
- package/src/storage/repositories/history-repo.ts +75 -7
- 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
|
@@ -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.
|
|
81
|
-
* resets
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
253
|
+
const removed = await pruneLocal(localRetention(settings));
|
|
250
254
|
console.log(
|
|
251
255
|
removed.length === 0
|
|
252
|
-
? ` ${pc.dim(`Nothing to prune (
|
|
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
|
+
}
|
package/src/core/backup/plan.ts
CHANGED
|
@@ -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
|
+
}
|