talon-agent 5.26.1 → 5.26.3

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 (43) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app.ts +42 -1
  4. package/src/bootstrap.ts +171 -57
  5. package/src/cli/commands/backup.ts +4 -1
  6. package/src/cli/commands/history.ts +152 -0
  7. package/src/cli/index.ts +6 -0
  8. package/src/core/backup/boot/version-checkpoint.ts +199 -0
  9. package/src/core/backup/index.ts +3 -0
  10. package/src/core/backup/passphrase.ts +55 -2
  11. package/src/core/backup/restore.ts +32 -4
  12. package/src/core/backup/scheduler.ts +125 -13
  13. package/src/core/backup/status.ts +26 -3
  14. package/src/core/update/self-update.ts +144 -47
  15. package/src/frontend/discord/admin.ts +2 -2
  16. package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
  17. package/src/frontend/discord/commands/admin.ts +22 -3
  18. package/src/frontend/discord/commands/definitions.ts +7 -0
  19. package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
  20. package/src/frontend/native/chats/chats.ts +14 -2
  21. package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
  22. package/src/frontend/native/chats/reset.ts +39 -17
  23. package/src/frontend/native/commands/session.ts +2 -2
  24. package/src/frontend/native/surface/models.ts +3 -3
  25. package/src/frontend/native/turn/turn-meta.ts +0 -6
  26. package/src/frontend/presentation/model-commands.ts +8 -11
  27. package/src/frontend/presentation/session-status.ts +12 -13
  28. package/src/frontend/presentation/text-commands.ts +6 -9
  29. package/src/frontend/telegram/admin/sessions.ts +2 -1
  30. package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
  31. package/src/frontend/telegram/commands/admin.ts +21 -3
  32. package/src/frontend/whatsapp/commands.ts +5 -8
  33. package/src/storage/backup/index.ts +1 -1
  34. package/src/storage/chat-settings.ts +13 -0
  35. package/src/storage/db.ts +5 -0
  36. package/src/storage/history.ts +167 -17
  37. package/src/storage/media-index.ts +4 -3
  38. package/src/storage/repositories/history-repo.ts +75 -7
  39. package/src/storage/sessions.ts +60 -1
  40. package/src/storage/sql/history.sql +45 -4
  41. package/src/storage/sql/schema.sql +13 -0
  42. package/src/storage/sql/statements.generated.ts +49 -5
  43. package/src/storage/turn-meta.ts +1 -1
@@ -27,10 +27,19 @@
27
27
  * can never leave the deployment un-updatable, which is what the old
28
28
  * `pull --ff-only` did (it aborts on any dirty/diverged tree). `.gitignore`
29
29
  * is respected (no `-x`), so node_modules, secrets and local config survive.
30
+ *
31
+ * Before anything in the checkout moves, a pinned pre-update checkpoint is
32
+ * taken. If it fails, the update is refused. An update with no way back is
33
+ * when data goes missing, so the operator must say `force` to go on
34
+ * without one. Opting out in config (`backup.checkpointBeforeUpdate:
35
+ * false`) is the only way to skip it without being asked.
30
36
  */
31
37
 
32
38
  import { execFile } from "node:child_process";
33
- import { checkpointBeforeUpdate } from "../backup/index.js";
39
+ import {
40
+ checkpointBeforeUpdate,
41
+ type UpdateCheckpoint,
42
+ } from "../backup/index.js";
34
43
  import { existsSync } from "node:fs";
35
44
  import { dirname, join } from "node:path";
36
45
  import { fileURLToPath } from "node:url";
@@ -60,6 +69,13 @@ export interface UpdateOptions {
60
69
  entry?: { cmd: string; args: readonly string[] };
61
70
  /** Injectable command runner (tests). */
62
71
  runner?: CommandRunner;
72
+ /**
73
+ * Go on even when the pre-update checkpoint fails (`/update force`).
74
+ * The result still says the checkpoint failed.
75
+ */
76
+ force?: boolean;
77
+ /** Injectable pre-update checkpoint (tests). */
78
+ checkpoint?: (from: string, to: string) => Promise<UpdateCheckpoint>;
63
79
  }
64
80
 
65
81
  /** One executed step in an update run. */
