talon-agent 5.26.2 → 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 CHANGED
@@ -468,6 +468,7 @@ talon kill Abort a killable task by id
468
468
  talon events Tail the event bus (-f follows, --history [N] reads the journal)
469
469
  talon plugin Manage plugins (install / enable / disable / remove)
470
470
  talon skill Manage skills (install / enable / disable / remove)
471
+ talon history Chat history kept by Talon (show / hidden / purge — docs/chat-history.md)
471
472
  talon config View or edit configuration
472
473
  talon logs Tail structured log file
473
474
  talon doctor Validate environment and dependencies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.26.2",
3
+ "version": "5.26.3",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `talon history` — the operator's view of chat history the daemon keeps.
3
+ *
4
+ * Talon never deletes chat history on its own: /reset is a soft reset
5
+ * (a context marker), backend switches leave history alone, and deleting a
6
+ * chat in the app only hides it. `purge` is the one way to delete rows for
7
+ * real — host access only (it is not reachable from any chat), and it asks
8
+ * for the chat id to be typed back before touching anything.
9
+ */
10
+
11
+ import { createInterface } from "node:readline/promises";
12
+ import pc from "picocolors";
13
+ import {
14
+ getChatHistoryState,
15
+ getHistoryStats,
16
+ listHiddenChats,
17
+ purgeChatHistory,
18
+ } from "../../storage/history.js";
19
+ import { clearTurnMeta } from "../../storage/turn-meta.js";
20
+
21
+ const USAGE = [
22
+ ` Usage: ${pc.cyan("talon history <command>")}`,
23
+ "",
24
+ " Commands:",
25
+ ` ${pc.cyan("show <chatId>")} Row count, date range, reset/hidden state`,
26
+ ` ${pc.cyan("hidden")} Chats deleted in a client (rows kept)`,
27
+ ` ${pc.cyan("purge <chatId> [--yes]")} PERMANENTLY delete a chat's history`,
28
+ "",
29
+ " Resets, backend switches and chat deletion never delete history;",
30
+ ` ${pc.cyan("purge")} is the only command that does. Take a checkpoint first:`,
31
+ ` ${pc.cyan('talon backup now --checkpoint "before purge"')}`,
32
+ "",
33
+ ].join("\n");
34
+
35
+ /** Reads one line from stdin; injectable so tests can answer the prompt. */
36
+ export type HistoryCliIo = {
37
+ ask: (question: string) => Promise<string>;
38
+ print: (line: string) => void;
39
+ };
40
+
41
+ const defaultIo: HistoryCliIo = {
42
+ ask: async (question) => {
43
+ const rl = createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+ try {
48
+ return await rl.question(question);
49
+ } finally {
50
+ rl.close();
51
+ }
52
+ },
53
+ print: (line) => console.log(line),
54
+ };
55
+
56
+ function date(ms: number | undefined): string {
57
+ return ms ? new Date(ms).toISOString() : "—";
58
+ }
59
+
60
+ function cmdShow(chatId: string, io: HistoryCliIo): void {
61
+ const stats = getHistoryStats(chatId);
62
+ const state = getChatHistoryState(chatId);
63
+ io.print(` ${pc.bold(chatId)}`);
64
+ io.print(
65
+ ` ${stats.totalMessages} message(s), ${stats.uniqueUsers} sender(s), ` +
66
+ `${date(stats.oldestTimestamp || undefined)} → ${date(stats.newestTimestamp || undefined)}`,
67
+ );
68
+ if (state?.clearedAt !== undefined) {
69
+ io.print(` context reset at ${date(state.clearedAt)} (rows kept)`);
70
+ }
71
+ if (state?.hiddenAt !== undefined) {
72
+ io.print(` deleted in a client at ${date(state.hiddenAt)} (rows kept)`);
73
+ }
74
+ io.print("");
75
+ }
76
+
77
+ function cmdHidden(io: HistoryCliIo): void {
78
+ const chats = listHiddenChats();
79
+ if (chats.length === 0) {
80
+ io.print(` ${pc.dim("No hidden chats.")}\n`);
81
+ return;
82
+ }
83
+ for (const chat of chats) {
84
+ io.print(
85
+ ` ${pc.bold(chat.chatId)} ${chat.total} message(s) ${pc.dim(`hidden ${date(chat.hiddenAt)}`)}`,
86
+ );
87
+ }
88
+ io.print("");
89
+ }
90
+
91
+ /**
92
+ * Delete a chat's history for good. Without `--yes` the operator must type
93
+ * the chat id back; anything else aborts. Returns rows deleted (0 when
94
+ * aborted or empty).
95
+ */
96
+ async function cmdPurge(
97
+ chatId: string,
98
+ yes: boolean,
99
+ io: HistoryCliIo,
100
+ ): Promise<number> {
101
+ const { totalMessages } = getHistoryStats(chatId);
102
+ if (totalMessages === 0 && getChatHistoryState(chatId) === undefined) {
103
+ io.print(` ${pc.dim(`No history stored for ${chatId}.`)}\n`);
104
+ return 0;
105
+ }
106
+ if (!yes) {
107
+ io.print(
108
+ `\n This PERMANENTLY deletes ${pc.bold(String(totalMessages))} message(s) of chat ${pc.bold(chatId)}.\n` +
109
+ ` It cannot be undone except from a backup. Take a checkpoint first:\n` +
110
+ ` ${pc.cyan('talon backup now --checkpoint "before purge"')}\n`,
111
+ );
112
+ const answer = (await io.ask(` Type the chat id to confirm: `)).trim();
113
+ if (answer !== chatId) {
114
+ io.print(` ${pc.yellow("●")} Aborted — nothing deleted.\n`);
115
+ return 0;
116
+ }
117
+ }
118
+ const deleted = purgeChatHistory(chatId);
119
+ clearTurnMeta(chatId);
120
+ io.print(` ${pc.green("●")} Purged ${deleted} message(s) from ${chatId}.\n`);
121
+ return deleted;
122
+ }
123
+
124
+ /** Route a `talon history <command>` invocation. */
125
+ export async function runHistoryCommand(
126
+ args: readonly string[],
127
+ io: HistoryCliIo = defaultIo,
128
+ ): Promise<void> {
129
+ const positional = args.filter((a) => !a.startsWith("--"));
130
+ const yes = args.includes("--yes");
131
+ const chatId = positional[1];
132
+ try {
133
+ switch (positional[0]) {
134
+ case "show":
135
+ if (!chatId) io.print(` ${pc.red("✖")} show needs a chat id\n`);
136
+ else cmdShow(chatId, io);
137
+ break;
138
+ case "hidden":
139
+ cmdHidden(io);
140
+ break;
141
+ case "purge":
142
+ if (!chatId) io.print(` ${pc.red("✖")} purge needs a chat id\n`);
143
+ else await cmdPurge(chatId, yes, io);
144
+ break;
145
+ default:
146
+ io.print(USAGE);
147
+ }
148
+ } catch (err) {
149
+ io.print(` ${pc.red("✖")} ${err instanceof Error ? err.message : err}`);
150
+ process.exitCode = 1;
151
+ }
152
+ }
package/src/cli/index.ts CHANGED
@@ -37,6 +37,7 @@ import { showEvents } from "./events.js";
37
37
  import { runPluginCommand } from "./plugin.js";
