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
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
resolveChatBackend,
|
|
33
33
|
} from "../../core/engine/backend-controller/index.js";
|
|
34
34
|
import { resetSession } from "../../storage/sessions.js";
|
|
35
|
-
import { clearHistory } from "../../storage/history.js";
|
|
36
35
|
import { resetPulseCheckpoint } from "../../core/background/pulse/pulse.js";
|
|
37
36
|
import { logWarn } from "../../util/log.js";
|
|
38
37
|
import {
|
|
@@ -240,18 +239,18 @@ export function matchBackendArg(
|
|
|
240
239
|
|
|
241
240
|
/**
|
|
242
241
|
* Drop the session state a backend switch invalidates. Session ids are
|
|
243
|
-
* not portable across backends
|
|
244
|
-
*
|
|
245
|
-
*
|
|
242
|
+
* not portable across backends (the replaced id is archived by
|
|
243
|
+
* resetSession); each backend's remembered model pick IS kept, so
|
|
244
|
+
* switching back restores it. Chat history is never touched: a switch
|
|
245
|
+
* changes who answers, not what was said, and the new backend reads the
|
|
246
|
+
* same stored conversation through its history tools.
|
|
246
247
|
*/
|
|
247
248
|
function handOffChatSession(
|
|
248
249
|
chatId: string,
|
|
249
250
|
previous: Backend | null,
|
|
250
251
|
deps: ModelCommandDeps,
|
|
251
|
-
keepHistory: boolean,
|
|
252
252
|
): void {
|
|
253
|
-
resetSession(chatId);
|
|
254
|
-
if (!keepHistory) clearHistory(chatId);
|
|
253
|
+
resetSession(chatId, "backend-switch");
|
|
255
254
|
resetPulseCheckpoint(chatId);
|
|
256
255
|
previous?.sessions?.resetChat?.(chatId);
|
|
257
256
|
const next = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
|
|
@@ -290,7 +289,6 @@ export async function switchChatBackend(
|
|
|
290
289
|
chatId: string,
|
|
291
290
|
target: { id: string; label: string },
|
|
292
291
|
deps: ModelCommandDeps,
|
|
293
|
-
opts: { keepHistory?: boolean } = {},
|
|
294
292
|
): Promise<CommandOutcome> {
|
|
295
293
|
const { backend: previous, backendId: previousId } = resolveChatBackendPair(
|
|
296
294
|
chatId,
|
|
@@ -311,7 +309,7 @@ export async function switchChatBackend(
|
|
|
311
309
|
};
|
|
312
310
|
}
|
|
313
311
|
setChatBackend(chatId, target.id);
|
|
314
|
-
handOffChatSession(chatId, previous, deps
|
|
312
|
+
handOffChatSession(chatId, previous, deps);
|
|
315
313
|
return {
|
|
316
314
|
ok: true,
|
|
317
315
|
text: `Backend: ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}). Session started fresh.`,
|
|
@@ -322,12 +320,11 @@ export async function switchChatBackend(
|
|
|
322
320
|
export async function resetChatBackend(
|
|
323
321
|
chatId: string,
|
|
324
322
|
deps: ModelCommandDeps,
|
|
325
|
-
opts: { keepHistory?: boolean } = {},
|
|
326
323
|
): Promise<CommandOutcome> {
|
|
327
324
|
const { backend: previous } = resolveChatBackendPair(chatId, deps);
|
|
328
325
|
await releaseChat(chatId);
|
|
329
326
|
setChatBackend(chatId, undefined);
|
|
330
|
-
handOffChatSession(chatId, previous, deps
|
|
327
|
+
handOffChatSession(chatId, previous, deps);
|
|
331
328
|
const { backendId } = resolveChatBackendPair(chatId, deps);
|
|
332
329
|
return {
|
|
333
330
|
ok: true,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
getSessionInfo,
|
|
13
13
|
getActiveSessionCount,
|
|
14
14
|
} from "../../storage/sessions.js";
|
|
15
|
-
import {
|
|
15
|
+
import { markContextCleared } from "../../storage/history.js";
|
|
16
16
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
17
17
|
import { resetPulseCheckpoint } from "../../core/background/pulse/pulse.js";
|
|
18
18
|
import { isPulseEnabled } from "../../core/background/pulse/pulse.js";
|
|
@@ -37,16 +37,16 @@ import { formatDuration } from "./format.js";
|
|
|
37
37
|
import { talonVersionLabel } from "../../util/version.js";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Clear a chat's session state everywhere it lives: Talon's session
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* Clear a chat's session state everywhere it lives: Talon's session store
|
|
41
|
+
* (the replaced backend session id is archived), the chat's context (a
|
|
42
|
+
* soft reset — see below), the pulse checkpoint, and any in-process
|
|
43
|
+
* backend memory (e.g. openai-agents' MemorySession — stateless backends
|
|
44
|
+
* ignore this). Ends by warming the new session so the next turn (and
|
|
45
|
+
* /status) doesn't pay cold-start latency.
|
|
45
46
|
*/
|
|
46
47
|
export async function performSessionReset(
|
|
47
48
|
chatId: string,
|
|
48
49
|
backend: Backend | null | undefined,
|
|
49
|
-
opts: { keepHistory?: boolean } = {},
|
|
50
50
|
): Promise<void> {
|
|
51
51
|
const info = getSessionInfo(chatId);
|
|
52
52
|
if (info.turns > 0) {
|
|
@@ -62,12 +62,11 @@ export async function performSessionReset(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
resetSession(chatId);
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
if (!opts.keepHistory) clearHistory(chatId);
|
|
65
|
+
// Soft reset, on every frontend: the bot's context starts fresh after
|
|
66
|
+
// this point, but no history row is deleted — the old conversation stays
|
|
67
|
+
// searchable (search_history labels it) and recoverable. Hard-deleting
|
|
68
|
+
// here once wiped weeks of history; only `talon history purge` deletes.
|
|
69
|
+
markContextCleared(chatId);
|
|
71
70
|
resetPulseCheckpoint(chatId);
|
|
72
71
|
backend?.sessions?.resetChat?.(chatId);
|
|
73
72
|
await backend?.sessions?.warmSession?.(chatId);
|
|
@@ -161,13 +161,12 @@ function renderStatus(s: SessionStatusData): string {
|
|
|
161
161
|
// ── Replies ─────────────────────────────────────────────────────────────────
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* How a frontend changes backend. By default the shared switch runs
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
164
|
+
* How a frontend changes backend. By default the shared switch runs (it
|
|
165
|
+
* never touches chat history); a frontend that owns more per-chat state
|
|
166
|
+
* than the shared stores (the native bridge's cached readouts) supplies
|
|
167
|
+
* its own.
|
|
168
168
|
*/
|
|
169
169
|
export type BackendSwitchHooks = {
|
|
170
|
-
keepHistory?: boolean;
|
|
171
170
|
switchBackend?: (target: { id: string; label: string }) => Promise<string>;
|
|
172
171
|
resetBackend?: () => Promise<string>;
|
|
173
172
|
};
|
|
@@ -181,10 +180,9 @@ export async function modelCommandReply(
|
|
|
181
180
|
): Promise<string> {
|
|
182
181
|
if (!arg) return renderModelOverview(await describeChatModels(chatId, deps));
|
|
183
182
|
const lower = arg.toLowerCase();
|
|
184
|
-
const keepHistory = hooks.keepHistory === true;
|
|
185
183
|
if (lower === "backend default" || lower === "backend reset") {
|
|
186
184
|
if (hooks.resetBackend) return hooks.resetBackend();
|
|
187
|
-
return (await resetChatBackend(chatId, deps
|
|
185
|
+
return (await resetChatBackend(chatId, deps)).text;
|
|
188
186
|
}
|
|
189
187
|
if (lower === "reset" || lower === "default") {
|
|
190
188
|
return (await resetChatModel(chatId, deps)).text;
|
|
@@ -192,8 +190,7 @@ export async function modelCommandReply(
|
|
|
192
190
|
const backend = matchBackendArg(arg, deps.config);
|
|
193
191
|
if (backend) {
|
|
194
192
|
if (hooks.switchBackend) return hooks.switchBackend(backend);
|
|
195
|
-
return (await switchChatBackend(chatId, backend, deps
|
|
196
|
-
.text;
|
|
193
|
+
return (await switchChatBackend(chatId, backend, deps)).text;
|
|
197
194
|
}
|
|
198
195
|
return (await selectChatModel(chatId, arg, deps)).text;
|
|
199
196
|
}
|
|
@@ -100,7 +100,8 @@ export type AdminGateway = { backend: Backend | null };
|
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
102
|
* `/admin kill <chatId>` — the same reset /reset performs in that chat:
|
|
103
|
-
* session
|
|
103
|
+
* session store, a soft context reset (history rows are kept), pulse
|
|
104
|
+
* checkpoint, and the backend's own
|
|
104
105
|
* per-chat session (resolved through the chat's backend override).
|
|
105
106
|
*/
|
|
106
107
|
export async function killSession(
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import type { Context } from "grammy";
|
|
8
8
|
import { setChatBackend } from "../../../../storage/chat-settings.js";
|
|
9
9
|
import { resetSession } from "../../../../storage/sessions.js";
|
|
10
|
-
import { clearHistory } from "../../../../storage/history.js";
|
|
11
10
|
import {
|
|
12
11
|
getBackendIdForChat,
|
|
13
12
|
listAvailableBackends,
|
|
@@ -96,9 +95,9 @@ export async function handleBackendSelect(
|
|
|
96
95
|
// NOT clear `modelByBackend` — keeping each backend's prior
|
|
97
96
|
// pick means switching back-and-forth restores each side's
|
|
98
97
|
// last choice automatically (Codex chat keeps gpt-5.5,
|
|
99
|
-
// OpenRouter chat keeps owl-alpha, etc).
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
// OpenRouter chat keeps owl-alpha, etc). Chat history is never
|
|
99
|
+
// touched: a switch changes who answers, not what was said.
|
|
100
|
+
resetSession(cid, "backend-switch");
|
|
102
101
|
resetPulseCheckpoint(cid);
|
|
103
102
|
handOffBackendSession(cid, previousBackend, gateway);
|
|
104
103
|
const label =
|
|
@@ -134,8 +133,8 @@ export async function handleBackendDefault(
|
|
|
134
133
|
const previousBackend = resolveBackendForChat(cid, gateway);
|
|
135
134
|
await releaseChat(cid);
|
|
136
135
|
setChatBackend(cid, undefined);
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
// History stays — see handleBackendSelect.
|
|
137
|
+
resetSession(cid, "backend-switch");
|
|
139
138
|
resetPulseCheckpoint(cid);
|
|
140
139
|
handOffBackendSession(cid, previousBackend, gateway);
|
|
141
140
|
// Resolve the now-default backend's model for the toast.
|
|
@@ -11,8 +11,10 @@ import type { TalonConfig } from "../../../core/config/index.js";
|
|
|
11
11
|
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
12
12
|
import { isStaleCommand } from "../polling/stale-command.js";
|
|
13
13
|
import {
|
|
14
|
+
describeCheckpoint,
|
|
14
15
|
getRepoRoot,
|
|
15
16
|
runSelfUpdate,
|
|
17
|
+
wantsForce,
|
|
16
18
|
} from "../../../core/update/self-update.js";
|
|
17
19
|
import { forceDream } from "../../../core/background/dream/index.js";
|
|
18
20
|
import { escapeHtml } from "../formatting.js";
|
|
@@ -169,7 +171,8 @@ function registerRestartCommand(bot: Bot): void {
|
|
|
169
171
|
});
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
// /update — pull latest, reinstall, run setup, restart.
|
|
174
|
+
// /update [force] — pull latest, reinstall, run setup, restart. Refused
|
|
175
|
+
// when the pre-update checkpoint fails unless "force" is given. Only wired
|
|
173
176
|
// up for developer builds running from a git checkout; packaged
|
|
174
177
|
// binaries have no source tree (getRepoRoot() === null) so the
|
|
175
178
|
// command stays absent entirely.
|
|
@@ -187,8 +190,11 @@ function registerUpdateCommand(
|
|
|
187
190
|
if (isStaleCommand(ctx.message?.date, "/update")) return;
|
|
188
191
|
const remote = config.update?.remote ?? "origin";
|
|
189
192
|
const branch = config.update?.branch ?? "main";
|
|
193
|
+
const force = wantsForce(typeof ctx.match === "string" ? ctx.match : "");
|
|
190
194
|
const sent = await ctx.reply(
|
|
191
|
-
`⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code
|
|
195
|
+
`⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code>` +
|
|
196
|
+
(force ? " (forced: a failed checkpoint will not stop it)" : "") +
|
|
197
|
+
"…",
|
|
192
198
|
{ parse_mode: "HTML" },
|
|
193
199
|
);
|
|
194
200
|
const edit = (text: string) =>
|
|
@@ -204,12 +210,24 @@ function registerUpdateCommand(
|
|
|
204
210
|
branch,
|
|
205
211
|
setup: config.update?.setup,
|
|
206
212
|
repoRoot: updateRepoRoot,
|
|
213
|
+
force,
|
|
207
214
|
})
|
|
208
215
|
.then(async (res) => {
|
|
216
|
+
if (res.checkpointRefused) {
|
|
217
|
+
await edit(
|
|
218
|
+
`🛑 Update refused: ${escapeHtml(res.error ?? "the pre-update checkpoint failed")}\n\n` +
|
|
219
|
+
`Send <code>/update force</code> to update without a checkpoint.`,
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const note = res.checkpoint
|
|
224
|
+
? `\n${escapeHtml(describeCheckpoint(res.checkpoint))}`
|
|
225
|
+
: "";
|
|
209
226
|
if (!res.ok) {
|
|
210
227
|
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
211
228
|
await edit(
|
|
212
229
|
`⚠️ Update failed: ${escapeHtml(res.error ?? "unknown error")}` +
|
|
230
|
+
note +
|
|
213
231
|
(tail ? `\n\n<pre>${escapeHtml(tail.slice(-1500))}</pre>` : ""),
|
|
214
232
|
);
|
|
215
233
|
return;
|
|
@@ -221,7 +239,7 @@ function registerUpdateCommand(
|
|
|
221
239
|
return;
|
|
222
240
|
}
|
|
223
241
|
await edit(
|
|
224
|
-
`✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code
|
|
242
|
+
`✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code>.${note}\n♻️ Restarting…`,
|
|
225
243
|
);
|
|
226
244
|
// The successor documents any provisioning changes (plugin
|
|
227
245
|
// runtime upgrades, migrations) back to this chat once it's up.
|
|
@@ -106,14 +106,11 @@ async function runResetCommand(
|
|
|
106
106
|
senderName: string,
|
|
107
107
|
deps: ModelCommandDeps,
|
|
108
108
|
): Promise<string> {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
109
|
+
// A soft reset: the conversation log is kept (it is WhatsApp's only chat
|
|
110
|
+
// record), only the bot's context starts fresh.
|
|
111
111
|
await performSessionReset(
|
|
112
112
|
chatId,
|
|
113
113
|
resolveChatBackendPair(chatId, deps).backend,
|
|
114
|
-
{
|
|
115
|
-
keepHistory: true,
|
|
116
|
-
},
|
|
117
114
|
);
|
|
118
115
|
log("whatsapp", `Session reset by ${senderName}`);
|
|
119
116
|
return "Session cleared.";
|
|
@@ -135,9 +132,9 @@ export async function executeWhatsAppCommand(
|
|
|
135
132
|
}
|
|
136
133
|
switch (cmd.name) {
|
|
137
134
|
case "model":
|
|
138
|
-
//
|
|
139
|
-
// only chat record
|
|
140
|
-
return modelCommandReply(chatId, cmd.arg, deps
|
|
135
|
+
// A backend switch never touches history (the local store is also
|
|
136
|
+
// WhatsApp's only chat record).
|
|
137
|
+
return modelCommandReply(chatId, cmd.arg, deps);
|
|
141
138
|
case "effort":
|
|
142
139
|
return effortCommandReply(chatId, cmd.arg, deps);
|
|
143
140
|
case "settings":
|
|
@@ -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
|
package/src/storage/history.ts
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
* `includes()` scan over the tail
|
|
14
14
|
* - writes are transactional rows, not rewrite-the-file-on-flush
|
|
15
15
|
*
|
|
16
|
+
* Rows are never deleted by normal operation. A /reset (or admin kill,
|
|
17
|
+
* or a native chat reset) records a per-chat context floor
|
|
18
|
+
* ({@link markContextCleared}); a native chat delete hides the chat
|
|
19
|
+
* ({@link hideChatHistory}). Readers that build the bot's context —
|
|
20
|
+
* getRecentHistory, the read_history tool, the pulse, the native
|
|
21
|
+
* transcript — start after the floor; explicit search sees every row,
|
|
22
|
+
* labelling the ones from before the reset. {@link purgeChatHistory},
|
|
23
|
+
* reached only from the operator's `talon history purge`, is the one
|
|
24
|
+
* path that deletes rows.
|
|
25
|
+
*
|
|
16
26
|
* The legacy ~/.talon/data/history.json (JsonStore envelope or bare
|
|
17
27
|
* pre-envelope shape) is imported once on first load, then renamed to
|
|
18
28
|
* history.json.imported.
|
|
@@ -94,21 +104,133 @@ export function maxMsgIdForChatPrefix(prefix: string): number | undefined {
|
|
|
94
104
|
return repo.maxMsgIdForPrefix(prefix);
|
|
95
105
|
}
|
|
96
106
|
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
// ── Soft reset / soft delete ────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Reader options: `includeCleared` also returns rows from before a reset. */
|
|
110
|
+
export type HistoryReadOptions = { includeCleared?: boolean };
|
|
111
|
+
|
|
112
|
+
/** The chat's reset/hidden state, or undefined when it has none. */
|
|
113
|
+
export function getChatHistoryState(
|
|
114
|
+
chatId: string,
|
|
115
|
+
): repo.ChatHistoryState | undefined {
|
|
116
|
+
try {
|
|
117
|
+
return repo.chatState(chatId);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logError("history", `Failed to read history state chat=${chatId}`, err);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Row-id floor for context readers: 0 when the chat was never reset. */
|
|
125
|
+
function contextFloor(chatId: string, opts: HistoryReadOptions = {}): number {
|
|
126
|
+
if (opts.includeCleared) return 0;
|
|
127
|
+
return getChatHistoryState(chatId)?.clearedThroughId ?? 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Soft reset: the chat's context starts fresh from here. Every stored row
|
|
132
|
+
* stays — searchable, recoverable — but context readers skip rows stored
|
|
133
|
+
* before this call. Replaces the old hard delete on /reset, /new and
|
|
134
|
+
* admin kill. Never throws.
|
|
135
|
+
*/
|
|
136
|
+
export function markContextCleared(chatId: string, at = Date.now()): void {
|
|
137
|
+
try {
|
|
138
|
+
repo.markCleared(chatId, at);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logError("history", `Failed to mark context reset chat=${chatId}`, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Soft delete: the user deleted this chat in a client. The rows stay (the
|
|
146
|
+
* operator can still recover or purge them); the chat is flagged hidden
|
|
147
|
+
* and its context floor moves, so it no longer restores and a chat that
|
|
148
|
+
* reappears under the same id starts fresh. Never throws.
|
|
149
|
+
*/
|
|
150
|
+
export function hideChatHistory(chatId: string, at = Date.now()): void {
|
|
151
|
+
try {
|
|
152
|
+
repo.markHidden(chatId, at);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logError("history", `Failed to hide chat history chat=${chatId}`, err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True when the chat was deleted in a client (its rows are kept). */
|
|
159
|
+
export function isChatHistoryHidden(chatId: string): boolean {
|
|
160
|
+
return getChatHistoryState(chatId)?.hiddenAt !== undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Chats deleted in a client, newest first, with their kept row counts. */
|
|
164
|
+
export function listHiddenChats(): repo.HiddenChat[] {
|
|
165
|
+
return repo.hiddenChats();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Permanently delete a chat's history rows and state. The ONLY hard delete
|
|
170
|
+
* of history: reached solely from the operator's `talon history purge`
|
|
171
|
+
* (which confirms first). Returns the number of rows deleted.
|
|
172
|
+
*/
|
|
173
|
+
export function purgeChatHistory(chatId: string): number {
|
|
174
|
+
const deleted = repo.purgeChat(chatId);
|
|
175
|
+
log("history", `Purged ${deleted} history row(s) for chat=${chatId}`);
|
|
176
|
+
return deleted;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Reads ───────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The chat's current conversation: its most recent `limit` messages after
|
|
183
|
+
* the last context reset, chronological. `includeCleared` reads across it.
|
|
184
|
+
*/
|
|
185
|
+
export function getRecentHistory(
|
|
186
|
+
chatId: string,
|
|
187
|
+
limit = 50,
|
|
188
|
+
opts: HistoryReadOptions = {},
|
|
189
|
+
): HistoryMessage[] {
|
|
190
|
+
return repo.recent(chatId, limit, contextFloor(chatId, opts));
|
|
99
191
|
}
|
|
100
192
|
|
|
101
193
|
/**
|
|
102
194
|
* Scroll-back pagination: the `limit` messages strictly older than
|
|
103
195
|
* `beforeMsgId`, chronological. Used by the bridge's /history endpoint so
|
|
104
196
|
* clients can walk long histories page by page instead of one giant fetch.
|
|
197
|
+
* Stops at the context-reset floor unless `includeCleared`.
|
|
105
198
|
*/
|
|
106
199
|
export function getHistoryBefore(
|
|
107
200
|
chatId: string,
|
|
108
201
|
beforeMsgId: number,
|
|
109
202
|
limit = 50,
|
|
203
|
+
opts: HistoryReadOptions = {},
|
|
110
204
|
): HistoryMessage[] {
|
|
111
|
-
return repo.recentBefore(
|
|
205
|
+
return repo.recentBefore(
|
|
206
|
+
chatId,
|
|
207
|
+
beforeMsgId,
|
|
208
|
+
limit,
|
|
209
|
+
contextFloor(chatId, opts),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A note for the read_history tool when the chat's context was reset: the
|
|
215
|
+
* older rows are kept, only out of the default view. Empty when there is
|
|
216
|
+
* nothing hidden behind the floor.
|
|
217
|
+
*/
|
|
218
|
+
function clearedNote(chatId: string): string {
|
|
219
|
+
const state = getChatHistoryState(chatId);
|
|
220
|
+
if (!state || state.clearedThroughId <= 0) return "";
|
|
221
|
+
const when = state.clearedAt
|
|
222
|
+
? ` on ${new Date(state.clearedAt).toISOString()}`
|
|
223
|
+
: "";
|
|
224
|
+
return (
|
|
225
|
+
`[Context was reset${when}. Messages from before the reset are kept ` +
|
|
226
|
+
"but not shown here; search_history can still find them.]"
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function withClearedNote(chatId: string, body: string, empty: string): string {
|
|
231
|
+
const note = clearedNote(chatId);
|
|
232
|
+
if (!note) return body || empty;
|
|
233
|
+
return body ? `${note}\n${body}` : `${empty}\n${note}`;
|
|
112
234
|
}
|
|
113
235
|
|
|
114
236
|
/** Formatted page of the messages strictly older than `beforeMsgId`. */
|
|
@@ -117,9 +239,13 @@ export function getFormattedBefore(
|
|
|
117
239
|
beforeMsgId: number,
|
|
118
240
|
limit = 30,
|
|
119
241
|
): string {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
242
|
+
const floor = contextFloor(chatId);
|
|
243
|
+
const messages = repo.recentBefore(chatId, beforeMsgId, limit, floor);
|
|
244
|
+
const body = messages.map(formatMessage).join("\n");
|
|
245
|
+
if (floor > 0 && messages.length < limit) {
|
|
246
|
+
return withClearedNote(chatId, body, "No messages before that point.");
|
|
247
|
+
}
|
|
248
|
+
return body || "No messages before that point.";
|
|
123
249
|
}
|
|
124
250
|
|
|
125
251
|
/** Formatted page of the messages strictly older than a timestamp (ms). */
|
|
@@ -128,9 +254,13 @@ export function getFormattedBeforeTime(
|
|
|
128
254
|
beforeTs: number,
|
|
129
255
|
limit = 30,
|
|
130
256
|
): string {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
257
|
+
const floor = contextFloor(chatId);
|
|
258
|
+
const messages = repo.recentBeforeTime(chatId, beforeTs, limit, floor);
|
|
259
|
+
const body = messages.map(formatMessage).join("\n");
|
|
260
|
+
if (floor > 0 && messages.length < limit) {
|
|
261
|
+
return withClearedNote(chatId, body, "No messages before that date.");
|
|
262
|
+
}
|
|
263
|
+
return body || "No messages before that date.";
|
|
134
264
|
}
|
|
135
265
|
|
|
136
266
|
/**
|
|
@@ -163,10 +293,6 @@ export function setMessageFilePath(
|
|
|
163
293
|
repo.setFilePath(chatId, msgId, filePath);
|
|
164
294
|
}
|
|
165
295
|
|
|
166
|
-
export function clearHistory(chatId: string): void {
|
|
167
|
-
repo.deleteChat(chatId);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
296
|
// ── Formatted queries ───────────────────────────────────────────────────────
|
|
171
297
|
|
|
172
298
|
function formatMessage(m: HistoryMessage): string {
|
|
@@ -186,10 +312,34 @@ function formatMessage(m: HistoryMessage): string {
|
|
|
186
312
|
return `[msg:${m.msgId} ${time}] ${who}${replyTag}${mediaTag}${stickerTag}${fileTag}: ${m.text}`;
|
|
187
313
|
}
|
|
188
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The read_history tool's default view: the conversation since the last
|
|
317
|
+
* context reset, with a note saying older rows exist when the page reaches
|
|
318
|
+
* the reset.
|
|
319
|
+
*/
|
|
189
320
|
export function getRecentFormatted(chatId: string, limit = 20): string {
|
|
190
321
|
const messages = getRecentHistory(chatId, limit);
|
|
191
|
-
|
|
192
|
-
|
|
322
|
+
const body = messages.map(formatMessage).join("\n");
|
|
323
|
+
if (messages.length < limit) {
|
|
324
|
+
return withClearedNote(chatId, body, "No messages in history.");
|
|
325
|
+
}
|
|
326
|
+
return body || "No messages in history.";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Formatter for explicit lookups that read across a context reset (search,
|
|
331
|
+
* by-user): rows from before the reset carry a label so the reader knows
|
|
332
|
+
* they are no longer part of the current conversation. The label is by
|
|
333
|
+
* timestamp — rows have no id on the domain type — so a message stamped
|
|
334
|
+
* just before the reset but stored after it reads as older.
|
|
335
|
+
*/
|
|
336
|
+
function labelledFormatter(chatId: string): (m: HistoryMessage) => string {
|
|
337
|
+
const clearedAt = getChatHistoryState(chatId)?.clearedAt;
|
|
338
|
+
if (clearedAt === undefined) return formatMessage;
|
|
339
|
+
return (m) =>
|
|
340
|
+
m.timestamp <= clearedAt
|
|
341
|
+
? `[before context reset] ${formatMessage(m)}`
|
|
342
|
+
: formatMessage(m);
|
|
193
343
|
}
|
|
194
344
|
|
|
195
345
|
/**
|
|
@@ -236,7 +386,7 @@ export function searchHistory(
|
|
|
236
386
|
return `No messages matching "${query}".`;
|
|
237
387
|
}
|
|
238
388
|
if (messages.length === 0) return `No messages matching "${query}".`;
|
|
239
|
-
return messages.map(
|
|
389
|
+
return messages.map(labelledFormatter(chatId)).join("\n");
|
|
240
390
|
}
|
|
241
391
|
|
|
242
392
|
export function getMessagesByUser(
|
|
@@ -247,7 +397,7 @@ export function getMessagesByUser(
|
|
|
247
397
|
if (chatIsEmpty(chatId)) return "No messages in history.";
|
|
248
398
|
const messages = repo.bySenderName(chatId, userName, limit);
|
|
249
399
|
if (messages.length === 0) return `No messages from "${userName}".`;
|
|
250
|
-
return messages.map(
|
|
400
|
+
return messages.map(labelledFormatter(chatId)).join("\n");
|
|
251
401
|
}
|
|
252
402
|
|
|
253
403
|
/** The stored row for one message, or undefined. */
|
|
@@ -48,15 +48,16 @@ function isMediaEntry(value: unknown): value is MediaEntry {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Run the one-time import of the legacy JSON store, then sweep
|
|
51
|
-
* expired entries
|
|
51
|
+
* expired entries (unless `purgeExpired: false` — a boot with no safety
|
|
52
|
+
* checkpoint deletes nothing). Idempotent; called once at boot.
|
|
52
53
|
*/
|
|
53
|
-
export function loadMediaIndex(): void {
|
|
54
|
+
export function loadMediaIndex(options: { purgeExpired?: boolean } = {}): void {
|
|
54
55
|
try {
|
|
55
56
|
importLegacyMediaIndex();
|
|
56
57
|
} catch (err) {
|
|
57
58
|
logError("media", "Media index load failed", err);
|
|
58
59
|
}
|
|
59
|
-
purgeExpired();
|
|
60
|
+
if (options.purgeExpired !== false) purgeExpired();
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/** Legacy shape: bare MediaEntry[]. */
|