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
@@ -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; each backend's remembered model pick IS
244
- * kept, so switching back restores it. `keepHistory` is for frontends
245
- * whose local history store is the only record of the chat.
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, opts.keepHistory === true);
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, opts.keepHistory === true);
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 { clearHistory } from "../../storage/history.js";
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
- * history stores, the pulse checkpoint, and any in-process backend memory
42
- * (e.g. openai-agents' MemorySession — stateless backends ignore this).
43
- * Ends by warming the new session so the next turn (and /status) doesn't
44
- * pay cold-start latency.
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
- // Frontends whose platform keeps the real chat record (Telegram,
66
- // Discord) clear the local mirror too — the platform still has
67
- // everything. WhatsApp passes keepHistory: the local store is the ONLY
68
- // record there, and wiping it on /reset would destroy exactly what the
69
- // continuity tools (read/search_chat_history) exist to recover.
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, with
165
- * `keepHistory` deciding whether the local chat log survives it; a
166
- * frontend that owns more per-chat state than the shared stores (the
167
- * native bridge's turn meta and cached readouts) supplies its own.
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, { keepHistory })).text;
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, { keepHistory }))
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 + history stores, pulse checkpoint, and the backend's own
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
- resetSession(cid);
101
- clearHistory(cid);
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
- resetSession(cid);
138
- clearHistory(cid);
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. Only wired
174
+ // /update [force] — pull latest, reinstall, run setup, restart. Refused
175
+ // when the pre-update checkpoint fails unless "force" is given. Only wired
173
176
  // up for developer builds running from a git checkout; packaged
174
177
  // binaries have no source tree (getRepoRoot() === null) so the
175
178
  // command stays absent entirely.
@@ -187,8 +190,11 @@ function registerUpdateCommand(
187
190
  if (isStaleCommand(ctx.message?.date, "/update")) return;
188
191
  const remote = config.update?.remote ?? "origin";
189
192
  const branch = config.update?.branch ?? "main";
193
+ const force = wantsForce(typeof ctx.match === "string" ? ctx.match : "");
190
194
  const sent = await ctx.reply(
191
- `⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code>…`,
195
+ `⏳ Updating from <code>${escapeHtml(remote)}/${escapeHtml(branch)}</code>` +
196
+ (force ? " (forced: a failed checkpoint will not stop it)" : "") +
197
+ "…",
192
198
  { parse_mode: "HTML" },
193
199
  );
194
200
  const edit = (text: string) =>
@@ -204,12 +210,24 @@ function registerUpdateCommand(
204
210
  branch,
205
211
  setup: config.update?.setup,
206
212
  repoRoot: updateRepoRoot,
213
+ force,
207
214
  })
208
215
  .then(async (res) => {
216
+ if (res.checkpointRefused) {
217
+ await edit(
218
+ `🛑 Update refused: ${escapeHtml(res.error ?? "the pre-update checkpoint failed")}\n\n` +
219
+ `Send <code>/update force</code> to update without a checkpoint.`,
220
+ );
221
+ return;
222
+ }
223
+ const note = res.checkpoint
224
+ ? `\n${escapeHtml(describeCheckpoint(res.checkpoint))}`
225
+ : "";
209
226
  if (!res.ok) {
210
227
  const tail = res.steps[res.steps.length - 1]?.output ?? "";
211
228
  await edit(
212
229
  `⚠️ Update failed: ${escapeHtml(res.error ?? "unknown error")}` +
230
+ note +
213
231
  (tail ? `\n\n<pre>${escapeHtml(tail.slice(-1500))}</pre>` : ""),
214
232
  );
215
233
  return;
@@ -221,7 +239,7 @@ function registerUpdateCommand(
221
239
  return;
222
240
  }
223
241
  await edit(
224
- `✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code>. ♻️ Restarting…`,
242
+ `✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code>.${note}\n♻️ Restarting…`,
225
243
  );
226
244
  // The successor documents any provisioning changes (plugin
227
245
  // runtime upgrades, migrations) back to this chat once it's up.
@@ -106,14 +106,11 @@ async function runResetCommand(
106
106
  senderName: string,
107
107
  deps: ModelCommandDeps,
108
108
  ): Promise<string> {
109
- // The local history store is WhatsApp's only chat record — a reset
110
- // clears the model's session, not the conversation log.
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
- // `/reset` keeps history on WhatsApp because the local store is the
139
- // only chat record; a backend switch keeps it for the same reason.
140
- return modelCommandReply(chatId, cmd.arg, deps, { keepHistory: true });
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
@@ -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
- export function getRecentHistory(chatId: string, limit = 50): HistoryMessage[] {
98
- return repo.recent(chatId, limit);
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(chatId, beforeMsgId, limit);
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 messages = repo.recentBefore(chatId, beforeMsgId, limit);
121
- if (messages.length === 0) return "No messages before that point.";
122
- return messages.map(formatMessage).join("\n");
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 messages = repo.recentBeforeTime(chatId, beforeTs, limit);
132
- if (messages.length === 0) return "No messages before that date.";
133
- return messages.map(formatMessage).join("\n");
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
- if (messages.length === 0) return "No messages in history.";
192
- return messages.map(formatMessage).join("\n");
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(formatMessage).join("\n");
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(formatMessage).join("\n");
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. Idempotent; called once at boot.
51
+ * expired entries (unless `purgeExpired: false` — a boot with no safety
52
+ * checkpoint deletes nothing). Idempotent; called once at boot.
52
53
  */
53
- export function loadMediaIndex(): void {
54
+ export function loadMediaIndex(options: { purgeExpired?: boolean } = {}): void {
54
55
  try {
55
56
  importLegacyMediaIndex();
56
57
  } catch (err) {
57
58
  logError("media", "Media index load failed", err);
58
59
  }
59
- purgeExpired();
60
+ if (options.purgeExpired !== false) purgeExpired();
60
61
  }
61
62
 
62
63
  /** Legacy shape: bare MediaEntry[]. */