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
|
@@ -34,6 +34,11 @@ import { dirs } from "../../util/paths.js";
|
|
|
34
34
|
import { passphraseProblem } from "./passphrase.js";
|
|
35
35
|
import { buildSnapshot } from "./snapshot.js";
|
|
36
36
|
import { listLocalManifests, pruneLocal, reconcileIndex } from "./store.js";
|
|
37
|
+
import {
|
|
38
|
+
describeRetention,
|
|
39
|
+
localRetention,
|
|
40
|
+
remoteRetention,
|
|
41
|
+
} from "./retention/policy.js";
|
|
37
42
|
import { discoverTargets, selectTargets } from "./targets.js";
|
|
38
43
|
import { pruneRemote, uploadSnapshot } from "./upload.js";
|
|
39
44
|
import type { BackupSettings, Manifest, SnapshotKind } from "./types.js";
|
|
@@ -162,7 +167,7 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
|
|
|
162
167
|
parts: manifest.parts.length,
|
|
163
168
|
durationMs: Date.now() - started,
|
|
164
169
|
});
|
|
165
|
-
await _backupDeps.pruneLocal(settings
|
|
170
|
+
await _backupDeps.pruneLocal(localRetention(settings), state.home);
|
|
166
171
|
if (!request.localOnly) {
|
|
167
172
|
const targets = selectTargets(
|
|
168
173
|
await _backupDeps.discover(),
|
|
@@ -170,7 +175,7 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
|
|
|
170
175
|
);
|
|
171
176
|
if (targets.length > 0) {
|
|
172
177
|
await _backupDeps.upload(manifest, targets, state.home);
|
|
173
|
-
await _backupDeps.pruneRemote(targets, settings
|
|
178
|
+
await _backupDeps.pruneRemote(targets, remoteRetention(settings));
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
backoff.succeed();
|
|
@@ -379,8 +384,9 @@ export async function initBackup(options: {
|
|
|
379
384
|
log(
|
|
380
385
|
"backup",
|
|
381
386
|
`Scheduled every ${options.settings.intervalHours}h; first run in ` +
|
|
382
|
-
`${Math.round(delay / 60_000)}m (
|
|
383
|
-
`${options.settings
|
|
387
|
+
`${Math.round(delay / 60_000)}m (retention ` +
|
|
388
|
+
`${describeRetention(localRetention(options.settings))}; ` +
|
|
389
|
+
`${options.settings.keepRemote} newest per remote target)`,
|
|
384
390
|
);
|
|
385
391
|
}
|
|
386
392
|
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
import { signManifest } from "./archive/manifest-auth.js";
|
|
55
55
|
import { TarWriter } from "./archive/tar.js";
|
|
56
56
|
import { createCompressor } from "./archive/zstd.js";
|
|
57
|
+
import { verifyWrittenParts } from "./archive/verify.js";
|
|
57
58
|
import {
|
|
58
59
|
collectTree,
|
|
59
60
|
excludeForRoot,
|
|
@@ -755,6 +756,7 @@ export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
|
|
|
755
756
|
}
|
|
756
757
|
}
|
|
757
758
|
await removeScratch(dir);
|
|
759
|
+
const verifiedAt = await verifyWrittenParts(dir, parts, passphrase);
|
|
758
760
|
|
|
759
761
|
const gitHead = await readGitHead(process.cwd());
|
|
760
762
|
const manifest: Manifest = {
|
|
@@ -776,6 +778,7 @@ export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
|
|
|
776
778
|
...(palaceHash ? { palaceHash } : {}),
|
|
777
779
|
sizeBytes: parts.reduce((sum, part) => sum + part.bytes, 0),
|
|
778
780
|
remote: {},
|
|
781
|
+
verifiedAt,
|
|
779
782
|
};
|
|
780
783
|
if (passphrase) manifest.auth = await signManifest(manifest, passphrase);
|
|
781
784
|
await writeManifest(manifest, home);
|
|
@@ -53,6 +53,10 @@ export type BackupStatus = {
|
|
|
53
53
|
type BackupPolicy = {
|
|
54
54
|
keepLocal: number;
|
|
55
55
|
keepRemote: number;
|
|
56
|
+
/** Absent on a policy built before tiered retention (older daemons). */
|
|
57
|
+
keepDaily?: number;
|
|
58
|
+
keepWeekly?: number;
|
|
59
|
+
keepCheckpoints?: number;
|
|
56
60
|
/**
|
|
57
61
|
* Snapshots are written encrypted: `backup.encryption` is configured or
|
|
58
62
|
* the passphrase comes from the environment (see passphrase.ts). Off
|
|
@@ -69,6 +73,9 @@ function describePolicy(
|
|
|
69
73
|
return {
|
|
70
74
|
keepLocal: settings.keepLocal,
|
|
71
75
|
keepRemote: settings.keepRemote,
|
|
76
|
+
keepDaily: settings.keepDaily,
|
|
77
|
+
keepWeekly: settings.keepWeekly,
|
|
78
|
+
keepCheckpoints: settings.keepCheckpoints,
|
|
72
79
|
encrypted:
|
|
73
80
|
settings.encryption !== undefined || Boolean(env[PASSPHRASE_ENV]?.trim()),
|
|
74
81
|
};
|
package/src/core/backup/store.ts
CHANGED
|
@@ -33,6 +33,11 @@ import {
|
|
|
33
33
|
updateBackupManifest,
|
|
34
34
|
} from "../../storage/backup/index.js";
|
|
35
35
|
import { pathExists } from "./sources/sessions.js";
|
|
36
|
+
import {
|
|
37
|
+
describeRetention,
|
|
38
|
+
planRetention,
|
|
39
|
+
type RetentionPolicy,
|
|
40
|
+
} from "./retention/policy.js";
|
|
36
41
|
import type {
|
|
37
42
|
Manifest,
|
|
38
43
|
RemoteState,
|
|
@@ -236,26 +241,6 @@ export async function setSnapshotPinned(
|
|
|
236
241
|
|
|
237
242
|
// ── Retention ───────────────────────────────────────────────────────────────
|
|
238
243
|
|
|
239
|
-
/**
|
|
240
|
-
* Which snapshots to drop so that at most `keep` unpinned ones remain.
|
|
241
|
-
* Pure, and the order is the one the caller sees: newest first, pinned
|
|
242
|
-
* snapshots kept unconditionally and not counted against the budget —
|
|
243
|
-
* pinning is the user saying "this one outlives the policy".
|
|
244
|
-
*/
|
|
245
|
-
export function selectPrunable<
|
|
246
|
-
T extends { id: string; createdAt: number; pinned: boolean },
|
|
247
|
-
>(snapshots: readonly T[], keep: number): T[] {
|
|
248
|
-
const ordered = [...snapshots].sort((a, b) => b.createdAt - a.createdAt);
|
|
249
|
-
const doomed: T[] = [];
|
|
250
|
-
let kept = 0;
|
|
251
|
-
for (const snapshot of ordered) {
|
|
252
|
-
if (snapshot.pinned) continue;
|
|
253
|
-
kept += 1;
|
|
254
|
-
if (kept > keep) doomed.push(snapshot);
|
|
255
|
-
}
|
|
256
|
-
return doomed;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
244
|
/** Delete a snapshot directory and its index rows. */
|
|
260
245
|
async function removeSnapshot(
|
|
261
246
|
id: string,
|
|
@@ -266,14 +251,24 @@ async function removeSnapshot(
|
|
|
266
251
|
deleteBackup(id);
|
|
267
252
|
}
|
|
268
253
|
|
|
269
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Apply the local retention policy (see retention/policy.ts). Returns the ids
|
|
256
|
+
* removed. A directory whose manifest cannot be read never reaches the
|
|
257
|
+
* listing, so it is never pruned.
|
|
258
|
+
*/
|
|
270
259
|
export async function pruneLocal(
|
|
271
|
-
|
|
260
|
+
policy: RetentionPolicy,
|
|
272
261
|
home: string = dirs.root,
|
|
273
262
|
): Promise<string[]> {
|
|
274
|
-
const
|
|
263
|
+
const plan = planRetention(await listLocalManifests(home), policy);
|
|
264
|
+
for (const manifest of plan.skipped) {
|
|
265
|
+
logWarn(
|
|
266
|
+
"backup",
|
|
267
|
+
`Not pruning ${manifest.id}: its manifest has no usable createdAt`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
275
270
|
const removed: string[] = [];
|
|
276
|
-
for (const manifest of
|
|
271
|
+
for (const manifest of plan.prune) {
|
|
277
272
|
try {
|
|
278
273
|
await removeSnapshot(manifest.id, home);
|
|
279
274
|
removed.push(manifest.id);
|
|
@@ -284,7 +279,7 @@ export async function pruneLocal(
|
|
|
284
279
|
if (removed.length > 0) {
|
|
285
280
|
log(
|
|
286
281
|
"backup",
|
|
287
|
-
`Pruned ${removed.length} local snapshot(s) (
|
|
282
|
+
`Pruned ${removed.length} local snapshot(s) (${describeRetention(policy)})`,
|
|
288
283
|
);
|
|
289
284
|
}
|
|
290
285
|
return removed;
|
package/src/core/backup/types.ts
CHANGED
|
@@ -123,6 +123,14 @@ export type Manifest = {
|
|
|
123
123
|
/** Total bytes of all parts. */
|
|
124
124
|
sizeBytes: number;
|
|
125
125
|
remote: Record<string, RemoteState>;
|
|
126
|
+
/**
|
|
127
|
+
* Epoch ms at which every part was read back and matched its digest
|
|
128
|
+
* (and, when encrypted, decrypted end to end) — see archive/verify.ts. Set
|
|
129
|
+
* before signing, so the MAC covers it. Absent on snapshots written
|
|
130
|
+
* before verification existed. Retention never prunes the newest
|
|
131
|
+
* verified snapshot.
|
|
132
|
+
*/
|
|
133
|
+
verifiedAt?: number;
|
|
126
134
|
/** MAC under the backup passphrase; absent on plaintext/legacy snapshots. */
|
|
127
135
|
auth?: ManifestAuth;
|
|
128
136
|
};
|
|
@@ -146,6 +154,12 @@ export type BackupSettings = {
|
|
|
146
154
|
intervalHours: number;
|
|
147
155
|
keepLocal: number;
|
|
148
156
|
keepRemote: number;
|
|
157
|
+
/** Newest snapshot per day, for this many days (0 = off). */
|
|
158
|
+
keepDaily: number;
|
|
159
|
+
/** Newest snapshot per ISO week, for this many weeks (0 = off). */
|
|
160
|
+
keepWeekly: number;
|
|
161
|
+
/** Unpinned checkpoints kept, apart from the scheduled snapshots. */
|
|
162
|
+
keepCheckpoints: number;
|
|
149
163
|
includePalace: boolean;
|
|
150
164
|
/** WhatsApp auth + userbot session: see {@link LoginSessionsPolicy}. */
|
|
151
165
|
loginSessions: LoginSessionsPolicy;
|
|
@@ -30,11 +30,11 @@ import {
|
|
|
30
30
|
} from "../../storage/backup/index.js";
|
|
31
31
|
import { isEncryptedFile } from "./archive/crypt.js";
|
|
32
32
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} from "./store.js";
|
|
33
|
+
describeRetention,
|
|
34
|
+
planRetention,
|
|
35
|
+
type RetentionPolicy,
|
|
36
|
+
} from "./retention/policy.js";
|
|
37
|
+
import { partPath, reindexSnapshot, writeManifest } from "./store.js";
|
|
38
38
|
import type { BackupTarget } from "./targets.js";
|
|
39
39
|
import type { Manifest, RemoteState, SnapshotPart } from "./types.js";
|
|
40
40
|
|
|
@@ -182,14 +182,19 @@ async function uploadToAll(
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
|
-
* Apply the remote retention policy on each target
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
185
|
+
* Apply the remote retention policy on each target — the same tiers as
|
|
186
|
+
* local (see retention/policy.ts), with `keepRemote` as the newest-N tier.
|
|
187
|
+
*
|
|
188
|
+
* Two refusals keep a retention pass from turning into data loss:
|
|
189
|
+
* - A target that cannot list is skipped: deleting on a partial
|
|
190
|
+
* listing is how that happens.
|
|
191
|
+
* - An entry whose manifest is missing, unreadable, or has no
|
|
192
|
+
* createdAt is never pruned. Without an age it cannot be ranked,
|
|
193
|
+
* and treating it as the oldest would delete it first.
|
|
189
194
|
*/
|
|
190
195
|
export async function pruneRemote(
|
|
191
196
|
targets: readonly BackupTarget[],
|
|
192
|
-
|
|
197
|
+
policy: RetentionPolicy,
|
|
193
198
|
): Promise<void> {
|
|
194
199
|
for (const target of targets) {
|
|
195
200
|
if (!target.ready) continue;
|
|
@@ -203,15 +208,27 @@ export async function pruneRemote(
|
|
|
203
208
|
);
|
|
204
209
|
continue;
|
|
205
210
|
}
|
|
206
|
-
const
|
|
207
|
-
snapshots.map((entry) =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
const plan = planRetention(
|
|
212
|
+
snapshots.map((entry) => {
|
|
213
|
+
const manifest = entry.manifest as Partial<Manifest> | undefined;
|
|
214
|
+
return {
|
|
215
|
+
id: entry.snapshotId,
|
|
216
|
+
createdAt: manifest?.createdAt as number,
|
|
217
|
+
pinned: manifest?.pinned === true,
|
|
218
|
+
kind: manifest?.kind,
|
|
219
|
+
verifiedAt: manifest?.verifiedAt,
|
|
220
|
+
};
|
|
221
|
+
}),
|
|
222
|
+
policy,
|
|
213
223
|
);
|
|
214
|
-
|
|
224
|
+
if (plan.skipped.length > 0) {
|
|
225
|
+
logWarn(
|
|
226
|
+
"backup",
|
|
227
|
+
`Not pruning ${plan.skipped.length} snapshot(s) on ${target.id} whose manifest ` +
|
|
228
|
+
`is unreadable or has no createdAt: ${plan.skipped.map((s) => s.id).join(", ")}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const victim of plan.prune) {
|
|
215
232
|
try {
|
|
216
233
|
await target.remove(victim.id);
|
|
217
234
|
deleteBackupRemote(victim.id, target.id);
|
|
@@ -222,10 +239,10 @@ export async function pruneRemote(
|
|
|
222
239
|
);
|
|
223
240
|
}
|
|
224
241
|
}
|
|
225
|
-
if (
|
|
242
|
+
if (plan.prune.length > 0) {
|
|
226
243
|
log(
|
|
227
244
|
"backup",
|
|
228
|
-
`Pruned ${
|
|
245
|
+
`Pruned ${plan.prune.length} snapshot(s) from ${target.id} (${describeRetention(policy)})`,
|
|
229
246
|
);
|
|
230
247
|
}
|
|
231
248
|
}
|
package/src/core/config/index.ts
CHANGED
|
@@ -679,7 +679,8 @@ const configSchema = z.object({
|
|
|
679
679
|
* Backups & checkpoints (docs/backups.md). Talon's only safety net, so
|
|
680
680
|
* it is on by default: every `intervalHours` it writes a snapshot of
|
|
681
681
|
* the identity, state, database and memory under ~/.talon/backups/,
|
|
682
|
-
* keeps
|
|
682
|
+
* keeps them on a tiered schedule (newest `keepLocal`, one a day for
|
|
683
|
+
* `keepDaily` days, one a week for `keepWeekly` weeks), and uploads to whatever remote targets
|
|
683
684
|
* are registered (`targets: []` keeps everything local).
|
|
684
685
|
*
|
|
685
686
|
* - `workspaceInclude` — the workspace is mostly bulk that can be
|
|
@@ -716,6 +717,30 @@ const configSchema = z.object({
|
|
|
716
717
|
.min(1)
|
|
717
718
|
.max(1000)
|
|
718
719
|
.default(DEFAULT_BACKUP_SETTINGS.keepRemote),
|
|
720
|
+
/**
|
|
721
|
+
* Tiered retention on top of the newest `keepLocal`/`keepRemote`
|
|
722
|
+
* (docs/backups.md#retention): the newest snapshot of each of the
|
|
723
|
+
* last `keepDaily` days and `keepWeekly` weeks, plus up to
|
|
724
|
+
* `keepCheckpoints` unpinned checkpoints counted on their own.
|
|
725
|
+
*/
|
|
726
|
+
keepDaily: z
|
|
727
|
+
.number()
|
|
728
|
+
.int()
|
|
729
|
+
.min(0)
|
|
730
|
+
.max(1000)
|
|
731
|
+
.default(DEFAULT_BACKUP_SETTINGS.keepDaily),
|
|
732
|
+
keepWeekly: z
|
|
733
|
+
.number()
|
|
734
|
+
.int()
|
|
735
|
+
.min(0)
|
|
736
|
+
.max(1000)
|
|
737
|
+
.default(DEFAULT_BACKUP_SETTINGS.keepWeekly),
|
|
738
|
+
keepCheckpoints: z
|
|
739
|
+
.number()
|
|
740
|
+
.int()
|
|
741
|
+
.min(1)
|
|
742
|
+
.max(1000)
|
|
743
|
+
.default(DEFAULT_BACKUP_SETTINGS.keepCheckpoints),
|
|
719
744
|
includePalace: z.boolean().default(DEFAULT_BACKUP_SETTINGS.includePalace),
|
|
720
745
|
/**
|
|
721
746
|
* WhatsApp auth + the userbot's Telegram login. "local" (default)
|
package/src/core/errors.ts
CHANGED
|
@@ -139,6 +139,17 @@ function errorCodes(err: unknown, depth = 0): string[] {
|
|
|
139
139
|
|
|
140
140
|
// ── Classify any error ──────────────────────────────────────────────────────
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A backend session that expired or no longer exists. Claude's CLI answers
|
|
144
|
+
* a resume of a session whose transcript is missing with "No conversation
|
|
145
|
+
* found with session ID: …" — a definite not-found; without the match
|
|
146
|
+
* every later turn in the chat fails the same way. The reset that follows
|
|
147
|
+
* archives the old id (resetSession), so a transcript that turns up again
|
|
148
|
+
* can be re-attached.
|
|
149
|
+
*/
|
|
150
|
+
const SESSION_EXPIRED_RE =
|
|
151
|
+
/session.*expired|expired.*session|invalid.*resume|no conversation found/i;
|
|
152
|
+
|
|
142
153
|
/**
|
|
143
154
|
* Wrap or classify any thrown value into a TalonError.
|
|
144
155
|
* Call this at module boundaries (backend catch, bridge catch) to convert
|
|
@@ -259,8 +270,8 @@ export function classify(err: unknown): TalonError {
|
|
|
259
270
|
});
|
|
260
271
|
}
|
|
261
272
|
|
|
262
|
-
// Session expired
|
|
263
|
-
if (
|
|
273
|
+
// Session expired (or gone — see SESSION_EXPIRED_RE)
|
|
274
|
+
if (SESSION_EXPIRED_RE.test(msg)) {
|
|
264
275
|
return new TalonError(msg, {
|
|
265
276
|
reason: "session_expired",
|
|
266
277
|
retryable: false,
|
|
@@ -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
|
}
|
|
@@ -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
|
-
}
|
|
@@ -180,7 +180,13 @@ function storageLines(f: ReportFormatter, status: BackupStatus): string[] {
|
|
|
180
180
|
if (policy) {
|
|
181
181
|
lines.push(
|
|
182
182
|
`${f.bold("Retention:")} newest ${policy.keepLocal} kept here, ` +
|
|
183
|
-
`${policy.keepRemote} per target
|
|
183
|
+
`${policy.keepRemote} per target` +
|
|
184
|
+
(policy.keepDaily ? ` · 1/day for ${policy.keepDaily}d` : "") +
|
|
185
|
+
(policy.keepWeekly ? ` · 1/week for ${policy.keepWeekly}w` : "") +
|
|
186
|
+
(policy.keepCheckpoints
|
|
187
|
+
? ` · ${policy.keepCheckpoints} checkpoints`
|
|
188
|
+
: "") +
|
|
189
|
+
` · pinned ones and the last verified are never pruned`,
|
|
184
190
|
);
|
|
185
191
|
lines.push(
|
|
186
192
|
policy.encrypted
|