@@ -82,6 +98,13 @@ export interface UpdateResult {
82
98
  changed: boolean;
83
99
  /** Human-readable failure reason when `ok` is false. */
84
100
  error?: string;
101
+ /** The pre-update checkpoint, when the update got far enough to take one. */
102
+ checkpoint?: UpdateCheckpoint;
103
+ /**
104
+ * True when the update was refused because the checkpoint failed. Nothing
105
+ * in the checkout was touched; `force` would have gone on.
106
+ */
107
+ checkpointRefused?: boolean;
85
108
  }
86
109
 
87
110
  export type CommandRunner = (
@@ -171,12 +194,7 @@ export async function runSelfUpdate(
171
194
  const branch = opts.branch?.trim() || "main";
172
195
  const run = opts.runner ?? defaultRunner;
173
196
 
174
- const record = async (
175
- label: string,
176
- cmd: string,
177
- args: readonly string[],
178
- timeoutMs: number,
179
- ): Promise<UpdateStep> => {
197
+ const record: Recorder = async (label, cmd, args, timeoutMs) => {
180
198
  const { ok, output } = await run(cmd, args, repoRoot, timeoutMs);
181
199
  const step: UpdateStep = { label, ok, output };
182
200
  steps.push(step);
@@ -210,22 +228,58 @@ export async function runSelfUpdate(
210
228
  );
211
229
  if (!fetch.ok) return fail(`git fetch failed: ${fetch.output}`, before);
212
230
 
231
+ // Where the update is going, read before the tree moves: the checkpoint
232
+ // is labelled with it, and "already up to date" needs no checkpoint.
233
+ const targetRef = `${remote}/${branch}`;
234
+ const target = await record(
235
+ `rev-parse ${targetRef}`,
236
+ "git",
237
+ ["rev-parse", targetRef],
238
+ GIT_TIMEOUT_MS,
239
+ );
240
+ if (!target.ok) {
241
+ return fail(`Failed to read ${targetRef}: ${target.output}`, before);
242
+ }
243
+ const upcoming = shortSha(target.output);
244
+
245
+ // The safety net goes up before anything is destroyed: the reset and
246
+ // clean below discard local state, and the new code may migrate data.
247
+ let checkpoint: UpdateCheckpoint | undefined;
248
+ if (upcoming !== before) {
249
+ checkpoint = await (opts.checkpoint ?? checkpointBeforeUpdate)(
250
+ before,
251
+ upcoming,
252
+ );
253
+ steps.push({
254
+ label: "pre-update checkpoint",
255
+ ok: checkpoint.status !== "failed",
256
+ output: describeCheckpoint(checkpoint),
257
+ });
258
+ if (checkpoint.status === "failed" && !opts.force) {
259
+ return {
260
+ ...fail(refusal(checkpoint.error), before),
261
+ checkpoint,
262
+ checkpointRefused: true,
263
+ };
264
+ }
265
+ }
266
+
213
267
  // Force the checkout to exactly match the freshly-fetched remote
214
268
  // branch, discarding ANY local edits or diverged commits. The old
215
269
  // `pull --ff-only` aborted here whenever the tree was dirty, leaving the
216
270
  // deployment stuck; a bot host is meant to mirror the remote, so resetting
217
271
  // to it is both correct and reliable.
218
272
  const reset = await record(
219
- `reset --hard ${remote}/${branch}`,
273
+ `reset --hard ${targetRef}`,
220
274
  "git",
221
- ["reset", "--hard", `${remote}/${branch}`],
275
+ ["reset", "--hard", targetRef],
222
276
  GIT_TIMEOUT_MS,
223
277
  );
224
278
  if (!reset.ok) {
225
- return fail(
226
- `git reset --hard ${remote}/${branch} failed: ${reset.output}`,
227
- before,
228
- );
279
+ return {
280
+ ...fail(`git reset --hard ${targetRef} failed: ${reset.output}`, before),
281
+ checkpoint,
282
+ };
229
283
  }
230
284
 
231
285
  // Drop untracked files too so the tree is pristine and a future update
@@ -246,28 +300,62 @@ export async function runSelfUpdate(
246
300
  // Nothing moved — skip the expensive install/setup and tell the
247
301
  // caller no restart is needed.
248
302
  if (!changed) {
249
- return { ok: true, repoRoot, steps, before, after, changed: false };
303
+ return {
304
+ ok: true,
305
+ repoRoot,
306
+ steps,
307
+ before,
308
+ after,
309
+ changed: false,
310
+ checkpoint,
311
+ };
250
312
  }
251
313
 
252
- await checkpointBeforeUpdate(before, after);
314
+ const error = await installAndVerify(record, opts, before, after);
315
+ return {
316
+ ok: !error,
317
+ repoRoot,
318
+ steps,
319
+ before,
320
+ after,
321
+ changed: true,
322
+ checkpoint,
323
+ ...(error ? { error } : {}),
324
+ };
325
+ }
326
+
327
+ type Recorder = (
328
+ label: string,
329
+ cmd: string,
330
+ args: readonly string[],
331
+ timeoutMs: number,
332
+ ) => Promise<UpdateStep>;
253
333
 
334
+ function refusal(error: string): string {
335
+ return (
336
+ `the pre-update checkpoint failed (${error}). ` +
337
+ `Nothing was changed. Fix the backup problem, or run the update ` +
338
+ `with "force" to go on without a checkpoint.`
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Reinstall, run setup, and prove the new tree imports. Returns the
344
+ * failure reason, or null when the tree is ready to restart into.
345
+ */
346
+ async function installAndVerify(
347
+ record: Recorder,
348
+ opts: UpdateOptions,
349
+ before: string,
350
+ after: string,
351
+ ): Promise<string | null> {
254
352
  const install = await record(
255
353
  "npm install",
256
354
  "npm",
257
355
  ["install"],
258
356
  INSTALL_TIMEOUT_MS,
259
357
  );
260
- if (!install.ok) {
261
- return {
262
- ok: false,
263
- repoRoot,
264
- steps,
265
- before,
266
- after,
267
- changed,
268
- error: `npm install failed: ${install.output}`,
269
- };
270
- }
358
+ if (!install.ok) return `npm install failed: ${install.output}`;
271
359
 
272
360
  for (const cmd of opts.setup ?? []) {
273
361
  const trimmed = cmd.trim();
@@ -279,15 +367,7 @@ export async function runSelfUpdate(
279
367
  SETUP_TIMEOUT_MS,
280
368
  );
281
369
  if (!setup.ok) {
282
- return {
283
- ok: false,
284
- repoRoot,
285
- steps,
286
- before,
287
- after,
288
- changed,
289
- error: `setup command failed (${trimmed}): ${setup.output}`,
290
- };
370
+ return `setup command failed (${trimmed}): ${setup.output}`;
291
371
  }
292
372
  }
293
373
 
@@ -299,18 +379,35 @@ export async function runSelfUpdate(
299
379
  VERIFY_TIMEOUT_MS,
300
380
  );
301
381
  if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
302
- return {
303
- ok: false,
304
- repoRoot,
305
- steps,
306
- before,
307
- after,
308
- changed,
309
- error:
310
- `the updated tree does not import — not restarting into it. ` +
311
- `Still running ${before}; the checkout is at ${after}.`,
312
- };
382
+ return (
383
+ `the updated tree does not import — not restarting into it. ` +
384
+ `Still running ${before}; the checkout is at ${after}.`
385
+ );
386
+ }
387
+ return null;
388
+ }
389
+
390
+ /** One line for the step log and the chat reply. */
391
+ export function describeCheckpoint(checkpoint: UpdateCheckpoint): string {
392
+ switch (checkpoint.status) {
393
+ case "taken":
394
+ return `checkpoint ${checkpoint.id} taken`;
395
+ case "disabled":
396
+ return "no checkpoint (backup.checkpointBeforeUpdate is off)";
397
+ case "failed":
398
+ return `checkpoint failed: ${checkpoint.error}`;
313
399
  }
400
+ }
314
401
 
315
- return { ok: true, repoRoot, steps, before, after, changed: true };
402
+ /**
403
+ * Whether the arguments to `/update` ask to go on without a checkpoint.
404
+ * `force` and `--force` both work, so the chat command and a CLI habit
405
+ * read the same.
406
+ */
407
+ export function wantsForce(args: string | undefined | null): boolean {
408
+ return (args ?? "")
409
+ .trim()
410
+ .toLowerCase()
411
+ .split(/\s+/)
412
+ .some((token) => token === "force" || token === "--force");
316
413
  }
@@ -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
  }
@@ -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
  : []),
@@ -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
- }