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.
- package/README.md +1 -0
- 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/cli/commands/history.ts +152 -0
- package/src/cli/index.ts +6 -0
- 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/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
- package/src/frontend/discord/commands/admin.ts +22 -3
- package/src/frontend/discord/commands/definitions.ts +7 -0
- 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/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/telegram/commands/admin.ts +21 -3
- package/src/frontend/whatsapp/commands.ts +5 -8
- 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/history.ts +167 -17
- package/src/storage/media-index.ts +4 -3
- package/src/storage/repositories/history-repo.ts +75 -7
- package/src/storage/sessions.ts +60 -1
- 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
|
@@ -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 {
|
|
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 ${
|
|
273
|
+
`reset --hard ${targetRef}`,
|
|
220
274
|
"git",
|
|
221
|
-
["reset", "--hard",
|
|
275
|
+
["reset", "--hard", targetRef],
|
|
222
276
|
GIT_TIMEOUT_MS,
|
|
223
277
|
);
|
|
224
278
|
if (!reset.ok) {
|
|
225
|
-
return
|
|
226
|
-
`git reset --hard ${
|
|
227
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
: []),
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
3
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
185
|
+
handOffChatBackend(runtime, chatId);
|
|
186
186
|
emitSystem(
|
|
187
187
|
runtime,
|
|
188
188
|
entry,
|
|
189
|
-
`Switched to ${target} —
|
|
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
|
-
}
|