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 +1 -0
- package/package.json +1 -1
- package/src/cli/commands/history.ts +152 -0
- package/src/cli/index.ts +6 -0
- package/src/frontend/discord/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
- package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
- package/src/frontend/native/chats/chats.ts +14 -2
- package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
- package/src/frontend/native/chats/reset.ts +39 -17
- package/src/frontend/native/commands/session.ts +2 -2
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/turn/turn-meta.ts +0 -6
- package/src/frontend/presentation/model-commands.ts +8 -11
- package/src/frontend/presentation/session-status.ts +12 -13
- package/src/frontend/presentation/text-commands.ts +6 -9
- package/src/frontend/telegram/admin/sessions.ts +2 -1
- package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
- package/src/frontend/whatsapp/commands.ts +5 -8
- package/src/storage/history.ts +167 -17
- package/src/storage/repositories/history-repo.ts +75 -7
- package/src/storage/sql/history.sql +45 -4
- package/src/storage/sql/schema.sql +13 -0
- package/src/storage/sql/statements.generated.ts +49 -5
- package/src/storage/turn-meta.ts +1 -1
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
|
@@ -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 {
|
|
15
|
+
import { markContextCleared } from "../../storage/history.js";
|
|
16
16
|
import { todayLogDate } from "../../storage/daily-log.js";
|
|
17
17
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
18
18
|
import {
|
|
@@ -87,7 +87,7 @@ export async function handleAdminSubcommand(
|
|
|
87
87
|
const target = rest[0];
|
|
88
88
|
if (!target) return send("Usage: /admin kill <chatId>");
|
|
89
89
|
resetSession(target);
|
|
90
|
-
|
|
90
|
+
markContextCleared(target); // soft reset: history rows are kept
|
|
91
91
|
gateway?.backend?.sessions?.resetChat?.(target);
|
|
92
92
|
return send(`Session ${target} reset.`);
|
|
93
93
|
}
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
rebindChat,
|
|
21
21
|
} from "../../../../core/engine/backend-controller/index.js";
|
|
22
22
|
import { resetSession } from "../../../../storage/sessions.js";
|
|
23
|
-
import { clearHistory } from "../../../../storage/history.js";
|
|
24
23
|
import { resetPulseCheckpoint } from "../../../../core/background/pulse/pulse.js";
|
|
25
24
|
import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
|
|
26
25
|
import { logError } from "../../../../util/log.js";
|
|
@@ -109,12 +108,13 @@ export async function handleBackendSelect(
|
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
// Only now is the switch known to hold. Session state doesn't port across
|
|
112
|
-
// backends, so it goes — but each backend's remembered model pick stays
|
|
111
|
+
// backends, so it goes — but each backend's remembered model pick stays,
|
|
112
|
+
// and chat history is never touched (a switch changes who answers, not
|
|
113
|
+
// what was said).
|
|
113
114
|
// A re-pick of the backend already in use clears nothing: the retry after
|
|
114
115
|
// a timed-out interaction must not cost the session a second time.
|
|
115
116
|
if (!alreadyThere) {
|
|
116
|
-
resetSession(chatId);
|
|
117
|
-
clearHistory(chatId);
|
|
117
|
+
resetSession(chatId, "backend-switch");
|
|
118
118
|
resetPulseCheckpoint(chatId);
|
|
119
119
|
previous?.sessions?.resetChat?.(chatId);
|
|
120
120
|
}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { toClientChat } from "./chat-wire.js";
|
|
7
7
|
import type { ClientChat } from "../protocol.js";
|
|
8
8
|
import type { NativeRuntime } from "../runtime.js";
|
|
9
|
-
import { clearTurnMeta } from "../turn/turn-meta.js";
|
|
10
9
|
|
|
11
10
|
export function createChat(runtime: NativeRuntime, title?: string): ClientChat {
|
|
12
11
|
const entry = runtime.chats.create(title);
|
|
@@ -27,10 +26,14 @@ export function renameChat(
|
|
|
27
26
|
return chat;
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Soft delete (see NativeChats.remove): the chat disappears from every
|
|
31
|
+
* client, but its history rows and their turn meta are kept for the
|
|
32
|
+
* operator — `talon history purge` is the only hard delete.
|
|
33
|
+
*/
|
|
30
34
|
export function deleteChat(runtime: NativeRuntime, chatId: string): boolean {
|
|
31
35
|
const ok = runtime.chats.remove(chatId);
|
|
32
36
|
if (ok) {
|
|
33
|
-
clearTurnMeta(chatId);
|
|
34
37
|
runtime.contextByChat.delete(chatId);
|
|
35
38
|
runtime.queuedByChat.delete(chatId);
|
|
36
39
|
runtime.broadcast({ kind: "chat_deleted", chatId });
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
setSessionName,
|
|
18
18
|
deleteSession,
|
|
19
19
|
} from "../../../storage/sessions.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
getRecentHistory,
|
|
22
|
+
hideChatHistory,
|
|
23
|
+
isChatHistoryHidden,
|
|
24
|
+
} from "../../../storage/history.js";
|
|
21
25
|
import { previewOf } from "../protocol.js";
|
|
22
26
|
|
|
23
27
|
export type ChatEntry = {
|
|
@@ -55,6 +59,8 @@ export class NativeChats {
|
|
|
55
59
|
restore(): void {
|
|
56
60
|
for (const { chatId, info } of getAllSessions()) {
|
|
57
61
|
if (!isNativeChatId(chatId)) continue;
|
|
62
|
+
// Deleted by the user: its rows are kept, the chat stays gone.
|
|
63
|
+
if (isChatHistoryHidden(chatId)) continue;
|
|
58
64
|
const recent = getRecentHistory(chatId, 1);
|
|
59
65
|
const entry: ChatEntry = {
|
|
60
66
|
id: chatId,
|
|
@@ -133,13 +139,19 @@ export class NativeChats {
|
|
|
133
139
|
return entry;
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Delete the chat from the app. A soft delete: the chat leaves every list
|
|
144
|
+
* and its session goes, but its history rows are only hidden, never
|
|
145
|
+
* deleted — the operator can still find them, and only an explicit
|
|
146
|
+
* `talon history purge` removes them for good.
|
|
147
|
+
*/
|
|
136
148
|
remove(id: string): boolean {
|
|
137
149
|
const entry = this.byId.get(id);
|
|
138
150
|
if (!entry) return false;
|
|
139
151
|
this.byId.delete(id);
|
|
140
152
|
this.byNum.delete(entry.numericId);
|
|
141
153
|
deleteSession(id);
|
|
142
|
-
|
|
154
|
+
hideChatHistory(id);
|
|
143
155
|
return true;
|
|
144
156
|
}
|
|
145
157
|
|
|
@@ -28,7 +28,10 @@ function sweepEmptyChats(runtime: NativeRuntime): void {
|
|
|
28
28
|
for (const entry of runtime.chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
|
|
29
29
|
if (isBusy(runtime, entry.id) || runtime.queuedByChat.has(entry.id))
|
|
30
30
|
continue;
|
|
31
|
-
|
|
31
|
+
// Any stored row at all — even from before a reset — means the chat
|
|
32
|
+
// was used; never sweep it.
|
|
33
|
+
if (getRecentHistory(entry.id, 1, { includeCleared: true }).length > 0)
|
|
34
|
+
continue;
|
|
32
35
|
if (deleteChat(runtime, entry.id)) {
|
|
33
36
|
log("native", `Swept empty chat ${entry.id}`);
|
|
34
37
|
}
|
|
@@ -1,41 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chat reset —
|
|
3
|
-
*
|
|
2
|
+
* Chat reset and backend hand-off — what a native chat forgets when its
|
|
3
|
+
* conversation restarts. Neither path deletes chat history: a reset is a
|
|
4
|
+
* soft reset (the context floor moves, the rows stay searchable), and a
|
|
5
|
+
* backend switch leaves history and the transcript exactly as they were.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { resetSession } from "../../../storage/sessions.js";
|
|
7
|
-
import {
|
|
9
|
+
import { markContextCleared } from "../../../storage/history.js";
|
|
8
10
|
import { resetPulseCheckpoint } from "../../../core/background/pulse/pulse.js";
|
|
9
11
|
import { getBackendForChat } from "../../../core/engine/backend-controller/index.js";
|
|
10
12
|
import { broadcastChatUpdated } from "./chat-wire.js";
|
|
11
13
|
import { emitSystem } from "../turn/emit.js";
|
|
12
14
|
import type { NativeRuntime } from "../runtime.js";
|
|
13
|
-
import { clearTurnMeta } from "../turn/turn-meta.js";
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
* reset and a backend switch (sessions aren't portable across backends).
|
|
18
|
-
*/
|
|
19
|
-
export function wipeChatConversation(
|
|
16
|
+
/** Drop the per-process state tied to the chat's backend session. */
|
|
17
|
+
function dropSessionState(
|
|
20
18
|
runtime: NativeRuntime,
|
|
21
19
|
chatId: string,
|
|
20
|
+
reason: string,
|
|
22
21
|
): void {
|
|
23
|
-
resetSession(chatId);
|
|
24
|
-
clearHistory(chatId);
|
|
25
|
-
clearTurnMeta(chatId);
|
|
22
|
+
resetSession(chatId, reason);
|
|
26
23
|
runtime.contextByChat.delete(chatId);
|
|
27
24
|
runtime.queuedByChat.delete(chatId);
|
|
28
25
|
resetPulseCheckpoint(chatId);
|
|
29
26
|
}
|
|
30
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A backend switch: session ids aren't portable across backends, so the
|
|
30
|
+
* session goes (its id is archived by resetSession). History, turn meta
|
|
31
|
+
* and the transcript stay — a switch changes who answers, not what was
|
|
32
|
+
* said.
|
|
33
|
+
*/
|
|
34
|
+
export function handOffChatBackend(
|
|
35
|
+
runtime: NativeRuntime,
|
|
36
|
+
chatId: string,
|
|
37
|
+
): void {
|
|
38
|
+
dropSessionState(runtime, chatId, "backend-switch");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An explicit reset: the session goes and the chat's context starts fresh
|
|
43
|
+
* (a soft reset — every history row and its turn meta is kept, searchable
|
|
44
|
+
* and recoverable; the transcript and the bot's history tools start after
|
|
45
|
+
* the reset point).
|
|
46
|
+
*/
|
|
47
|
+
function resetChatContext(runtime: NativeRuntime, chatId: string): void {
|
|
48
|
+
dropSessionState(runtime, chatId, "reset");
|
|
49
|
+
markContextCleared(chatId);
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
export function resetChat(runtime: NativeRuntime, chatId: string): boolean {
|
|
32
53
|
const entry = runtime.chats.get(chatId);
|
|
33
54
|
if (!entry) return false;
|
|
34
|
-
// Full reset, matching /reset on the other frontends: session,
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
55
|
+
// Full reset, matching /reset on the other frontends: session, the
|
|
56
|
+
// chat's context (soft — the app re-fetches a transcript that starts
|
|
57
|
+
// after the reset; nothing is deleted), pulse checkpoint, and any
|
|
58
|
+
// in-process backend memory. Warm the fresh session in the background —
|
|
59
|
+
// the bridge handler is sync.
|
|
60
|
+
resetChatContext(runtime, chatId);
|
|
39
61
|
let backend = null;
|
|
40
62
|
try {
|
|
41
63
|
backend = getBackendForChat(chatId);
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
statusCommandReply,
|
|
29
29
|
} from "../../presentation/text-commands.js";
|
|
30
30
|
import { broadcastChatUpdated } from "../chats/chat-wire.js";
|
|
31
|
-
import { resetChat,
|
|
31
|
+
import { resetChat, handOffChatBackend } from "../chats/reset.js";
|
|
32
32
|
import { setBackend } from "../surface/models.js";
|
|
33
33
|
import { broadcastStatus } from "../surface/status.js";
|
|
34
34
|
import { interruptTurn } from "../turn/turn.js";
|
|
@@ -48,7 +48,7 @@ async function model(ctx: NativeCommandContext): Promise<void> {
|
|
|
48
48
|
},
|
|
49
49
|
resetBackend: async () => {
|
|
50
50
|
const outcome = await resetChatBackend(entry.id, deps);
|
|
51
|
-
|
|
51
|
+
handOffChatBackend(runtime, entry.id);
|
|
52
52
|
broadcastStatus(runtime);
|
|
53
53
|
return outcome.text;
|
|
54
54
|
},
|
|
@@ -25,7 +25,7 @@ import { getActiveReasoningLevels } from "../../presentation/reasoning-levels.js
|
|
|
25
25
|
import { broadcastChatUpdated } from "../chats/chat-wire.js";
|
|
26
26
|
import { emitSystem } from "../turn/emit.js";
|
|
27
27
|
import type { BackendOption, ModelOption } from "../protocol.js";
|
|
28
|
-
import {
|
|
28
|
+
import { handOffChatBackend } from "../chats/reset.js";
|
|
29
29
|
import type { NativeRuntime } from "../runtime.js";
|
|
30
30
|
import { broadcastStatus } from "./status.js";
|
|
31
31
|
|
|
@@ -182,11 +182,11 @@ export async function setBackend(
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
setChatBackend(chatId, target);
|
|
185
|
-
|
|
185
|
+
handOffChatBackend(runtime, chatId);
|
|
186
186
|
emitSystem(
|
|
187
187
|
runtime,
|
|
188
188
|
entry,
|
|
189
|
-
`Switched to ${target} —
|
|
189
|
+
`Switched to ${target} — new session, chat history kept.`,
|
|
190
190
|
);
|
|
191
191
|
broadcastChatUpdated(runtime, entry);
|
|
192
192
|
broadcastStatus(runtime);
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import {
|
|
10
10
|
recordTurnMeta as storeRecordTurnMeta,
|
|
11
11
|
getTurnMeta as storeGetTurnMeta,
|
|
12
|
-
clearTurnMeta as storeClearTurnMeta,
|
|
13
12
|
} from "../../../storage/turn-meta.js";
|
|
14
13
|
import type { ClientToolCall } from "../protocol.js";
|
|
15
14
|
|
|
@@ -33,8 +32,3 @@ export function recordTurnMeta(
|
|
|
33
32
|
export function getTurnMeta(chatId: string, msgId: string): TurnMeta | null {
|
|
34
33
|
return storeGetTurnMeta<TurnMeta>(chatId, msgId);
|
|
35
34
|
}
|
|
36
|
-
|
|
37
|
-
/** Forget a chat entirely (chat deleted / history cleared). */
|
|
38
|
-
export function clearTurnMeta(chatId: string): void {
|
|
39
|
-
storeClearTurnMeta(chatId);
|
|
40
|
-
}
|
|
@@ -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.
|
|
@@ -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":
|
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. */
|
|
@@ -131,11 +131,19 @@ export function insertMany(
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
package/src/storage/turn-meta.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function getTurnMeta<T>(chatId: string, msgId: string): T | null {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** Forget a chat
|
|
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 {
|