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.
@@ -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. Only reachable on developer
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(`⏳ Updating from \`${remote}/${branch}\`…`);
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 ?? "?"}\`. ♻️ Restarting…`,
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. Only wired
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>. ♻️ Restarting…`,
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. Idempotent; called once at boot.
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[]. */
@@ -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 &&