talon-agent 5.26.1 → 5.26.2
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/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/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/commands/admin.ts +22 -3
- package/src/frontend/discord/commands/definitions.ts +7 -0
- package/src/frontend/telegram/commands/admin.ts +21 -3
- 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/media-index.ts +4 -3
- package/src/storage/sessions.ts +60 -1
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
getRepoRoot,
|
|
34
34
|
runSelfUpdate,
|
|
35
|
+
describeCheckpoint,
|
|
35
36
|
} from "../../../core/update/self-update.js";
|
|
36
37
|
import { reply } from "./interaction.js";
|
|
37
38
|
|
|
@@ -153,7 +154,8 @@ export async function handleDream(
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/**
|
|
156
|
-
* /update — pull, reinstall, run setup, restart.
|
|
157
|
+
* /update [force] — pull, reinstall, run setup, restart. Refused when the
|
|
158
|
+
* pre-update checkpoint fails unless `force` is set. Only reachable on developer
|
|
157
159
|
* builds running from a git checkout; the command is not registered at all
|
|
158
160
|
* otherwise (see buildCommandDefinitions).
|
|
159
161
|
*/
|
|
@@ -172,8 +174,13 @@ export async function handleUpdate(
|
|
|
172
174
|
}
|
|
173
175
|
const remote = config.update?.remote ?? "origin";
|
|
174
176
|
const branch = config.update?.branch ?? "main";
|
|
177
|
+
const force = i.options.getBoolean("force") ?? false;
|
|
175
178
|
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
176
|
-
await i.editReply(
|
|
179
|
+
await i.editReply(
|
|
180
|
+
`⏳ Updating from \`${remote}/${branch}\`` +
|
|
181
|
+
(force ? " (forced: a failed checkpoint will not stop it)" : "") +
|
|
182
|
+
"…",
|
|
183
|
+
);
|
|
177
184
|
const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
|
|
178
185
|
|
|
179
186
|
// Fire-and-forget so the gateway keeps processing other interactions.
|
|
@@ -182,12 +189,24 @@ export async function handleUpdate(
|
|
|
182
189
|
branch,
|
|
183
190
|
setup: config.update?.setup,
|
|
184
191
|
repoRoot,
|
|
192
|
+
force,
|
|
185
193
|
})
|
|
186
194
|
.then(async (res) => {
|
|
195
|
+
if (res.checkpointRefused) {
|
|
196
|
+
await edit(
|
|
197
|
+
`🛑 Update refused: ${res.error ?? "the pre-update checkpoint failed"}\n\n` +
|
|
198
|
+
"Run `/update force:true` to update without a checkpoint.",
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const note = res.checkpoint
|
|
203
|
+
? `\n${describeCheckpoint(res.checkpoint)}`
|
|
204
|
+
: "";
|
|
187
205
|
if (!res.ok) {
|
|
188
206
|
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
189
207
|
await edit(
|
|
190
208
|
`⚠️ Update failed: ${res.error ?? "unknown error"}` +
|
|
209
|
+
note +
|
|
191
210
|
(tail
|
|
192
211
|
? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
|
|
193
212
|
: ""),
|
|
@@ -201,7 +220,7 @@ export async function handleUpdate(
|
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
222
|
await edit(
|
|
204
|
-
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}
|
|
223
|
+
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`.${note}\n♻️ Restarting…`,
|
|
205
224
|
);
|
|
206
225
|
// The successor documents any provisioning changes (plugin runtime
|
|
207
226
|
// upgrades, migrations) back to this channel once it's up.
|
|
@@ -156,6 +156,13 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
|
156
156
|
new SlashCommandBuilder()
|
|
157
157
|
.setName("update")
|
|
158
158
|
.setDescription("Pull latest, reinstall, restart (admin)")
|
|
159
|
+
.addBooleanOption((o) =>
|
|
160
|
+
o
|
|
161
|
+
.setName("force")
|
|
162
|
+
.setDescription(
|
|
163
|
+
"Update even if the pre-update checkpoint fails",
|
|
164
|
+
),
|
|
165
|
+
)
|
|
159
166
|
.toJSON(),
|
|
160
167
|
]
|
|
161
168
|
: []),
|
|
@@ -11,8 +11,10 @@ import type { TalonConfig } from "../../../core/config/index.js";
|
|
|
11
11
|
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
12
12
|
import { isStaleCommand } from "../polling/stale-command.js";
|
|
13
13
|
import {
|
|
14
|
+
describeCheckpoint,
|
|
14
15
|
getRepoRoot,
|
|
15
16
|
runSelfUpdate,
|
|
17
|
+
wantsForce,
|
|
16
18
|
} from "../../../core/update/self-update.js";
|
|
17
19
|
import { forceDream } from "../../../core/background/dream/index.js";
|
|
18
20
|
import { escapeHtml } from "../formatting.js";
|
|
@@ -169,7 +171,8 @@ function registerRestartCommand(bot: Bot): void {
|
|
|
169
171
|
});
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
// /update — pull latest, reinstall, run setup, restart.
|
|
174
|
+
// /update [force] — pull latest, reinstall, run setup, restart. Refused
|
|
175
|
+
// when the pre-update checkpoint fails unless "force" is given. Only wired
|
|
173
176
|
// up for developer builds running from a git checkout; packaged
|
|
174
177
|
// binaries have no source tree (getRepoRoot() === null) so the
|
|
175
178
|
// command stays absent entirely.
|
|
@@ -187,8 +190,11 @@ function registerUpdateCommand(
|
|
|
187
190
|
if (isStaleCommand(ctx.message?.date, "/update")) return;
|
|
188
191
|
const remote = config.update?.remote ?? "origin";
|
|
189
192
|
const branch = config.update?.branch ?? "main";
|
|
193
|
+
const force = wantsForce(typeof ctx.match === "string" ? ctx.match : "");
|
|
190
194
|
const sent = await ctx.reply(
|
|
191
|
-
`⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code
|
|
195
|
+
`⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code>` +
|
|
196
|
+
(force ? " (forced: a failed checkpoint will not stop it)" : "") +
|
|
197
|
+
"…",
|
|
192
198
|
{ parse_mode: "HTML" },
|
|
193
199
|
);
|
|
194
200
|
const edit = (text: string) =>
|
|
@@ -204,12 +210,24 @@ function registerUpdateCommand(
|
|
|
204
210
|
branch,
|
|
205
211
|
setup: config.update?.setup,
|
|
206
212
|
repoRoot: updateRepoRoot,
|
|
213
|
+
force,
|
|
207
214
|
})
|
|
208
215
|
.then(async (res) => {
|
|
216
|
+
if (res.checkpointRefused) {
|
|
217
|
+
await edit(
|
|
218
|
+
`🛑 Update refused: ${escapeHtml(res.error ?? "the pre-update checkpoint failed")}\n\n` +
|
|
219
|
+
`Send <code>/update force</code> to update without a checkpoint.`,
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const note = res.checkpoint
|
|
224
|
+
? `\n${escapeHtml(describeCheckpoint(res.checkpoint))}`
|
|
225
|
+
: "";
|
|
209
226
|
if (!res.ok) {
|
|
210
227
|
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
211
228
|
await edit(
|
|
212
229
|
`⚠️ Update failed: ${escapeHtml(res.error ?? "unknown error")}` +
|
|
230
|
+
note +
|
|
213
231
|
(tail ? `\n\n<pre>${escapeHtml(tail.slice(-1500))}</pre>` : ""),
|
|
214
232
|
);
|
|
215
233
|
return;
|
|
@@ -221,7 +239,7 @@ function registerUpdateCommand(
|
|
|
221
239
|
return;
|
|
222
240
|
}
|
|
223
241
|
await edit(
|
|
224
|
-
`✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code
|
|
242
|
+
`✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code>.${note}\n♻️ Restarting…`,
|
|
225
243
|
);
|
|
226
244
|
// The successor documents any provisioning changes (plugin
|
|
227
245
|
// runtime upgrades, migrations) back to this chat once it's up.
|
|
@@ -22,7 +22,7 @@ import * as repo from "./repo.js";
|
|
|
22
22
|
* handle stays inside storage/ (`db-handle-stays-in-storage`), and a
|
|
23
23
|
* snapshot of the database is a storage concern with a storage API.
|
|
24
24
|
*/
|
|
25
|
-
export { snapshotDatabase, snapshotSqliteFile } from "../db.js";
|
|
25
|
+
export { databasePath, snapshotDatabase, snapshotSqliteFile } from "../db.js";
|
|
26
26
|
|
|
27
27
|
export type { BackupRecord, BackupRemoteRecord } from "./repo.js";
|
|
28
28
|
import type { BackupRecord, BackupRemoteRecord } from "./repo.js";
|
|
@@ -313,6 +313,19 @@ export function clearAllChatModels(chatId: string): void {
|
|
|
313
313
|
persist(chatId);
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Drop only the legacy single-slot `model` field, keeping every
|
|
318
|
+
* per-backend pick. The boot reconcile uses it: a stale legacy value must
|
|
319
|
+
* go, but the picks the chat made on other backends are still good.
|
|
320
|
+
*/
|
|
321
|
+
export function clearLegacyChatModel(chatId: string): void {
|
|
322
|
+
const entry = cache.get(chatId);
|
|
323
|
+
if (!entry || entry.model === undefined) return;
|
|
324
|
+
delete entry.model;
|
|
325
|
+
cleanupEmpty(chatId);
|
|
326
|
+
persist(chatId);
|
|
327
|
+
}
|
|
328
|
+
|
|
316
329
|
/**
|
|
317
330
|
* @deprecated Prefer `setChatModelForBackend(chatId, backendId, model)`
|
|
318
331
|
* which is explicit about which backend's slot is being mutated.
|
package/src/storage/db.ts
CHANGED
|
@@ -145,6 +145,11 @@ function defaultPath(): string {
|
|
|
145
145
|
return process.env.TALON_DB_PATH || files.database;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Where the process-wide database lives (or will, once opened). */
|
|
149
|
+
export function databasePath(): string {
|
|
150
|
+
return defaultPath();
|
|
151
|
+
}
|
|
152
|
+
|
|
148
153
|
/**
|
|
149
154
|
* Open (or return) the process-wide database. The first call wins the
|
|
150
155
|
* path; tests pass an explicit tmp path and call closeDatabase() in
|
|
@@ -48,15 +48,16 @@ function isMediaEntry(value: unknown): value is MediaEntry {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Run the one-time import of the legacy JSON store, then sweep
|
|
51
|
-
* expired entries
|
|
51
|
+
* expired entries (unless `purgeExpired: false` — a boot with no safety
|
|
52
|
+
* checkpoint deletes nothing). Idempotent; called once at boot.
|
|
52
53
|
*/
|
|
53
|
-
export function loadMediaIndex(): void {
|
|
54
|
+
export function loadMediaIndex(options: { purgeExpired?: boolean } = {}): void {
|
|
54
55
|
try {
|
|
55
56
|
importLegacyMediaIndex();
|
|
56
57
|
} catch (err) {
|
|
57
58
|
logError("media", "Media index load failed", err);
|
|
58
59
|
}
|
|
59
|
-
purgeExpired();
|
|
60
|
+
if (options.purgeExpired !== false) purgeExpired();
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/** Legacy shape: bare MediaEntry[]. */
|
package/src/storage/sessions.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { recordError } from "../util/watchdog.js";
|
|
|
23
23
|
import { files } from "../util/paths.js";
|
|
24
24
|
import { importLegacyJson } from "./legacy-import.js";
|
|
25
25
|
import { dbErrorFields } from "./db.js";
|
|
26
|
+
import { kvGet, kvSet } from "./kv.js";
|
|
26
27
|
import * as repo from "./repositories/sessions-repo.js";
|
|
27
28
|
|
|
28
29
|
export type {
|
|
@@ -534,18 +535,76 @@ function removeSessionRow(chatId: string): void {
|
|
|
534
535
|
}
|
|
535
536
|
}
|
|
536
537
|
|
|
538
|
+
// ── Replaced-session archive ───────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/** A backend session id a reset replaced — kept so it can be re-linked. */
|
|
541
|
+
export type ArchivedSession = {
|
|
542
|
+
chatId: string;
|
|
543
|
+
sessionId: string;
|
|
544
|
+
/** Why the session was replaced ("reset", "backend-unavailable", …). */
|
|
545
|
+
reason: string;
|
|
546
|
+
/** When it was replaced (ms epoch). */
|
|
547
|
+
at: number;
|
|
548
|
+
turns: number;
|
|
549
|
+
lastModel?: string;
|
|
550
|
+
sessionName?: string;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const SESSION_ARCHIVE_KEY = "sessions.archive";
|
|
554
|
+
/** Newest entries kept; the transcripts themselves stay on disk regardless. */
|
|
555
|
+
const SESSION_ARCHIVE_MAX = 500;
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Remember a session id that is about to be dropped. A reset used to
|
|
559
|
+
* forget it outright, leaving the backend transcript on disk with nothing
|
|
560
|
+
* pointing at it; with the id kept, a chat reset by mistake (or by a boot
|
|
561
|
+
* reconcile) can be re-linked to its old conversation. Never throws.
|
|
562
|
+
*/
|
|
563
|
+
function archiveSessionId(entry: ArchivedSession): void {
|
|
564
|
+
try {
|
|
565
|
+
const prior = kvGet<ArchivedSession[]>(SESSION_ARCHIVE_KEY);
|
|
566
|
+
const list = Array.isArray(prior) ? prior : [];
|
|
567
|
+
list.push(entry);
|
|
568
|
+
kvSet(SESSION_ARCHIVE_KEY, list.slice(-SESSION_ARCHIVE_MAX));
|
|
569
|
+
} catch (err) {
|
|
570
|
+
logError("sessions", `Failed to archive session chat=${entry.chatId}`, err);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Archived (replaced) session ids, oldest first; one chat's when given. */
|
|
575
|
+
export function getArchivedSessions(chatId?: string): ArchivedSession[] {
|
|
576
|
+
const list = kvGet<ArchivedSession[]>(SESSION_ARCHIVE_KEY);
|
|
577
|
+
if (!Array.isArray(list)) return [];
|
|
578
|
+
return chatId ? list.filter((e) => e.chatId === chatId) : list;
|
|
579
|
+
}
|
|
580
|
+
|
|
537
581
|
/**
|
|
538
582
|
* Reset the chat's conversation state (backend session id, turns, usage)
|
|
539
583
|
* while carrying its metrics forward. Resets fire on /new, model switches
|
|
540
584
|
* and error recovery — none of which should erase the chat's accounting
|
|
541
585
|
* history (that's what makes per-session metrics survive anything short
|
|
542
586
|
* of deleting the chat). Use deleteSession() to drop the chat entirely.
|
|
587
|
+
*
|
|
588
|
+
* The replaced backend session id is archived (see getArchivedSessions),
|
|
589
|
+
* never just discarded. Talon's own chat history is untouched: clearing
|
|
590
|
+
* it is a separate, explicit call.
|
|
543
591
|
*/
|
|
544
|
-
export function resetSession(chatId: string): void {
|
|
592
|
+
export function resetSession(chatId: string, reason = "reset"): void {
|
|
545
593
|
const session = cache.get(chatId);
|
|
546
594
|
const turns = session?.turns ?? 0;
|
|
547
595
|
const name = session?.sessionName;
|
|
548
596
|
const metrics = session?.metrics;
|
|
597
|
+
if (session?.sessionId) {
|
|
598
|
+
archiveSessionId({
|
|
599
|
+
chatId,
|
|
600
|
+
sessionId: session.sessionId,
|
|
601
|
+
reason,
|
|
602
|
+
at: Date.now(),
|
|
603
|
+
turns,
|
|
604
|
+
...(session.lastModel ? { lastModel: session.lastModel } : {}),
|
|
605
|
+
...(name ? { sessionName: name } : {}),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
549
608
|
removeSessionRow(chatId);
|
|
550
609
|
const hasHistory =
|
|
551
610
|
metrics &&
|