talon-agent 5.26.1 → 5.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app.ts +42 -1
  4. package/src/bootstrap.ts +171 -57
  5. package/src/cli/commands/backup.ts +4 -1
  6. package/src/cli/commands/history.ts +152 -0
  7. package/src/cli/index.ts +6 -0
  8. package/src/core/backup/boot/version-checkpoint.ts +199 -0
  9. package/src/core/backup/index.ts +3 -0
  10. package/src/core/backup/passphrase.ts +55 -2
  11. package/src/core/backup/restore.ts +32 -4
  12. package/src/core/backup/scheduler.ts +125 -13
  13. package/src/core/backup/status.ts +26 -3
  14. package/src/core/update/self-update.ts +144 -47
  15. package/src/frontend/discord/admin.ts +2 -2
  16. package/src/frontend/discord/callbacks/components/backend-select.ts +4 -4
  17. package/src/frontend/discord/commands/admin.ts +22 -3
  18. package/src/frontend/discord/commands/definitions.ts +7 -0
  19. package/src/frontend/native/chats/chat-lifecycle.ts +5 -2
  20. package/src/frontend/native/chats/chats.ts +14 -2
  21. package/src/frontend/native/chats/empty-chat-sweep.ts +4 -1
  22. package/src/frontend/native/chats/reset.ts +39 -17
  23. package/src/frontend/native/commands/session.ts +2 -2
  24. package/src/frontend/native/surface/models.ts +3 -3
  25. package/src/frontend/native/turn/turn-meta.ts +0 -6
  26. package/src/frontend/presentation/model-commands.ts +8 -11
  27. package/src/frontend/presentation/session-status.ts +12 -13
  28. package/src/frontend/presentation/text-commands.ts +6 -9
  29. package/src/frontend/telegram/admin/sessions.ts +2 -1
  30. package/src/frontend/telegram/callbacks/model/backend.ts +5 -6
  31. package/src/frontend/telegram/commands/admin.ts +21 -3
  32. package/src/frontend/whatsapp/commands.ts +5 -8
  33. package/src/storage/backup/index.ts +1 -1
  34. package/src/storage/chat-settings.ts +13 -0
  35. package/src/storage/db.ts +5 -0
  36. package/src/storage/history.ts +167 -17
  37. package/src/storage/media-index.ts +4 -3
  38. package/src/storage/repositories/history-repo.ts +75 -7
  39. package/src/storage/sessions.ts +60 -1
  40. package/src/storage/sql/history.sql +45 -4
  41. package/src/storage/sql/schema.sql +13 -0
  42. package/src/storage/sql/statements.generated.ts +49 -5
  43. package/src/storage/turn-meta.ts +1 -1
@@ -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
  /**
@@ -23,6 +23,7 @@ import { recordError } from "../util/watchdog.js";
23
23
  import { files } from "../util/paths.js";
24
24
  import { importLegacyJson } from "./legacy-import.js";
25
25
  import { dbErrorFields } from "./db.js";
26
+ import { kvGet, kvSet } from "./kv.js";
26
27
  import * as repo from "./repositories/sessions-repo.js";
27
28
 
28
29
  export type {
@@ -534,18 +535,76 @@ function removeSessionRow(chatId: string): void {
534
535
  }
535
536
  }
536
537
 
538
+ // ── Replaced-session archive ───────────────────────────────────────────────
539
+
540
+ /** A backend session id a reset replaced — kept so it can be re-linked. */
541
+ export type ArchivedSession = {
542
+ chatId: string;
543
+ sessionId: string;
544
+ /** Why the session was replaced ("reset", "backend-unavailable", …). */
545
+ reason: string;
546
+ /** When it was replaced (ms epoch). */
547
+ at: number;
548
+ turns: number;
549
+ lastModel?: string;
550
+ sessionName?: string;
551
+ };
552
+
553
+ const SESSION_ARCHIVE_KEY = "sessions.archive";
554
+ /** Newest entries kept; the transcripts themselves stay on disk regardless. */
555
+ const SESSION_ARCHIVE_MAX = 500;
556
+
557
+ /**
558
+ * Remember a session id that is about to be dropped. A reset used to
559
+ * forget it outright, leaving the backend transcript on disk with nothing
560
+ * pointing at it; with the id kept, a chat reset by mistake (or by a boot
561
+ * reconcile) can be re-linked to its old conversation. Never throws.
562
+ */
563
+ function archiveSessionId(entry: ArchivedSession): void {
564
+ try {
565
+ const prior = kvGet<ArchivedSession[]>(SESSION_ARCHIVE_KEY);
566
+ const list = Array.isArray(prior) ? prior : [];
567
+ list.push(entry);
568
+ kvSet(SESSION_ARCHIVE_KEY, list.slice(-SESSION_ARCHIVE_MAX));
569
+ } catch (err) {
570
+ logError("sessions", `Failed to archive session chat=${entry.chatId}`, err);
571
+ }
572
+ }
573
+
574
+ /** Archived (replaced) session ids, oldest first; one chat's when given. */
575
+ export function getArchivedSessions(chatId?: string): ArchivedSession[] {
576
+ const list = kvGet<ArchivedSession[]>(SESSION_ARCHIVE_KEY);
577
+ if (!Array.isArray(list)) return [];
578
+ return chatId ? list.filter((e) => e.chatId === chatId) : list;
579
+ }
580
+
537
581
  /**
538
582
  * Reset the chat's conversation state (backend session id, turns, usage)
539
583
  * while carrying its metrics forward. Resets fire on /new, model switches
540
584
  * and error recovery — none of which should erase the chat's accounting
541
585
  * history (that's what makes per-session metrics survive anything short
542
586
  * of deleting the chat). Use deleteSession() to drop the chat entirely.
587
+ *
588
+ * The replaced backend session id is archived (see getArchivedSessions),
589
+ * never just discarded. Talon's own chat history is untouched: clearing
590
+ * it is a separate, explicit call.
543
591
  */
544
- export function resetSession(chatId: string): void {
592
+ export function resetSession(chatId: string, reason = "reset"): void {
545
593
  const session = cache.get(chatId);
546
594
  const turns = session?.turns ?? 0;
547
595
  const name = session?.sessionName;
548
596
  const metrics = session?.metrics;
597
+ if (session?.sessionId) {
598
+ archiveSessionId({
599
+ chatId,
600
+ sessionId: session.sessionId,
601
+ reason,
602
+ at: Date.now(),
603
+ turns,
604
+ ...(session.lastModel ? { lastModel: session.lastModel } : {}),
605
+ ...(name ? { sessionName: name } : {}),
606
+ });
607
+ }
549
608
  removeSessionRow(chatId);
550
609
  const hasHistory =
551
610
  metrics &&
@@ -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 {