38
38
  import { runSkillCommand } from "./skill.js";
39
39
  import { runMemoryCommand } from "./memory.js";
40
+ import { runHistoryCommand } from "./commands/history.js";
40
41
  import { mainMenu } from "./menu.js";
41
42
  import { runBackupCommand } from "./commands/backup.js";
42
43
  import { runMeshCommand } from "./commands/mesh.js";
@@ -62,6 +63,7 @@ const CLI_COMMANDS = [
62
63
  "plugin",
63
64
  "skill",
64
65
  "memory",
66
+ "history",
65
67
  "backup",
66
68
  "mesh",
67
69
  ];
@@ -109,6 +111,9 @@ function printHelp(): void {
109
111
  console.log(
110
112
  ` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
111
113
  );
114
+ console.log(
115
+ ` ${pc.cyan("history")} Chat history kept by Talon (show/hidden/purge)`,
116
+ );
112
117
  console.log(
113
118
  ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
114
119
  );
@@ -180,6 +185,7 @@ const COMMANDS: Record<string, CommandHandler> = {
180
185
  plugin: (args) => runPluginCommand(args),
181
186
  skill: (args) => runSkillCommand(args),
182
187
  memory: (args) => runMemoryCommand(args),
188
+ history: (args) => runHistoryCommand(args),
183
189
  "--version": () => console.log(pkg.version),
184
190
  "-v": () => console.log(pkg.version),
185
191
  "--help": () => printHelp(),
@@ -12,7 +12,7 @@ import { tailFile } from "../../util/tail-file.js";
12
12
  import type { TalonConfig } from "../../core/config/index.js";
13
13
  import type { Gateway } from "../../core/engine/gateway.js";
14
14
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
15
- import { clearHistory } from "../../storage/history.js";
15
+ import { markContextCleared } from "../../storage/history.js";
16
16
  import { todayLogDate } from "../../storage/daily-log.js";
17
17
  import { getChatSettings } from "../../storage/chat-settings.js";
18
18
  import {
@@ -87,7 +87,7 @@ export async function handleAdminSubcommand(
87
87
  const target = rest[0];
88
88
  if (!target) return send("Usage: /admin kill <chatId>");
89
89
  resetSession(target);
90
- clearHistory(target);
90
+ markContextCleared(target); // soft reset: history rows are kept
91
91
  gateway?.backend?.sessions?.resetChat?.(target);
92
92
  return send(`Session ${target} reset.`);
93
93
  }
@@ -20,7 +20,6 @@ import {
20
20
  rebindChat,
21
21
  } from "../../../../core/engine/backend-controller/index.js";
22
22
  import { resetSession } from "../../../../storage/sessions.js";
23
- import { clearHistory } from "../../../../storage/history.js";
24
23
  import { resetPulseCheckpoint } from "../../../../core/background/pulse/pulse.js";
25
24
  import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
26
25
  import { logError } from "../../../../util/log.js";
@@ -109,12 +108,13 @@ export async function handleBackendSelect(
109
108
  }
110
109
 
111
110
  // Only now is the switch known to hold. Session state doesn't port across
112
- // backends, so it goes — but each backend's remembered model pick stays.
111
+ // backends, so it goes — but each backend's remembered model pick stays,
112
+ // and chat history is never touched (a switch changes who answers, not
113
+ // what was said).
113
114
  // A re-pick of the backend already in use clears nothing: the retry after
114
115
  // a timed-out interaction must not cost the session a second time.
115
116
  if (!alreadyThere) {
116
- resetSession(chatId);
117
- clearHistory(chatId);
117
+ resetSession(chatId, "backend-switch");
118
118
  resetPulseCheckpoint(chatId);
119
119
  previous?.sessions?.resetChat?.(chatId);
120
120
  }
@@ -6,7 +6,6 @@
6
6
  import { toClientChat } from "./chat-wire.js";
7
7
  import type { ClientChat } from "../protocol.js";
8
8
  import type { NativeRuntime } from "../runtime.js";
9
- import { clearTurnMeta } from "../turn/turn-meta.js";
10
9
 
11
10
  export function createChat(runtime: NativeRuntime, title?: string): ClientChat {
12
11
  const entry = runtime.chats.create(title);
@@ -27,10 +26,14 @@ export function renameChat(
27
26
  return chat;
28
27
  }
29
28
 
29
+ /**
30
+ * Soft delete (see NativeChats.remove): the chat disappears from every
31
+ * client, but its history rows and their turn meta are kept for the
32
+ * operator — `talon history purge` is the only hard delete.
33
+ */
30
34
  export function deleteChat(runtime: NativeRuntime, chatId: string): boolean {
31
35
  const ok = runtime.chats.remove(chatId);
32
36
  if (ok) {
33
- clearTurnMeta(chatId);
34
37
  runtime.contextByChat.delete(chatId);
35
38
  runtime.queuedByChat.delete(chatId);
36
39
  runtime.broadcast({ kind: "chat_deleted", chatId });
@@ -17,7 +17,11 @@ import {
17
17
  setSessionName,
18
18
  deleteSession,
19
19
  } from "../../../storage/sessions.js";
20
- import { getRecentHistory, clearHistory } from "../../../storage/history.js";
20
+ import {
21
+ getRecentHistory,
22
+ hideChatHistory,
23
+ isChatHistoryHidden,
24
+ } from "../../../storage/history.js";
21
25
  import { previewOf } from "../protocol.js";
22
26
 
23
27
  export type ChatEntry = {
@@ -55,6 +59,8 @@ export class NativeChats {
55
59
  restore(): void {
56
60
  for (const { chatId, info } of getAllSessions()) {
57
61
  if (!isNativeChatId(chatId)) continue;
62
+ // Deleted by the user: its rows are kept, the chat stays gone.
63
+ if (isChatHistoryHidden(chatId)) continue;
58
64
  const recent = getRecentHistory(chatId, 1);
59
65
  const entry: ChatEntry = {
60
66
  id: chatId,
@@ -133,13 +139,19 @@ export class NativeChats {
133
139
  return entry;
134
140
  }
135
141
 
142
+ /**
143
+ * Delete the chat from the app. A soft delete: the chat leaves every list
144
+ * and its session goes, but its history rows are only hidden, never
145
+ * deleted — the operator can still find them, and only an explicit
146
+ * `talon history purge` removes them for good.
147
+ */
136
148
  remove(id: string): boolean {
137
149
  const entry = this.byId.get(id);
138
150
  if (!entry) return false;
139
151
  this.byId.delete(id);
140
152
  this.byNum.delete(entry.numericId);
141
153
  deleteSession(id);
142
- clearHistory(id);
154
+ hideChatHistory(id);
143
155
  return true;
144
156
  }
145
157
 
@@ -28,7 +28,10 @@ function sweepEmptyChats(runtime: NativeRuntime): void {
28
28
  for (const entry of runtime.chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
29
29
  if (isBusy(runtime, entry.id) || runtime.queuedByChat.has(entry.id))
30
30
  continue;
31
- if (getRecentHistory(entry.id, 1).length > 0) continue;
31
+ // Any stored row at all — even from before a reset — means the chat
32
+ // was used; never sweep it.
33
+ if (getRecentHistory(entry.id, 1, { includeCleared: true }).length > 0)
34
+ continue;
32
35
  if (deleteChat(runtime, entry.id)) {
33
36
  log("native", `Swept empty chat ${entry.id}`);
34
37
  }
@@ -1,41 +1,63 @@
1
1
  /**
2
- * Chat reset — drop a chat's conversation (session, history, turn meta,
3
- * cached readouts, pulse checkpoint) while keeping the chat itself.
2
+ * Chat reset and backend hand-off — what a native chat forgets when its
3
+ * conversation restarts. Neither path deletes chat history: a reset is a
4
+ * soft reset (the context floor moves, the rows stay searchable), and a
5
+ * backend switch leaves history and the transcript exactly as they were.
4
6
  */
5
7
 
6
8
  import { resetSession } from "../../../storage/sessions.js";
7
- import { clearHistory } from "../../../storage/history.js";
9
+ import { markContextCleared } from "../../../storage/history.js";
8
10
  import { resetPulseCheckpoint } from "../../../core/background/pulse/pulse.js";
9
11
  import { getBackendForChat } from "../../../core/engine/backend-controller/index.js";
10
12
  import { broadcastChatUpdated } from "./chat-wire.js";
11
13
  import { emitSystem } from "../turn/emit.js";
12
14
  import type { NativeRuntime } from "../runtime.js";
13
- import { clearTurnMeta } from "../turn/turn-meta.js";
14
15
 
15
- /**
16
- * Forget everything the conversation accumulated. Shared by the explicit
17
- * reset and a backend switch (sessions aren't portable across backends).
18
- */
19
- export function wipeChatConversation(
16
+ /** Drop the per-process state tied to the chat's backend session. */
17
+ function dropSessionState(
20
18
  runtime: NativeRuntime,
21
19
  chatId: string,
20
+ reason: string,
22
21
  ): void {
23
- resetSession(chatId);
24
- clearHistory(chatId);
25
- clearTurnMeta(chatId);
22
+ resetSession(chatId, reason);
26
23
  runtime.contextByChat.delete(chatId);
27
24
  runtime.queuedByChat.delete(chatId);
28
25
  resetPulseCheckpoint(chatId);
29
26
  }
30
27
 
28
+ /**
29
+ * A backend switch: session ids aren't portable across backends, so the
30
+ * session goes (its id is archived by resetSession). History, turn meta
31
+ * and the transcript stay — a switch changes who answers, not what was
32
+ * said.
33
+ */
34
+ export function handOffChatBackend(
35
+ runtime: NativeRuntime,
36
+ chatId: string,
37
+ ): void {
38
+ dropSessionState(runtime, chatId, "backend-switch");
39
+ }
40
+
41
+ /**
42
+ * An explicit reset: the session goes and the chat's context starts fresh
43
+ * (a soft reset — every history row and its turn meta is kept, searchable
44
+ * and recoverable; the transcript and the bot's history tools start after
45
+ * the reset point).
46
+ */
47
+ function resetChatContext(runtime: NativeRuntime, chatId: string): void {
48
+ dropSessionState(runtime, chatId, "reset");
49
+ markContextCleared(chatId);
50
+ }
51
+
31
52
  export function resetChat(runtime: NativeRuntime, chatId: string): boolean {
32
53
  const entry = runtime.chats.get(chatId);
33
54
  if (!entry) return false;
34
- // Full reset, matching /reset on the other frontends: session,
35
- // history (the app re-fetches its transcript from us), pulse
36
- // checkpoint, and any in-process backend memory. Warm the fresh
37
- // session in the background — the bridge handler is sync.
38
- wipeChatConversation(runtime, chatId);
55
+ // Full reset, matching /reset on the other frontends: session, the
56
+ // chat's context (soft — the app re-fetches a transcript that starts
57
+ // after the reset; nothing is deleted), pulse checkpoint, and any
58
+ // in-process backend memory. Warm the fresh session in the background —
59
+ // the bridge handler is sync.
60
+ resetChatContext(runtime, chatId);
39
61
  let backend = null;
40
62
  try {
41
63
  backend = getBackendForChat(chatId);
@@ -28,7 +28,7 @@ import {
28
28
  statusCommandReply,
29
29
  } from "../../presentation/text-commands.js";
30
30
  import { broadcastChatUpdated } from "../chats/chat-wire.js";
31
- import { resetChat, wipeChatConversation } from "../chats/reset.js";
31
+ import { resetChat, handOffChatBackend } from "../chats/reset.js";
32
32
  import { setBackend } from "../surface/models.js";
33
33
  import { broadcastStatus } from "../surface/status.js";
34
34
  import { interruptTurn } from "../turn/turn.js";
@@ -48,7 +48,7 @@ async function model(ctx: NativeCommandContext): Promise<void> {
48
48
  },
49
49
  resetBackend: async () => {
50
50
  const outcome = await resetChatBackend(entry.id, deps);
51
- wipeChatConversation(runtime, entry.id);
51
+ handOffChatBackend(runtime, entry.id);
52
52
  broadcastStatus(runtime);
53
53
  return outcome.text;
54
54
  },
@@ -25,7 +25,7 @@ import { getActiveReasoningLevels } from "../../presentation/reasoning-levels.js
25
25
  import { broadcastChatUpdated } from "../chats/chat-wire.js";
26
26
  import { emitSystem } from "../turn/emit.js";
27
27
  import type { BackendOption, ModelOption } from "../protocol.js";
28
- import { wipeChatConversation } from "../chats/reset.js";
28
+ import { handOffChatBackend } from "../chats/reset.js";
29
29
  import type { NativeRuntime } from "../runtime.js";
30
30
  import { broadcastStatus } from "./status.js";
31
31
 
@@ -182,11 +182,11 @@ export async function setBackend(
182
182
  }
183
183
 
184
184
  setChatBackend(chatId, target);
185
- wipeChatConversation(runtime, chatId);
185
+ handOffChatBackend(runtime, chatId);
186
186
  emitSystem(
187
187
  runtime,
188
188
  entry,
189
- `Switched to ${target} — starting a fresh conversation.`,
189
+ `Switched to ${target} — new session, chat history kept.`,
190
190
  );
191
191
  broadcastChatUpdated(runtime, entry);
192
192
  broadcastStatus(runtime);
@@ -9,7 +9,6 @@
9
9
  import {
10
10
  recordTurnMeta as storeRecordTurnMeta,
11
11
  getTurnMeta as storeGetTurnMeta,
12
- clearTurnMeta as storeClearTurnMeta,
13
12
  } from "../../../storage/turn-meta.js";
14
13
  import type { ClientToolCall } from "../protocol.js";
15
14
 
@@ -33,8 +32,3 @@ export function recordTurnMeta(
33
32
  export function getTurnMeta(chatId: string, msgId: string): TurnMeta | null {
34
33
  return storeGetTurnMeta<TurnMeta>(chatId, msgId);
35
34
  }
36
-
37
- /** Forget a chat entirely (chat deleted / history cleared). */
38
- export function clearTurnMeta(chatId: string): void {
39
- storeClearTurnMeta(chatId);
40
- }
@@ -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.
@@ -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":
@@ -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. */
@@ -131,11 +131,19 @@ export function insertMany(
131
131
  });
132
132
  }
133
133
 
134
- /** Most-recent `limit` messages, in chronological order. */
135
- export function recent(chatId: string, limit: number): HistoryMessage[] {
134
+ /**
135
+ * Most-recent `limit` messages, in chronological order. `floorId` is the
136
+ * chat's context-reset marker: only rows with a larger id are returned
137
+ * (0 = every row).
138
+ */
139
+ export function recent(
140
+ chatId: string,
141
+ limit: number,
142
+ floorId = 0,
143
+ ): HistoryMessage[] {
136
144
  const rows = getDatabase()
137
145
  .prepare(historySql.recent)
138
- .all(chatId, limit) as Row[];
146
+ .all(chatId, floorId, limit) as Row[];
139
147
  return rows.reverse().map(rowToMessage);
140
148
  }
141
149
 
@@ -147,10 +155,11 @@ export function recentBefore(
147
155
  chatId: string,
148
156
  beforeMsgId: number,
149
157
  limit: number,
158
+ floorId = 0,
150
159
  ): HistoryMessage[] {
151
160
  const rows = getDatabase()
152
161
  .prepare(historySql.recentBefore)
153
- .all(chatId, beforeMsgId, limit) as Row[];
162
+ .all(chatId, beforeMsgId, floorId, limit) as Row[];
154
163
  return rows.reverse().map(rowToMessage);
155
164
  }
156
165
 
@@ -158,10 +167,11 @@ export function recentBeforeTime(
158
167
  chatId: string,
159
168
  beforeTs: number,
160
169
  limit: number,
170
+ floorId = 0,
161
171
  ): HistoryMessage[] {
162
172
  const rows = getDatabase()
163
173
  .prepare(historySql.recentBeforeTime)
164
- .all(chatId, beforeTs, limit) as Row[];
174
+ .all(chatId, beforeTs, floorId, limit) as Row[];
165
175
  return rows.reverse().map(rowToMessage);
166
176
  }
167
177
 
@@ -173,8 +183,66 @@ export function setFilePath(
173
183
  getDatabase().prepare(historySql.setFilePath).run(filePath, chatId, msgId);
174
184
  }
175
185
 
176
- export function deleteChat(chatId: string): void {
177
- getDatabase().prepare(historySql.deleteChat).run(chatId);
186
+ /**
187
+ * Hard-delete a chat's rows and its state. Only the operator's explicit
188
+ * purge reaches this (history.ts purgeChatHistory). Returns rows deleted.
189
+ */
190
+ export function purgeChat(chatId: string): number {
191
+ return inTransaction(() => {
192
+ const db = getDatabase();
193
+ const result = db.prepare(historySql.purgeChat).run(chatId) as {
194
+ changes: number | bigint;
195
+ };
196
+ db.prepare(historySql.purgeChatState).run(chatId);
197
+ return Number(result.changes);
198
+ });
199
+ }
200
+
201
+ /** A chat's soft-reset / soft-delete state. */
202
+ export type ChatHistoryState = {
203
+ /** Rows with an id at or under this predate the last context reset. */
204
+ clearedThroughId: number;
205
+ clearedAt?: number;
206
+ hiddenAt?: number;
207
+ };
208
+
209
+ export function chatState(chatId: string): ChatHistoryState | undefined {
210
+ const row = getDatabase().prepare(historySql.chatState).get(chatId) as
211
+ | {
212
+ cleared_through_id: number;
213
+ cleared_at: number | null;
214
+ hidden_at: number | null;
215
+ }
216
+ | undefined;
217
+ if (!row) return undefined;
218
+ return {
219
+ clearedThroughId: row.cleared_through_id,
220
+ clearedAt: row.cleared_at ?? undefined,
221
+ hiddenAt: row.hidden_at ?? undefined,
222
+ };
223
+ }
224
+
225
+ export function markCleared(chatId: string, at: number): void {
226
+ getDatabase().prepare(historySql.markCleared).run(chatId, chatId, at);
227
+ }
228
+
229
+ export function markHidden(chatId: string, at: number): void {
230
+ getDatabase().prepare(historySql.markHidden).run(chatId, chatId, at, at);
231
+ }
232
+
233
+ export type HiddenChat = { chatId: string; hiddenAt: number; total: number };
234
+
235
+ export function hiddenChats(): HiddenChat[] {
236
+ const rows = getDatabase().prepare(historySql.hiddenChats).all() as Array<{
237
+ chat_id: string;
238
+ hidden_at: number;
239
+ total: number;
240
+ }>;
241
+ return rows.map((r) => ({
242
+ chatId: r.chat_id,
243
+ hiddenAt: r.hidden_at,
244
+ total: r.total,
245
+ }));
178
246
  }
179
247
 
180
248
  /**
@@ -9,10 +9,13 @@ INSERT OR IGNORE INTO history_messages
9
9
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10
10
 
11
11
  -- name: recent
12
+ -- The `id > ?` floor is the chat's context-reset marker (0 for none; see
13
+ -- chatState below): rows at or under it stay stored and searchable but
14
+ -- are no longer the chat's current conversation.
12
15
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
13
16
  timestamp, media_type, sticker_file_id, file_path, attachments
14
17
  FROM history_messages
15
- WHERE chat_id = ? ORDER BY id DESC LIMIT ?
18
+ WHERE chat_id = ? AND id > ? ORDER BY id DESC LIMIT ?
16
19
 
17
20
  -- name: recentBefore
18
21
  -- Scroll-back pagination: the window of messages strictly older than a
@@ -20,7 +23,7 @@ WHERE chat_id = ? ORDER BY id DESC LIMIT ?
20
23
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
21
24
  timestamp, media_type, sticker_file_id, file_path, attachments
22
25
  FROM history_messages
23
- WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
26
+ WHERE chat_id = ? AND msg_id < ? AND id > ? ORDER BY id DESC LIMIT ?
24
27
 
25
28
  -- name: recentBeforeTime
26
29
  -- Time-cursor variant of recentBefore for the read_history `before` date
@@ -28,14 +31,52 @@ WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
28
31
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
29
32
  timestamp, media_type, sticker_file_id, file_path, attachments
30
33
  FROM history_messages
31
- WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?
34
+ WHERE chat_id = ? AND timestamp < ? AND id > ? ORDER BY id DESC LIMIT ?
32
35
 
33
36
  -- name: setFilePath
34
37
  UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?
35
38
 
36
- -- name: deleteChat
39
+ -- name: purgeChat
40
+ -- The ONLY statement that deletes history rows. Reached solely through the
41
+ -- operator's explicit `talon history purge` (history.ts purgeChatHistory);
42
+ -- resets, backend switches and chat deletion never delete rows.
37
43
  DELETE FROM history_messages WHERE chat_id = ?
38
44
 
45
+ -- name: purgeChatState
46
+ DELETE FROM history_chat_state WHERE chat_id = ?
47
+
48
+ -- name: chatState
49
+ SELECT cleared_through_id, cleared_at, hidden_at
50
+ FROM history_chat_state WHERE chat_id = ?
51
+
52
+ -- name: markCleared
53
+ -- Soft reset: move the chat's context floor to its newest stored row. The
54
+ -- rows stay; readers that build the bot's context skip everything at or
55
+ -- under the floor. Parameters: chat_id, chat_id, cleared_at.
56
+ INSERT INTO history_chat_state (chat_id, cleared_through_id, cleared_at)
57
+ VALUES (?, (SELECT COALESCE(MAX(id), 0) FROM history_messages WHERE chat_id = ?), ?)
58
+ ON CONFLICT(chat_id) DO UPDATE SET
59
+ cleared_through_id = excluded.cleared_through_id,
60
+ cleared_at = excluded.cleared_at
61
+
62
+ -- name: markHidden
63
+ -- Soft delete: a chat the user deleted is hidden (and its context floor
64
+ -- moved, so a chat that reappears under the same id starts fresh). The
65
+ -- rows stay. Parameters: chat_id, chat_id, cleared_at, hidden_at.
66
+ INSERT INTO history_chat_state (chat_id, cleared_through_id, cleared_at, hidden_at)
67
+ VALUES (?, (SELECT COALESCE(MAX(id), 0) FROM history_messages WHERE chat_id = ?), ?, ?)
68
+ ON CONFLICT(chat_id) DO UPDATE SET
69
+ cleared_through_id = excluded.cleared_through_id,
70
+ cleared_at = excluded.cleared_at,
71
+ hidden_at = excluded.hidden_at
72
+
73
+ -- name: hiddenChats
74
+ SELECT s.chat_id, s.hidden_at,
75
+ (SELECT COUNT(*) FROM history_messages h WHERE h.chat_id = s.chat_id) AS total
76
+ FROM history_chat_state s
77
+ WHERE s.hidden_at IS NOT NULL
78
+ ORDER BY s.hidden_at DESC
79
+
39
80
  -- name: searchFts
40
81
  -- The match param must already be a valid FTS5 expression
41
82
  -- (see history.ts ftsQuery).
@@ -52,6 +52,19 @@ CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE OF text, sender_name ON his
52
52
  VALUES (new.id, new.text, new.sender_name);
53
53
  END;
54
54
 
55
+ -- Per-chat history state. Chat history is never deleted by a reset, a
56
+ -- backend switch or a chat deletion: a reset records a context floor
57
+ -- (`cleared_through_id`, the newest history_messages.id at reset time) so
58
+ -- the bot's context starts fresh after it while every row stays stored
59
+ -- and searchable; deleting a chat in a client sets `hidden_at`. Only the
60
+ -- operator's explicit `talon history purge` removes rows.
61
+ CREATE TABLE IF NOT EXISTS history_chat_state (
62
+ chat_id TEXT PRIMARY KEY,
63
+ cleared_through_id INTEGER NOT NULL DEFAULT 0,
64
+ cleared_at INTEGER,
65
+ hidden_at INTEGER
66
+ );
67
+
55
68
  -- Typed memory: one row per claim, with an FTS5 index over subject +
56
69
  -- text. Kinds are lifecycles, not labels (docs/memory-persona-plan.md
57
70
  -- §3.1): `directive` is durable human intent, `fact` is durable and
@@ -57,6 +57,19 @@ CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE OF text, sender_name ON his
57
57
  VALUES (new.id, new.text, new.sender_name);
58
58
  END;
59
59
 
60
+ -- Per-chat history state. Chat history is never deleted by a reset, a
61
+ -- backend switch or a chat deletion: a reset records a context floor
62
+ -- (\`cleared_through_id\`, the newest history_messages.id at reset time) so
63
+ -- the bot's context starts fresh after it while every row stays stored
64
+ -- and searchable; deleting a chat in a client sets \`hidden_at\`. Only the
65
+ -- operator's explicit \`talon history purge\` removes rows.
66
+ CREATE TABLE IF NOT EXISTS history_chat_state (
67
+ chat_id TEXT PRIMARY KEY,
68
+ cleared_through_id INTEGER NOT NULL DEFAULT 0,
69
+ cleared_at INTEGER,
70
+ hidden_at INTEGER
71
+ );
72
+
60
73
  -- Typed memory: one row per claim, with an FTS5 index over subject +
61
74
  -- text. Kinds are lifecycles, not labels (docs/memory-persona-plan.md
62
75
  -- §3.1): \`directive\` is durable human intent, \`fact\` is durable and
@@ -518,24 +531,55 @@ export const historySql = {
518
531
  reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path,
519
532
  attachments)
520
533
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
521
- recent: `SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
534
+ recent: `-- The \`id > ?\` floor is the chat's context-reset marker (0 for none; see
535
+ -- chatState below): rows at or under it stay stored and searchable but
536
+ -- are no longer the chat's current conversation.
537
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
522
538
  timestamp, media_type, sticker_file_id, file_path, attachments
523
539
  FROM history_messages
524
- WHERE chat_id = ? ORDER BY id DESC LIMIT ?`,
540
+ WHERE chat_id = ? AND id > ? ORDER BY id DESC LIMIT ?`,
525
541
  recentBefore: `-- Scroll-back pagination: the window of messages strictly older than a
526
542
  -- given msg_id, newest-first (the repository reverses to chronological).
527
543
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
528
544
  timestamp, media_type, sticker_file_id, file_path, attachments
529
545
  FROM history_messages
530
- WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?`,
546
+ WHERE chat_id = ? AND msg_id < ? AND id > ? ORDER BY id DESC LIMIT ?`,
531
547
  recentBeforeTime: `-- Time-cursor variant of recentBefore for the read_history \`before\` date
532
548
  -- parameter: the newest \`limit\` messages strictly older than a timestamp.
533
549
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
534
550
  timestamp, media_type, sticker_file_id, file_path, attachments
535
551
  FROM history_messages
536
- WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?`,
552
+ WHERE chat_id = ? AND timestamp < ? AND id > ? ORDER BY id DESC LIMIT ?`,
537
553
  setFilePath: `UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?`,
538
- deleteChat: `DELETE FROM history_messages WHERE chat_id = ?`,
554
+ purgeChat: `-- The ONLY statement that deletes history rows. Reached solely through the
555
+ -- operator's explicit \`talon history purge\` (history.ts purgeChatHistory);
556
+ -- resets, backend switches and chat deletion never delete rows.
557
+ DELETE FROM history_messages WHERE chat_id = ?`,
558
+ purgeChatState: `DELETE FROM history_chat_state WHERE chat_id = ?`,
559
+ chatState: `SELECT cleared_through_id, cleared_at, hidden_at
560
+ FROM history_chat_state WHERE chat_id = ?`,
561
+ markCleared: `-- Soft reset: move the chat's context floor to its newest stored row. The
562
+ -- rows stay; readers that build the bot's context skip everything at or
563
+ -- under the floor. Parameters: chat_id, chat_id, cleared_at.
564
+ INSERT INTO history_chat_state (chat_id, cleared_through_id, cleared_at)
565
+ VALUES (?, (SELECT COALESCE(MAX(id), 0) FROM history_messages WHERE chat_id = ?), ?)
566
+ ON CONFLICT(chat_id) DO UPDATE SET
567
+ cleared_through_id = excluded.cleared_through_id,
568
+ cleared_at = excluded.cleared_at`,
569
+ markHidden: `-- Soft delete: a chat the user deleted is hidden (and its context floor
570
+ -- moved, so a chat that reappears under the same id starts fresh). The
571
+ -- rows stay. Parameters: chat_id, chat_id, cleared_at, hidden_at.
572
+ INSERT INTO history_chat_state (chat_id, cleared_through_id, cleared_at, hidden_at)
573
+ VALUES (?, (SELECT COALESCE(MAX(id), 0) FROM history_messages WHERE chat_id = ?), ?, ?)
574
+ ON CONFLICT(chat_id) DO UPDATE SET
575
+ cleared_through_id = excluded.cleared_through_id,
576
+ cleared_at = excluded.cleared_at,
577
+ hidden_at = excluded.hidden_at`,
578
+ hiddenChats: `SELECT s.chat_id, s.hidden_at,
579
+ (SELECT COUNT(*) FROM history_messages h WHERE h.chat_id = s.chat_id) AS total
580
+ FROM history_chat_state s
581
+ WHERE s.hidden_at IS NOT NULL
582
+ ORDER BY s.hidden_at DESC`,
539
583
  searchFts: `-- The match param must already be a valid FTS5 expression
540
584
  -- (see history.ts ftsQuery).
541
585
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
@@ -121,7 +121,7 @@ export function getTurnMeta<T>(chatId: string, msgId: string): T | null {
121
121
  }
122
122
  }
123
123
 
124
- /** Forget a chat entirely (chat deleted / history cleared). */
124
+ /** Forget a chat's turn meta — only the operator's history purge does. */
125
125
  export function clearTurnMeta(chatId: string): void {
126
126
  ensureLoaded();
127
127
  try {