@tznthou/ccrecall 0.1.2

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 (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +290 -0
  3. package/README_ZH.md +288 -0
  4. package/dist/api/routes.d.ts +13 -0
  5. package/dist/api/routes.d.ts.map +1 -0
  6. package/dist/api/routes.js +428 -0
  7. package/dist/api/routes.js.map +1 -0
  8. package/dist/api/server.d.ts +7 -0
  9. package/dist/api/server.d.ts.map +1 -0
  10. package/dist/api/server.js +44 -0
  11. package/dist/api/server.js.map +1 -0
  12. package/dist/cli/daemon.d.ts +35 -0
  13. package/dist/cli/daemon.d.ts.map +1 -0
  14. package/dist/cli/daemon.js +163 -0
  15. package/dist/cli/daemon.js.map +1 -0
  16. package/dist/core/compression.d.ts +37 -0
  17. package/dist/core/compression.d.ts.map +1 -0
  18. package/dist/core/compression.js +129 -0
  19. package/dist/core/compression.js.map +1 -0
  20. package/dist/core/database.d.ts +219 -0
  21. package/dist/core/database.d.ts.map +1 -0
  22. package/dist/core/database.js +1513 -0
  23. package/dist/core/database.js.map +1 -0
  24. package/dist/core/index.d.ts +9 -0
  25. package/dist/core/index.d.ts.map +1 -0
  26. package/dist/core/index.js +8 -0
  27. package/dist/core/index.js.map +1 -0
  28. package/dist/core/indexer.d.ts +15 -0
  29. package/dist/core/indexer.d.ts.map +1 -0
  30. package/dist/core/indexer.js +224 -0
  31. package/dist/core/indexer.js.map +1 -0
  32. package/dist/core/lint.d.ts +17 -0
  33. package/dist/core/lint.d.ts.map +1 -0
  34. package/dist/core/lint.js +51 -0
  35. package/dist/core/lint.js.map +1 -0
  36. package/dist/core/log-safe.d.ts +6 -0
  37. package/dist/core/log-safe.d.ts.map +1 -0
  38. package/dist/core/log-safe.js +11 -0
  39. package/dist/core/log-safe.js.map +1 -0
  40. package/dist/core/maintenance-coordinator.d.ts +26 -0
  41. package/dist/core/maintenance-coordinator.d.ts.map +1 -0
  42. package/dist/core/maintenance-coordinator.js +67 -0
  43. package/dist/core/maintenance-coordinator.js.map +1 -0
  44. package/dist/core/memory-service.d.ts +31 -0
  45. package/dist/core/memory-service.d.ts.map +1 -0
  46. package/dist/core/memory-service.js +47 -0
  47. package/dist/core/memory-service.js.map +1 -0
  48. package/dist/core/parser.d.ts +17 -0
  49. package/dist/core/parser.d.ts.map +1 -0
  50. package/dist/core/parser.js +205 -0
  51. package/dist/core/parser.js.map +1 -0
  52. package/dist/core/scanner.d.ts +8 -0
  53. package/dist/core/scanner.d.ts.map +1 -0
  54. package/dist/core/scanner.js +123 -0
  55. package/dist/core/scanner.js.map +1 -0
  56. package/dist/core/summarizer.d.ts +12 -0
  57. package/dist/core/summarizer.d.ts.map +1 -0
  58. package/dist/core/summarizer.js +430 -0
  59. package/dist/core/summarizer.js.map +1 -0
  60. package/dist/core/topic-extractor.d.ts +11 -0
  61. package/dist/core/topic-extractor.d.ts.map +1 -0
  62. package/dist/core/topic-extractor.js +51 -0
  63. package/dist/core/topic-extractor.js.map +1 -0
  64. package/dist/core/types.d.ts +329 -0
  65. package/dist/core/types.d.ts.map +1 -0
  66. package/dist/core/types.js +12 -0
  67. package/dist/core/types.js.map +1 -0
  68. package/dist/core/watcher.d.ts +30 -0
  69. package/dist/core/watcher.d.ts.map +1 -0
  70. package/dist/core/watcher.js +146 -0
  71. package/dist/core/watcher.js.map +1 -0
  72. package/dist/index.d.ts +3 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +116 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/mcp/server.d.ts +3 -0
  77. package/dist/mcp/server.d.ts.map +1 -0
  78. package/dist/mcp/server.js +32 -0
  79. package/dist/mcp/server.js.map +1 -0
  80. package/dist/mcp/tools.d.ts +39 -0
  81. package/dist/mcp/tools.d.ts.map +1 -0
  82. package/dist/mcp/tools.js +215 -0
  83. package/dist/mcp/tools.js.map +1 -0
  84. package/hooks/README.md +78 -0
  85. package/hooks/session-end.mjs +92 -0
  86. package/hooks/session-start.mjs +112 -0
  87. package/package.json +54 -0
@@ -0,0 +1,1513 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import BetterSqlite3 from 'better-sqlite3';
3
+ import { mkdirSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { scrubErrorMessage } from './log-safe.js';
6
+ /** sessions 資料表的完整欄位 SELECT 子句(getSessions / getSessionById 共用) */
7
+ const SESSION_SELECT_COLUMNS = `id, project_id, title, message_count, started_at, ended_at, archived,
8
+ summary_text, intent_text, outcome_status, duration_seconds, active_duration_seconds, summary_version,
9
+ tags, files_touched, tools_used, total_input_tokens, total_output_tokens`;
10
+ function mapSessionRow(r) {
11
+ return {
12
+ id: r.id,
13
+ projectId: r.project_id,
14
+ title: r.title,
15
+ messageCount: r.message_count,
16
+ startedAt: r.started_at,
17
+ endedAt: r.ended_at,
18
+ archived: r.archived === 1,
19
+ summaryText: r.summary_text,
20
+ intentText: r.intent_text,
21
+ outcomeStatus: r.outcome_status ?? null,
22
+ durationSeconds: r.duration_seconds,
23
+ activeDurationSeconds: r.active_duration_seconds,
24
+ summaryVersion: r.summary_version,
25
+ tags: r.tags,
26
+ filesTouched: r.files_touched,
27
+ toolsUsed: r.tools_used,
28
+ totalInputTokens: r.total_input_tokens,
29
+ totalOutputTokens: r.total_output_tokens,
30
+ };
31
+ }
32
+ function mapMemoryRow(r) {
33
+ return {
34
+ id: r.id,
35
+ sessionId: r.session_id,
36
+ messageId: r.message_id,
37
+ content: r.content,
38
+ type: r.type,
39
+ confidence: r.confidence,
40
+ createdAt: r.created_at,
41
+ };
42
+ }
43
+ function mapTopicRow(r) {
44
+ return {
45
+ topicKey: r.topic_key,
46
+ projectId: r.project_id,
47
+ mentionCount: r.mention_count,
48
+ lastTouched: r.last_touched,
49
+ };
50
+ }
51
+ function mapCheckpointRow(r) {
52
+ return {
53
+ id: r.id,
54
+ sessionId: r.session_id,
55
+ projectId: r.project_id,
56
+ snapshotText: r.snapshot_text,
57
+ createdAt: r.created_at,
58
+ };
59
+ }
60
+ /** 重建 message_content / message_archive 表,修正 FK 指向 messages */
61
+ function rebuildSideTables(db) {
62
+ db.exec(`
63
+ ALTER TABLE message_content RENAME TO message_content_old;
64
+ CREATE TABLE message_content (
65
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
66
+ content_json TEXT
67
+ );
68
+ INSERT INTO message_content SELECT * FROM message_content_old;
69
+ DROP TABLE message_content_old;
70
+
71
+ ALTER TABLE message_archive RENAME TO message_archive_old;
72
+ CREATE TABLE message_archive (
73
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
74
+ raw_json TEXT
75
+ );
76
+ INSERT INTO message_archive SELECT * FROM message_archive_old;
77
+ DROP TABLE message_archive_old;
78
+ `);
79
+ }
80
+ /** 所有 migrations,依 version 遞增排列 */
81
+ const migrations = [
82
+ {
83
+ version: 1,
84
+ description: 'split messages: content_json → message_content, raw_json → message_archive',
85
+ up: (db) => {
86
+ // 檢查是否為舊 schema(messages 表有 content_json 欄位)
87
+ // 新建的 DB 已在 initSchema 用 slim schema,不需搬移
88
+ const cols = db.prepare("PRAGMA table_info(messages)").all();
89
+ const hasContentJson = cols.some(c => c.name === 'content_json');
90
+ if (!hasContentJson)
91
+ return; // 新 DB,不需 migration
92
+ // 1. 建立新表(IF NOT EXISTS 因為 initSchema 可能已建過空表)
93
+ db.exec(`
94
+ CREATE TABLE IF NOT EXISTS message_content (
95
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
96
+ content_json TEXT
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS message_archive (
100
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
101
+ raw_json TEXT
102
+ );
103
+ `);
104
+ // 2. 批量搬移資料(OR IGNORE 防止 initSchema 先建表後殘留資料導致 UNIQUE 衝突)
105
+ db.exec(`
106
+ INSERT OR IGNORE INTO message_content (message_id, content_json)
107
+ SELECT id, content_json FROM messages WHERE content_json IS NOT NULL;
108
+
109
+ INSERT OR IGNORE INTO message_archive (message_id, raw_json)
110
+ SELECT id, raw_json FROM messages WHERE raw_json IS NOT NULL;
111
+ `);
112
+ // 3. Rename + recreate slim messages table
113
+ // 注意:ALTER TABLE RENAME 會自動更新所有 FK references 指向新名稱
114
+ // 所以 message_content/message_archive 的 FK 會被改成指向 messages_old
115
+ // 必須在 DROP messages_old 後重建這兩張表
116
+ db.exec(`
117
+ ALTER TABLE messages RENAME TO messages_old;
118
+
119
+ CREATE TABLE messages (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ session_id TEXT NOT NULL REFERENCES sessions(id),
122
+ type TEXT NOT NULL,
123
+ role TEXT,
124
+ content_text TEXT,
125
+ has_tool_use INTEGER DEFAULT 0,
126
+ has_tool_result INTEGER DEFAULT 0,
127
+ tool_names TEXT,
128
+ timestamp TEXT,
129
+ sequence INTEGER NOT NULL,
130
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
131
+ );
132
+
133
+ INSERT INTO messages (id, session_id, type, role, content_text, has_tool_use, has_tool_result, tool_names, timestamp, sequence, created_at)
134
+ SELECT id, session_id, type, role, content_text, has_tool_use, has_tool_result, tool_names, timestamp, sequence, created_at
135
+ FROM messages_old;
136
+
137
+ DROP TABLE messages_old;
138
+
139
+ CREATE INDEX idx_messages_session ON messages(session_id, sequence);
140
+ `);
141
+ // 4. 重建 message_content / message_archive(FK 被 RENAME 改壞了)
142
+ rebuildSideTables(db);
143
+ // 5. 重建 FTS5 triggers(舊 trigger 隨 messages_old 一起消失了)
144
+ db.exec(`
145
+ DROP TRIGGER IF EXISTS messages_ai;
146
+ DROP TRIGGER IF EXISTS messages_ad;
147
+
148
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
149
+ INSERT INTO messages_fts(rowid, content_text) VALUES (new.id, new.content_text);
150
+ END;
151
+
152
+ CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
153
+ INSERT INTO messages_fts(messages_fts, rowid, content_text) VALUES ('delete', old.id, old.content_text);
154
+ END;
155
+ `);
156
+ },
157
+ },
158
+ {
159
+ version: 2,
160
+ description: 'add archived column to sessions',
161
+ up: (db) => {
162
+ const cols = db.prepare("PRAGMA table_info(sessions)").all();
163
+ if (cols.some(c => c.name === 'archived'))
164
+ return; // 新 DB 已有
165
+ db.exec('ALTER TABLE sessions ADD COLUMN archived INTEGER DEFAULT 0');
166
+ },
167
+ },
168
+ // v3 被開發期間的臨時 migration 佔用(已 apply 到生產 DB),故跳至 v4
169
+ {
170
+ version: 4,
171
+ description: 'fix FK references broken by v1 rename (message_content/archive → messages)',
172
+ up: (db) => {
173
+ // v1 的 ALTER TABLE messages RENAME TO messages_old 會讓
174
+ // message_content/archive 的 FK 自動被 SQLite 改成指向 messages_old
175
+ const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name='message_content'").get()?.sql ?? '';
176
+ if (!schema.includes('messages_old'))
177
+ return; // FK 已正確
178
+ rebuildSideTables(db);
179
+ },
180
+ },
181
+ {
182
+ version: 5,
183
+ description: 'add session summary columns (summary_text, tags, files_touched, tools_used)',
184
+ up: (db) => {
185
+ const cols = db.prepare('PRAGMA table_info(sessions)').all();
186
+ if (cols.some(c => c.name === 'summary_text'))
187
+ return;
188
+ db.exec(`
189
+ ALTER TABLE sessions ADD COLUMN summary_text TEXT;
190
+ ALTER TABLE sessions ADD COLUMN tags TEXT;
191
+ ALTER TABLE sessions ADD COLUMN files_touched TEXT;
192
+ ALTER TABLE sessions ADD COLUMN tools_used TEXT;
193
+ `);
194
+ // 清空 file_mtime 強制所有既有 session 在下次 indexer run 時 re-index
195
+ db.exec("UPDATE sessions SET file_mtime = NULL");
196
+ },
197
+ },
198
+ {
199
+ version: 6,
200
+ description: 'add sessions_fts for session-level search (title, tags, files_touched, summary_text)',
201
+ up: (db) => {
202
+ const exists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions_fts'").get();
203
+ if (exists)
204
+ return;
205
+ db.exec(`
206
+ CREATE VIRTUAL TABLE sessions_fts USING fts5(
207
+ title,
208
+ tags,
209
+ files_touched,
210
+ summary_text,
211
+ content='sessions',
212
+ content_rowid='rowid',
213
+ tokenize='unicode61'
214
+ );
215
+ `);
216
+ // 回填既有 session 資料
217
+ db.exec(`
218
+ INSERT INTO sessions_fts(rowid, title, tags, files_touched, summary_text)
219
+ SELECT rowid, COALESCE(title,''), COALESCE(tags,''), COALESCE(files_touched,''), COALESCE(summary_text,'')
220
+ FROM sessions;
221
+ `);
222
+ },
223
+ },
224
+ {
225
+ version: 7,
226
+ description: 'add token usage columns to messages and sessions (Phase 2.5 Context Budget)',
227
+ up: (db) => {
228
+ const msgCols = db.prepare('PRAGMA table_info(messages)').all();
229
+ if (msgCols.some(c => c.name === 'input_tokens'))
230
+ return;
231
+ db.exec(`
232
+ ALTER TABLE messages ADD COLUMN input_tokens INTEGER;
233
+ ALTER TABLE messages ADD COLUMN output_tokens INTEGER;
234
+ ALTER TABLE messages ADD COLUMN cache_read_tokens INTEGER;
235
+ ALTER TABLE messages ADD COLUMN cache_creation_tokens INTEGER;
236
+ ALTER TABLE messages ADD COLUMN model TEXT;
237
+ ALTER TABLE sessions ADD COLUMN total_input_tokens INTEGER;
238
+ ALTER TABLE sessions ADD COLUMN total_output_tokens INTEGER;
239
+ `);
240
+ // 清空 file_mtime 強制 re-index,讓既有 session 填入 token 資料
241
+ db.exec("UPDATE sessions SET file_mtime = NULL");
242
+ },
243
+ },
244
+ {
245
+ version: 8,
246
+ description: 'Phase 3: structured summary + session_files reverse index',
247
+ up: (db) => {
248
+ const cols = db.prepare('PRAGMA table_info(sessions)').all();
249
+ if (!cols.some(c => c.name === 'intent_text')) {
250
+ db.exec(`
251
+ ALTER TABLE sessions ADD COLUMN intent_text TEXT;
252
+ ALTER TABLE sessions ADD COLUMN outcome_status TEXT;
253
+ ALTER TABLE sessions ADD COLUMN outcome_signals TEXT;
254
+ ALTER TABLE sessions ADD COLUMN duration_seconds INTEGER;
255
+ ALTER TABLE sessions ADD COLUMN summary_version INTEGER;
256
+ `);
257
+ }
258
+ // session_files 反向索引表
259
+ db.exec(`
260
+ CREATE TABLE IF NOT EXISTS session_files (
261
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
262
+ file_path TEXT NOT NULL,
263
+ operation TEXT NOT NULL,
264
+ count INTEGER DEFAULT 1,
265
+ first_seen_seq INTEGER,
266
+ last_seen_seq INTEGER,
267
+ PRIMARY KEY (session_id, file_path, operation)
268
+ );
269
+ CREATE INDEX IF NOT EXISTS idx_session_files_path ON session_files(file_path);
270
+ CREATE INDEX IF NOT EXISTS idx_session_files_session ON session_files(session_id);
271
+ `);
272
+ // 清空 file_mtime 強制全量 re-index
273
+ db.exec("UPDATE sessions SET file_mtime = NULL");
274
+ },
275
+ },
276
+ {
277
+ version: 9,
278
+ description: 'rebuild sessions_fts with intent_text column for search enhancement',
279
+ up: (db) => {
280
+ db.exec(`
281
+ DROP TABLE IF EXISTS sessions_fts;
282
+ CREATE VIRTUAL TABLE sessions_fts USING fts5(
283
+ title,
284
+ tags,
285
+ files_touched,
286
+ summary_text,
287
+ intent_text,
288
+ content='sessions',
289
+ content_rowid='rowid',
290
+ tokenize='unicode61'
291
+ );
292
+ INSERT INTO sessions_fts(rowid, title, tags, files_touched, summary_text, intent_text)
293
+ SELECT rowid, COALESCE(title,''), COALESCE(tags,''), COALESCE(files_touched,''),
294
+ COALESCE(summary_text,''), COALESCE(intent_text,'')
295
+ FROM sessions;
296
+ `);
297
+ },
298
+ },
299
+ {
300
+ version: 10,
301
+ description: 'add uuid column to messages for cross-session dedup (resumed sessions)',
302
+ up: (db) => {
303
+ const cols = db.prepare('PRAGMA table_info(messages)').all();
304
+ if (cols.some(c => c.name === 'uuid'))
305
+ return;
306
+ db.exec(`
307
+ ALTER TABLE messages ADD COLUMN uuid TEXT;
308
+ CREATE INDEX idx_messages_uuid ON messages(uuid);
309
+ `);
310
+ // 強制全量 re-index,讓既有 messages 填入 uuid
311
+ db.exec("UPDATE sessions SET file_mtime = NULL");
312
+ },
313
+ },
314
+ {
315
+ version: 11,
316
+ description: 'add active_duration_seconds column to sessions',
317
+ up: (db) => {
318
+ const cols = db.prepare('PRAGMA table_info(sessions)').all();
319
+ if (cols.some(c => c.name === 'active_duration_seconds'))
320
+ return;
321
+ db.exec('ALTER TABLE sessions ADD COLUMN active_duration_seconds INTEGER');
322
+ // 強制全量 re-index,讓既有 sessions 填入 active_duration_seconds
323
+ db.exec("UPDATE sessions SET file_mtime = NULL");
324
+ },
325
+ },
326
+ {
327
+ version: 12,
328
+ description: 'add subagent_sessions table for subagent file scanning',
329
+ up: (db) => {
330
+ db.exec(`
331
+ CREATE TABLE IF NOT EXISTS subagent_sessions (
332
+ id TEXT PRIMARY KEY,
333
+ parent_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
334
+ agent_type TEXT,
335
+ file_path TEXT NOT NULL,
336
+ file_size INTEGER,
337
+ file_mtime TEXT,
338
+ message_count INTEGER DEFAULT 0,
339
+ started_at TEXT,
340
+ ended_at TEXT,
341
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
342
+ );
343
+ CREATE INDEX IF NOT EXISTS idx_subagent_parent ON subagent_sessions(parent_session_id);
344
+ `);
345
+ },
346
+ },
347
+ {
348
+ version: 13,
349
+ description: 'force re-index to fix UUID self-dedup bug (v12 re-index dropped messages with uuid)',
350
+ up: (db) => {
351
+ db.exec("UPDATE sessions SET file_mtime = NULL");
352
+ },
353
+ },
354
+ {
355
+ version: 14,
356
+ description: 'force re-index for requestId token dedup (fix ~2.3x inflated token counts)',
357
+ up: (db) => {
358
+ db.exec("UPDATE sessions SET file_mtime = NULL");
359
+ db.exec("UPDATE subagent_sessions SET file_mtime = NULL");
360
+ },
361
+ },
362
+ {
363
+ version: 15,
364
+ description: 'force re-index to strip system XML from contentText',
365
+ up: (db) => {
366
+ db.exec("UPDATE sessions SET file_mtime = NULL");
367
+ db.exec("UPDATE subagent_sessions SET file_mtime = NULL");
368
+ },
369
+ },
370
+ {
371
+ version: 16,
372
+ description: 'add memories table + memories_fts for Phase 2 memory layer',
373
+ up: (db) => {
374
+ db.exec(`
375
+ CREATE TABLE IF NOT EXISTS memories (
376
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
377
+ session_id TEXT,
378
+ message_id TEXT,
379
+ content TEXT NOT NULL,
380
+ type TEXT NOT NULL,
381
+ confidence REAL NOT NULL DEFAULT 0.8,
382
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
383
+ );
384
+
385
+ CREATE VIRTUAL TABLE memories_fts USING fts5(
386
+ content,
387
+ content='memories',
388
+ content_rowid='id',
389
+ tokenize='unicode61'
390
+ );
391
+
392
+ CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
393
+ INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
394
+ END;
395
+
396
+ CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
397
+ INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
398
+ END;
399
+ `);
400
+ },
401
+ },
402
+ {
403
+ version: 17,
404
+ description: 'Phase 3a — knowledge_map + session_topics + memory_topics + session_checkpoints',
405
+ up: (db) => {
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS knowledge_map (
408
+ topic_key TEXT NOT NULL,
409
+ project_id TEXT NOT NULL REFERENCES projects(id),
410
+ mention_count INTEGER DEFAULT 0,
411
+ last_touched TEXT NOT NULL DEFAULT (datetime('now')),
412
+ PRIMARY KEY (topic_key, project_id)
413
+ );
414
+
415
+ CREATE TABLE IF NOT EXISTS session_topics (
416
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
417
+ topic_key TEXT NOT NULL,
418
+ project_id TEXT NOT NULL,
419
+ PRIMARY KEY (session_id, topic_key)
420
+ );
421
+
422
+ CREATE TABLE IF NOT EXISTS memory_topics (
423
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
424
+ topic_key TEXT NOT NULL,
425
+ project_id TEXT NOT NULL,
426
+ PRIMARY KEY (memory_id, topic_key)
427
+ );
428
+
429
+ CREATE TABLE IF NOT EXISTS session_checkpoints (
430
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
431
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
432
+ project_id TEXT NOT NULL,
433
+ snapshot_text TEXT NOT NULL,
434
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
435
+ );
436
+
437
+ CREATE INDEX IF NOT EXISTS idx_knowledge_map_project ON knowledge_map(project_id, last_touched DESC);
438
+ CREATE INDEX IF NOT EXISTS idx_session_topics_topic ON session_topics(topic_key, project_id);
439
+ CREATE INDEX IF NOT EXISTS idx_memory_topics_topic ON memory_topics(topic_key, project_id);
440
+ CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON session_checkpoints(session_id, created_at DESC);
441
+ `);
442
+ // 強制 reindex:existing sessions 需 re-run indexer 才會 populate session_topics + knowledge_map
443
+ db.exec("UPDATE sessions SET file_mtime = NULL");
444
+ },
445
+ },
446
+ {
447
+ version: 18,
448
+ description: 'Phase 4 — memories lifecycle: access tracking + compression metadata + project_id denormalize + FTS update trigger',
449
+ up: (db) => {
450
+ db.exec(`
451
+ ALTER TABLE memories ADD COLUMN last_accessed TEXT;
452
+ ALTER TABLE memories ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
453
+ ALTER TABLE memories ADD COLUMN compressed_at TEXT;
454
+ ALTER TABLE memories ADD COLUMN compression_level INTEGER NOT NULL DEFAULT 0;
455
+ ALTER TABLE memories ADD COLUMN project_id TEXT;
456
+ `);
457
+ // Backfill session-backed memories' project_id from sessions table.
458
+ // Manual memories (session_id IS NULL) remain NULL until Phase 4b wires the MCP param.
459
+ db.exec(`
460
+ UPDATE memories
461
+ SET project_id = (
462
+ SELECT project_id FROM sessions WHERE sessions.id = memories.session_id
463
+ )
464
+ WHERE session_id IS NOT NULL
465
+ `);
466
+ // AFTER UPDATE trigger: when memory.content is rewritten (e.g. compression),
467
+ // keep memories_fts in sync. Existing _ai / _ad triggers only cover insert/delete.
468
+ db.exec(`
469
+ CREATE TRIGGER memories_au AFTER UPDATE OF content ON memories BEGIN
470
+ INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
471
+ INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
472
+ END;
473
+ CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_id);
474
+ CREATE INDEX IF NOT EXISTS idx_memories_access ON memories(last_accessed, access_count);
475
+ `);
476
+ },
477
+ },
478
+ ];
479
+ /** MessageRow → Message 轉換 */
480
+ function mapMessageRow(r) {
481
+ return {
482
+ id: r.id,
483
+ sessionId: r.session_id,
484
+ type: r.type,
485
+ role: r.role,
486
+ contentText: r.content_text,
487
+ contentJson: r.content_json,
488
+ hasToolUse: r.has_tool_use === 1,
489
+ hasToolResult: r.has_tool_result === 1,
490
+ toolNames: r.tool_names ? r.tool_names.split(',') : null,
491
+ timestamp: r.timestamp,
492
+ sequence: r.sequence,
493
+ inputTokens: r.input_tokens,
494
+ outputTokens: r.output_tokens,
495
+ cacheReadTokens: r.cache_read_tokens,
496
+ cacheCreationTokens: r.cache_creation_tokens,
497
+ model: r.model,
498
+ };
499
+ }
500
+ export class Database {
501
+ db;
502
+ /** Subagent 排除子查詢:用於所有面向使用者的 query,只顯示主 session */
503
+ static EXCLUDE_SUBAGENTS = 'NOT IN (SELECT id FROM subagent_sessions)';
504
+ /** Phase 4b: SQL snippet for effective confidence with exponential decay and
505
+ * access-extended half-life. Assumes `m` is the memories alias.
506
+ * age_days = julianday(now) − julianday(COALESCE(last_accessed, created_at))
507
+ * half_life = 7 + 7 · min(access_count, 4) days
508
+ * effective = confidence · exp(−ln 2 · age_days / half_life)
509
+ * Use in ORDER BY; requires exp() user function registered in the constructor. */
510
+ static EFFECTIVE_CONFIDENCE = `(
511
+ m.confidence * exp(
512
+ -0.6931471805599453 *
513
+ (julianday('now') - julianday(COALESCE(m.last_accessed, m.created_at))) /
514
+ (7.0 + 7.0 * MIN(m.access_count, 4))
515
+ )
516
+ )`;
517
+ constructor(dbPath) {
518
+ // :memory: 不需要建目錄
519
+ if (dbPath !== ':memory:') {
520
+ mkdirSync(path.dirname(dbPath), { recursive: true });
521
+ }
522
+ this.db = new BetterSqlite3(dbPath);
523
+ this.db.pragma('journal_mode = WAL');
524
+ this.db.pragma('foreign_keys = ON');
525
+ // Phase 4b: register exp() as a user function for effective-confidence decay
526
+ // in ORDER BY. SQLite's built-in math functions require a special compile flag
527
+ // that better-sqlite3 does not enable by default.
528
+ // Return NULL (not NaN) for non-finite inputs so ORDER BY sorts the row last
529
+ // instead of first — SQLite treats NaN REAL values as larger than all finite
530
+ // numbers in DESC ordering, which would let a corrupt row dominate recall.
531
+ this.db.function('exp', { deterministic: true }, (x) => {
532
+ if (typeof x !== 'number' || !Number.isFinite(x))
533
+ return null;
534
+ return Math.exp(x);
535
+ });
536
+ this.initSchema();
537
+ this.runMigrations();
538
+ }
539
+ close() {
540
+ this.db.close();
541
+ }
542
+ /** ⚠️ 測試專用:接受任意 SQL,禁止接到 IPC handler */
543
+ rawAll(sql) {
544
+ return this.db.prepare(sql).all();
545
+ }
546
+ /** ⚠️ 測試專用:執行 DDL/DML,禁止接到 IPC handler */
547
+ rawExec(sql) {
548
+ this.db.exec(sql);
549
+ }
550
+ initSchema() {
551
+ this.db.exec(`
552
+ CREATE TABLE IF NOT EXISTS projects (
553
+ id TEXT PRIMARY KEY,
554
+ display_name TEXT NOT NULL,
555
+ session_count INTEGER DEFAULT 0,
556
+ last_activity_at TEXT,
557
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
558
+ );
559
+
560
+ CREATE TABLE IF NOT EXISTS sessions (
561
+ id TEXT PRIMARY KEY,
562
+ project_id TEXT NOT NULL REFERENCES projects(id),
563
+ title TEXT,
564
+ message_count INTEGER DEFAULT 0,
565
+ file_path TEXT NOT NULL,
566
+ file_size INTEGER,
567
+ file_mtime TEXT,
568
+ started_at TEXT,
569
+ ended_at TEXT,
570
+ archived INTEGER DEFAULT 0,
571
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
572
+ );
573
+
574
+ CREATE TABLE IF NOT EXISTS messages (
575
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
576
+ session_id TEXT NOT NULL REFERENCES sessions(id),
577
+ type TEXT NOT NULL,
578
+ role TEXT,
579
+ content_text TEXT,
580
+ has_tool_use INTEGER DEFAULT 0,
581
+ has_tool_result INTEGER DEFAULT 0,
582
+ tool_names TEXT,
583
+ timestamp TEXT,
584
+ sequence INTEGER NOT NULL,
585
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
586
+ );
587
+
588
+ CREATE TABLE IF NOT EXISTS message_content (
589
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
590
+ content_json TEXT
591
+ );
592
+
593
+ CREATE TABLE IF NOT EXISTS message_archive (
594
+ message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
595
+ raw_json TEXT
596
+ );
597
+
598
+ CREATE TABLE IF NOT EXISTS session_files (
599
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
600
+ file_path TEXT NOT NULL,
601
+ operation TEXT NOT NULL,
602
+ count INTEGER DEFAULT 1,
603
+ first_seen_seq INTEGER,
604
+ last_seen_seq INTEGER,
605
+ PRIMARY KEY (session_id, file_path, operation)
606
+ );
607
+
608
+ CREATE TABLE IF NOT EXISTS subagent_sessions (
609
+ id TEXT PRIMARY KEY,
610
+ parent_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
611
+ agent_type TEXT,
612
+ file_path TEXT NOT NULL,
613
+ file_size INTEGER,
614
+ file_mtime TEXT,
615
+ message_count INTEGER DEFAULT 0,
616
+ started_at TEXT,
617
+ ended_at TEXT,
618
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
619
+ );
620
+
621
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, sequence);
622
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, started_at DESC);
623
+ CREATE INDEX IF NOT EXISTS idx_session_files_path ON session_files(file_path);
624
+ CREATE INDEX IF NOT EXISTS idx_session_files_session ON session_files(session_id);
625
+ CREATE INDEX IF NOT EXISTS idx_subagent_parent ON subagent_sessions(parent_session_id);
626
+ `);
627
+ // FTS5 虛擬表不支援 IF NOT EXISTS,先查 sqlite_master
628
+ const ftsExists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
629
+ if (!ftsExists) {
630
+ this.db.exec(`
631
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
632
+ content_text,
633
+ content='messages',
634
+ content_rowid='id',
635
+ tokenize='unicode61'
636
+ );
637
+
638
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
639
+ INSERT INTO messages_fts(rowid, content_text) VALUES (new.id, new.content_text);
640
+ END;
641
+
642
+ CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
643
+ INSERT INTO messages_fts(messages_fts, rowid, content_text) VALUES ('delete', old.id, old.content_text);
644
+ END;
645
+ `);
646
+ }
647
+ // schema_version 表
648
+ this.db.exec(`
649
+ CREATE TABLE IF NOT EXISTS schema_version (
650
+ version INTEGER PRIMARY KEY,
651
+ applied_at TEXT NOT NULL DEFAULT (datetime('now')),
652
+ description TEXT
653
+ );
654
+ `);
655
+ const hasBaseline = this.db.prepare('SELECT version FROM schema_version WHERE version = 0').get();
656
+ if (!hasBaseline) {
657
+ this.db.prepare("INSERT INTO schema_version (version, description) VALUES (0, 'baseline')").run();
658
+ }
659
+ }
660
+ /** 依序執行尚未套用的 migrations */
661
+ runMigrations() {
662
+ const current = this.getSchemaVersion();
663
+ let migrated = false;
664
+ for (const m of migrations) {
665
+ if (m.version <= current)
666
+ continue;
667
+ const migrate = this.db.transaction(() => {
668
+ m.up(this.db);
669
+ this.db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(m.version, m.description);
670
+ });
671
+ migrate();
672
+ migrated = true;
673
+ }
674
+ if (migrated) {
675
+ this.db.exec('VACUUM');
676
+ }
677
+ }
678
+ /** 取得目前 schema 版本 */
679
+ getSchemaVersion() {
680
+ const row = this.db.prepare('SELECT MAX(version) AS v FROM schema_version').get();
681
+ return row?.v ?? 0;
682
+ }
683
+ // ── Projects ──
684
+ upsertProject(id, displayName) {
685
+ this.db.prepare(`
686
+ INSERT INTO projects (id, display_name)
687
+ VALUES (?, ?)
688
+ ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name
689
+ `).run(id, displayName);
690
+ }
691
+ getMainSessionCount() {
692
+ const row = this.db.prepare(`SELECT COUNT(*) AS c FROM sessions WHERE id ${Database.EXCLUDE_SUBAGENTS}`).get();
693
+ return row.c;
694
+ }
695
+ updateProjectStats(projectId) {
696
+ this.db.prepare(`
697
+ UPDATE projects SET
698
+ session_count = (SELECT COUNT(*) FROM sessions WHERE project_id = ? AND id ${Database.EXCLUDE_SUBAGENTS}),
699
+ last_activity_at = (SELECT MAX(ended_at) FROM sessions WHERE project_id = ? AND id ${Database.EXCLUDE_SUBAGENTS})
700
+ WHERE id = ?
701
+ `).run(projectId, projectId, projectId);
702
+ }
703
+ getProjects() {
704
+ const rows = this.db.prepare('SELECT id, display_name, session_count, last_activity_at FROM projects ORDER BY last_activity_at DESC').all();
705
+ return rows.map(r => ({
706
+ id: r.id,
707
+ displayName: r.display_name,
708
+ sessionCount: r.session_count,
709
+ lastActivityAt: r.last_activity_at,
710
+ }));
711
+ }
712
+ // ── Sessions ──
713
+ getSessionMtime(sessionId) {
714
+ const row = this.db.prepare('SELECT file_mtime FROM sessions WHERE id = ?').get(sessionId);
715
+ return row?.file_mtime ?? null;
716
+ }
717
+ getSessions(projectId) {
718
+ const rows = this.db.prepare(`SELECT ${SESSION_SELECT_COLUMNS}
719
+ FROM sessions
720
+ WHERE project_id = ?
721
+ AND id ${Database.EXCLUDE_SUBAGENTS}
722
+ ORDER BY started_at DESC`).all(projectId);
723
+ return rows.map(mapSessionRow);
724
+ }
725
+ getSessionById(sessionId) {
726
+ const row = this.db.prepare(`SELECT ${SESSION_SELECT_COLUMNS}
727
+ FROM sessions
728
+ WHERE id = ?`).get(sessionId);
729
+ return row ? mapSessionRow(row) : null;
730
+ }
731
+ /** 將 DB 中不在 keepIds 集合的 session 標記為 archived(JSONL 已從磁碟消失),排除 subagent sessions */
732
+ archiveStaleSessionsExcept(keepIds) {
733
+ const allRows = this.db.prepare(`SELECT id FROM sessions WHERE archived = 0 AND id ${Database.EXCLUDE_SUBAGENTS}`).all();
734
+ const archiveStmt = this.db.prepare('UPDATE sessions SET archived = 1 WHERE id = ?');
735
+ const doArchive = this.db.transaction(() => {
736
+ for (const row of allRows) {
737
+ if (!keepIds.has(row.id)) {
738
+ archiveStmt.run(row.id);
739
+ }
740
+ }
741
+ });
742
+ doArchive();
743
+ }
744
+ /** 一次取得所有 session 的 file_mtime + archived 狀態(增量索引批次比對用) */
745
+ getAllSessionMtimes() {
746
+ const rows = this.db.prepare('SELECT id, file_mtime, archived FROM sessions').all();
747
+ const map = new Map();
748
+ for (const r of rows) {
749
+ map.set(r.id, { mtime: r.file_mtime, archived: r.archived === 1 });
750
+ }
751
+ return map;
752
+ }
753
+ // ── Subagent sessions ──
754
+ /** 寫入 subagent session 記錄(upsert:重複 id 更新 mtime/count) */
755
+ indexSubagentSession(params) {
756
+ this.db.prepare(`
757
+ INSERT INTO subagent_sessions (id, parent_session_id, agent_type, file_path, file_size, file_mtime, message_count, started_at, ended_at)
758
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
759
+ ON CONFLICT(id) DO UPDATE SET
760
+ agent_type = excluded.agent_type,
761
+ file_size = excluded.file_size,
762
+ file_mtime = excluded.file_mtime,
763
+ message_count = excluded.message_count,
764
+ started_at = excluded.started_at,
765
+ ended_at = excluded.ended_at
766
+ `).run(params.id, params.parentSessionId, params.agentType, params.filePath, params.fileSize, params.fileMtime, params.messageCount, params.startedAt, params.endedAt);
767
+ }
768
+ /** 取得指定 parent session 下的所有 subagent sessions */
769
+ getSubagentSessions(parentSessionId) {
770
+ const rows = this.db.prepare(`SELECT id, parent_session_id, agent_type, file_path, file_size, file_mtime,
771
+ message_count, started_at, ended_at, created_at
772
+ FROM subagent_sessions WHERE parent_session_id = ? ORDER BY started_at`).all(parentSessionId);
773
+ return rows.map(r => ({
774
+ id: r.id,
775
+ parentSessionId: r.parent_session_id,
776
+ agentType: r.agent_type,
777
+ filePath: r.file_path,
778
+ fileSize: r.file_size,
779
+ fileMtime: r.file_mtime,
780
+ messageCount: r.message_count,
781
+ startedAt: r.started_at,
782
+ endedAt: r.ended_at,
783
+ createdAt: r.created_at,
784
+ }));
785
+ }
786
+ /** 刪除指定 parent session 的所有 subagent sessions */
787
+ deleteSubagentSessions(parentSessionId) {
788
+ this.db.prepare('DELETE FROM subagent_sessions WHERE parent_session_id = ?').run(parentSessionId);
789
+ }
790
+ /** 刪除單一 subagent session(含對應的 sessions/messages 資料) */
791
+ deleteSubagentSession(subagentId) {
792
+ const doDelete = this.db.transaction(() => {
793
+ this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(subagentId);
794
+ this.db.prepare('DELETE FROM session_files WHERE session_id = ?').run(subagentId);
795
+ this.db.prepare('DELETE FROM sessions WHERE id = ?').run(subagentId);
796
+ this.db.prepare('DELETE FROM subagent_sessions WHERE id = ?').run(subagentId);
797
+ });
798
+ doDelete();
799
+ }
800
+ /** 取得指定 parent session 的所有 subagent ID */
801
+ getSubagentSessionIds(parentSessionId) {
802
+ const rows = this.db.prepare('SELECT id FROM subagent_sessions WHERE parent_session_id = ?').all(parentSessionId);
803
+ return rows.map(r => r.id);
804
+ }
805
+ /** 在單一 transaction 中執行多個 DB 操作 */
806
+ runTransaction(fn) {
807
+ const tx = this.db.transaction(fn);
808
+ tx();
809
+ }
810
+ /** 一次取得所有 subagent sessions 的 file_mtime(增量比對用) */
811
+ getAllSubagentMtimes() {
812
+ const rows = this.db.prepare('SELECT id, file_mtime FROM subagent_sessions').all();
813
+ const map = new Map();
814
+ for (const r of rows) {
815
+ if (r.file_mtime)
816
+ map.set(r.id, r.file_mtime);
817
+ }
818
+ return map;
819
+ }
820
+ // ── UUID dedup helper ──
821
+ /** 查詢 DB 中已存在的 uuid(用於跨 session 去重 resumed session replay) */
822
+ getExistingUuids(uuids, excludeSessionId) {
823
+ const result = new Set();
824
+ for (let i = 0; i < uuids.length; i += 500) {
825
+ const chunk = uuids.slice(i, i + 500);
826
+ const placeholders = chunk.map(() => '?').join(',');
827
+ const rows = this.db.prepare(`SELECT uuid FROM messages WHERE session_id != ? AND uuid IN (${placeholders})`).all(excludeSessionId, ...chunk);
828
+ for (const r of rows)
829
+ result.add(r.uuid);
830
+ }
831
+ return result;
832
+ }
833
+ // ── Atomic session indexing ──
834
+ indexSession(params) {
835
+ const doIndex = this.db.transaction(() => {
836
+ this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(params.sessionId);
837
+ // 清除 sessions_fts 中的舊資料(external content 模式需手動維護)
838
+ const oldSession = this.db.prepare('SELECT rowid, title, tags, files_touched, summary_text, intent_text FROM sessions WHERE id = ?').get(params.sessionId);
839
+ if (oldSession) {
840
+ this.db.prepare("INSERT INTO sessions_fts(sessions_fts, rowid, title, tags, files_touched, summary_text, intent_text) VALUES ('delete', ?, ?, ?, ?, ?, ?)").run(oldSession.rowid, oldSession.title ?? '', oldSession.tags ?? '', oldSession.files_touched ?? '', oldSession.summary_text ?? '', oldSession.intent_text ?? '');
841
+ }
842
+ this.db.prepare('DELETE FROM session_files WHERE session_id = ?').run(params.sessionId);
843
+ this.db.prepare('DELETE FROM sessions WHERE id = ?').run(params.sessionId);
844
+ this.upsertProject(params.projectId, params.projectDisplayName);
845
+ // 計算 token 彙總
846
+ let totalInput = 0;
847
+ let totalOutput = 0;
848
+ for (const m of params.messages) {
849
+ if (m.inputTokens != null)
850
+ totalInput += m.inputTokens;
851
+ if (m.outputTokens != null)
852
+ totalOutput += m.outputTokens;
853
+ }
854
+ const insertResult = this.db.prepare(`
855
+ INSERT INTO sessions (id, project_id, title, message_count, file_path, file_size, file_mtime, started_at, ended_at,
856
+ summary_text, intent_text, outcome_status, outcome_signals, duration_seconds, active_duration_seconds, summary_version,
857
+ tags, files_touched, tools_used, total_input_tokens, total_output_tokens)
858
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
859
+ `).run(params.sessionId, params.projectId, params.title, params.messageCount, params.filePath, params.fileSize, params.fileMtime, params.startedAt, params.endedAt, params.summaryText ?? null, params.intentText ?? null, params.outcomeStatus ?? null, params.outcomeSignals ?? null, params.durationSeconds ?? null, params.activeDurationSeconds ?? null, params.summaryVersion ?? null, params.tags ?? null, params.filesTouched ?? null, params.toolsUsed ?? null, totalInput || null, totalOutput || null);
860
+ // 新增 sessions_fts 條目(用 INSERT 回傳的 rowid 避免多餘查詢)
861
+ this.db.prepare('INSERT INTO sessions_fts(rowid, title, tags, files_touched, summary_text, intent_text) VALUES (?, ?, ?, ?, ?, ?)').run(insertResult.lastInsertRowid, params.title ?? '', params.tags ?? '', params.filesTouched ?? '', params.summaryText ?? '', params.intentText ?? '');
862
+ // 寫入 session_files
863
+ if (params.sessionFiles && params.sessionFiles.length > 0) {
864
+ const insertFile = this.db.prepare(`
865
+ INSERT INTO session_files (session_id, file_path, operation, count, first_seen_seq, last_seen_seq)
866
+ VALUES (?, ?, ?, ?, ?, ?)
867
+ `);
868
+ for (const f of params.sessionFiles) {
869
+ insertFile.run(params.sessionId, f.filePath, f.operation, f.count, f.firstSeenSeq, f.lastSeenSeq);
870
+ }
871
+ }
872
+ const insertMsg = this.db.prepare(`
873
+ INSERT INTO messages (session_id, type, role, content_text, has_tool_use, has_tool_result, tool_names, timestamp, sequence, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, model, uuid)
874
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
875
+ `);
876
+ const insertContent = this.db.prepare('INSERT INTO message_content (message_id, content_json) VALUES (?, ?)');
877
+ const insertArchive = this.db.prepare('INSERT INTO message_archive (message_id, raw_json) VALUES (?, ?)');
878
+ for (const m of params.messages) {
879
+ const result = insertMsg.run(params.sessionId, m.type, m.role, m.contentText, m.hasToolUse ? 1 : 0, m.hasToolResult ? 1 : 0, m.toolNames.length > 0 ? m.toolNames.join(',') : null, m.timestamp, m.sequence, m.inputTokens, m.outputTokens, m.cacheReadTokens, m.cacheCreationTokens, m.model, m.uuid);
880
+ const msgId = result.lastInsertRowid;
881
+ if (m.contentJson != null) {
882
+ insertContent.run(msgId, m.contentJson);
883
+ }
884
+ if (m.rawJson != null) {
885
+ insertArchive.run(msgId, m.rawJson);
886
+ }
887
+ }
888
+ });
889
+ doIndex();
890
+ }
891
+ // ── Messages ──
892
+ getMessages(sessionId) {
893
+ const rows = this.db.prepare(`
894
+ SELECT m.id, m.session_id, m.type, m.role, m.content_text,
895
+ mc.content_json, m.has_tool_use, m.has_tool_result,
896
+ m.tool_names, m.timestamp, m.sequence,
897
+ m.input_tokens, m.output_tokens, m.cache_read_tokens, m.cache_creation_tokens, m.model
898
+ FROM messages m
899
+ LEFT JOIN message_content mc ON mc.message_id = m.id
900
+ WHERE m.session_id = ?
901
+ ORDER BY m.sequence
902
+ `).all(sessionId);
903
+ return rows.map(mapMessageRow);
904
+ }
905
+ /** 取得指定訊息及其前後 range 則訊息(搜尋結果上下文預覽) */
906
+ getMessageContext(messageId, range = 2) {
907
+ const msgSelect = `
908
+ SELECT m.id, m.session_id, m.type, m.role, m.content_text,
909
+ mc.content_json, m.has_tool_use, m.has_tool_result,
910
+ m.tool_names, m.timestamp, m.sequence,
911
+ m.input_tokens, m.output_tokens, m.cache_read_tokens, m.cache_creation_tokens, m.model
912
+ FROM messages m
913
+ LEFT JOIN message_content mc ON mc.message_id = m.id
914
+ `;
915
+ const target = this.db.prepare(`${msgSelect} WHERE m.id = ?`).get(messageId);
916
+ if (!target) {
917
+ return { target: null, before: [], after: [] };
918
+ }
919
+ const beforeRows = this.db.prepare(`${msgSelect} WHERE m.session_id = ? AND m.sequence < ? AND m.sequence >= ? ORDER BY m.sequence`).all(target.session_id, target.sequence, target.sequence - range);
920
+ const afterRows = this.db.prepare(`${msgSelect} WHERE m.session_id = ? AND m.sequence > ? AND m.sequence <= ? ORDER BY m.sequence`).all(target.session_id, target.sequence, target.sequence + range);
921
+ return {
922
+ target: mapMessageRow(target),
923
+ before: beforeRows.map(mapMessageRow),
924
+ after: afterRows.map(mapMessageRow),
925
+ };
926
+ }
927
+ // ── FTS5 Search ──
928
+ static SEARCH_PAGE_SIZE = 30;
929
+ static VALID_OUTCOMES = new Set(['committed', 'tested', 'in-progress', 'quick-qa']);
930
+ static parseOutcomeStatus(v) {
931
+ return v && Database.VALID_OUTCOMES.has(v) ? v : null;
932
+ }
933
+ /** FTS5 安全引號包裹:所有 token 無條件包引號,防止 operator injection */
934
+ static fts5QuoteIfNeeded(query) {
935
+ return query.split(/\s+/).filter(Boolean).map(token => `"${token.replace(/"/g, '""')}"`).join(' ');
936
+ }
937
+ search(query, projectId, offset = 0, limit = Database.SEARCH_PAGE_SIZE, options) {
938
+ limit = Math.min(limit, 100);
939
+ query = Database.fts5QuoteIfNeeded(query);
940
+ try {
941
+ let sql = `
942
+ SELECT
943
+ m.id AS message_id,
944
+ m.session_id,
945
+ s.title AS session_title,
946
+ s.project_id,
947
+ p.display_name AS project_name,
948
+ snippet(messages_fts, 0, x'EE8080', x'EE8081', '...', 128) AS snippet,
949
+ m.timestamp,
950
+ s.started_at AS session_started_at
951
+ FROM messages_fts
952
+ JOIN messages m ON m.id = messages_fts.rowid
953
+ JOIN sessions s ON s.id = m.session_id
954
+ JOIN projects p ON p.id = s.project_id
955
+ WHERE messages_fts MATCH ?
956
+ AND m.type NOT IN ('last-prompt', 'queue-operation')
957
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
958
+ `;
959
+ const params = [query];
960
+ if (projectId) {
961
+ sql += ' AND s.project_id = ?';
962
+ params.push(projectId);
963
+ }
964
+ if (options?.dateFrom) {
965
+ sql += ' AND date(s.started_at) >= ?';
966
+ params.push(options.dateFrom);
967
+ }
968
+ if (options?.dateTo) {
969
+ sql += ' AND date(s.started_at) <= ?';
970
+ params.push(options.dateTo);
971
+ }
972
+ sql += options?.sortBy === 'date' ? ' ORDER BY m.timestamp DESC, m.id DESC' : ' ORDER BY rank, m.id DESC';
973
+ sql += ' LIMIT ? OFFSET ?';
974
+ params.push(limit + 1, offset); // 多取 1 筆判斷 hasMore
975
+ const rows = this.db.prepare(sql).all(...params);
976
+ const hasMore = rows.length > limit;
977
+ if (hasMore)
978
+ rows.pop();
979
+ const results = rows.map(r => ({
980
+ sessionId: r.session_id,
981
+ sessionTitle: r.session_title,
982
+ projectId: r.project_id,
983
+ projectName: r.project_name,
984
+ messageId: r.message_id,
985
+ snippet: r.snippet,
986
+ timestamp: r.timestamp,
987
+ sessionStartedAt: r.session_started_at,
988
+ }));
989
+ return { results, offset, hasMore };
990
+ }
991
+ catch {
992
+ // FTS5 查詢失敗(語法錯誤、未關閉的引號等)→ 回傳空頁
993
+ return { results: [], offset, hasMore: false };
994
+ }
995
+ }
996
+ /** 搜尋 session 標題 / 標籤 / 檔案路徑 / 摘要 / 意圖 */
997
+ searchSessions(query, projectId, offset = 0, limit = Database.SEARCH_PAGE_SIZE, options) {
998
+ limit = Math.min(limit, 100);
999
+ query = Database.fts5QuoteIfNeeded(query);
1000
+ try {
1001
+ let sql = `
1002
+ SELECT
1003
+ s.id AS session_id,
1004
+ s.title AS session_title,
1005
+ s.project_id,
1006
+ p.display_name AS project_name,
1007
+ s.tags,
1008
+ s.files_touched,
1009
+ snippet(sessions_fts, -1, x'EE8080', x'EE8081', '...', 128) AS snippet,
1010
+ s.started_at,
1011
+ s.outcome_status
1012
+ FROM sessions_fts
1013
+ JOIN sessions s ON s.rowid = sessions_fts.rowid
1014
+ JOIN projects p ON p.id = s.project_id
1015
+ WHERE sessions_fts MATCH ?
1016
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
1017
+ `;
1018
+ const params = [query];
1019
+ if (projectId) {
1020
+ sql += ' AND s.project_id = ?';
1021
+ params.push(projectId);
1022
+ }
1023
+ if (options?.dateFrom) {
1024
+ sql += ' AND date(s.started_at) >= ?';
1025
+ params.push(options.dateFrom);
1026
+ }
1027
+ if (options?.dateTo) {
1028
+ sql += ' AND date(s.started_at) <= ?';
1029
+ params.push(options.dateTo);
1030
+ }
1031
+ sql += options?.sortBy === 'date' ? ' ORDER BY s.started_at DESC, s.rowid DESC' : ' ORDER BY rank, s.rowid DESC';
1032
+ sql += ' LIMIT ? OFFSET ?';
1033
+ params.push(limit + 1, offset);
1034
+ const rows = this.db.prepare(sql).all(...params);
1035
+ const hasMore = rows.length > limit;
1036
+ if (hasMore)
1037
+ rows.pop();
1038
+ const results = rows.map(r => ({
1039
+ sessionId: r.session_id,
1040
+ sessionTitle: r.session_title,
1041
+ projectId: r.project_id,
1042
+ projectName: r.project_name,
1043
+ tags: r.tags,
1044
+ filesTouched: r.files_touched,
1045
+ snippet: r.snippet,
1046
+ startedAt: r.started_at,
1047
+ outcomeStatus: Database.parseOutcomeStatus(r.outcome_status),
1048
+ }));
1049
+ return { results, offset, hasMore };
1050
+ }
1051
+ catch {
1052
+ return { results: [], offset, hasMore: false };
1053
+ }
1054
+ }
1055
+ // ── Token Stats ──
1056
+ getSessionTokenStats(sessionId) {
1057
+ const rows = this.db.prepare(`
1058
+ SELECT sequence, timestamp,
1059
+ input_tokens, output_tokens,
1060
+ cache_read_tokens, cache_creation_tokens,
1061
+ has_tool_use, tool_names, model
1062
+ FROM messages
1063
+ WHERE session_id = ? AND input_tokens IS NOT NULL
1064
+ ORDER BY sequence
1065
+ `).all(sessionId);
1066
+ let totalInput = 0;
1067
+ let totalOutput = 0;
1068
+ let totalCacheRead = 0;
1069
+ let totalCacheCreation = 0;
1070
+ const modelCounts = new Map();
1071
+ const turns = rows.map(r => {
1072
+ totalInput += r.input_tokens;
1073
+ totalOutput += r.output_tokens;
1074
+ totalCacheRead += r.cache_read_tokens;
1075
+ totalCacheCreation += r.cache_creation_tokens;
1076
+ if (r.model)
1077
+ modelCounts.set(r.model, (modelCounts.get(r.model) ?? 0) + 1);
1078
+ return {
1079
+ sequence: r.sequence,
1080
+ timestamp: r.timestamp,
1081
+ inputTokens: r.input_tokens,
1082
+ outputTokens: r.output_tokens,
1083
+ cacheReadTokens: r.cache_read_tokens,
1084
+ cacheCreationTokens: r.cache_creation_tokens,
1085
+ contextTotal: r.input_tokens,
1086
+ hasToolUse: r.has_tool_use === 1,
1087
+ toolNames: r.tool_names ? r.tool_names.split(',') : [],
1088
+ model: r.model,
1089
+ };
1090
+ });
1091
+ const models = [...modelCounts.keys()];
1092
+ let primaryModel = null;
1093
+ let maxCount = 0;
1094
+ for (const [m, c] of modelCounts) {
1095
+ if (c > maxCount) {
1096
+ primaryModel = m;
1097
+ maxCount = c;
1098
+ }
1099
+ }
1100
+ return {
1101
+ totalInputTokens: totalInput,
1102
+ totalOutputTokens: totalOutput,
1103
+ totalCacheReadTokens: totalCacheRead,
1104
+ totalCacheCreationTokens: totalCacheCreation,
1105
+ cacheHitRate: totalInput > 0 ? totalCacheRead / totalInput : 0,
1106
+ models,
1107
+ primaryModel,
1108
+ turns,
1109
+ };
1110
+ }
1111
+ // ── Session Files (Reverse Index) ──
1112
+ /** 反向查詢:某檔案出現在哪些 session(按時間倒序) */
1113
+ getFileHistory(filePath) {
1114
+ const rows = this.db.prepare(`
1115
+ SELECT sf.session_id, s.title AS session_title, s.project_id, p.display_name AS project_name,
1116
+ sf.operation, sf.count, s.started_at
1117
+ FROM session_files sf
1118
+ JOIN sessions s ON s.id = sf.session_id
1119
+ JOIN projects p ON p.id = s.project_id
1120
+ WHERE sf.file_path = ?
1121
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
1122
+ ORDER BY s.started_at DESC
1123
+ `).all(filePath);
1124
+ return rows.map(r => ({
1125
+ sessionId: r.session_id,
1126
+ sessionTitle: r.session_title,
1127
+ projectId: r.project_id,
1128
+ projectName: r.project_name,
1129
+ operation: r.operation,
1130
+ count: r.count,
1131
+ startedAt: r.started_at,
1132
+ }));
1133
+ }
1134
+ /** 正向查詢:某 session 操作了哪些檔案 */
1135
+ getSessionFiles(sessionId) {
1136
+ const rows = this.db.prepare(`
1137
+ SELECT session_id, file_path, operation, count, first_seen_seq, last_seen_seq
1138
+ FROM session_files WHERE session_id = ?
1139
+ ORDER BY last_seen_seq DESC
1140
+ `).all(sessionId);
1141
+ return rows.map(r => ({
1142
+ sessionId: r.session_id,
1143
+ filePath: r.file_path,
1144
+ operation: r.operation,
1145
+ count: r.count,
1146
+ firstSeenSeq: r.first_seen_seq,
1147
+ lastSeenSeq: r.last_seen_seq,
1148
+ }));
1149
+ }
1150
+ // ── Memories ──
1151
+ saveMemory(input) {
1152
+ // Phase 4b: denormalize project_id.
1153
+ // Session-backed memories are ALWAYS derived from sessions.project_id — caller-
1154
+ // supplied projectId is ignored for session-backed rows to prevent forged scope
1155
+ // via a stale or orphaned sessions row (Phase 3 stale-denorm lesson). If the
1156
+ // session is missing, project_id falls back to NULL rather than trusting the
1157
+ // caller's claim.
1158
+ // Manual memories (no sessionId) use caller-supplied projectId directly, or
1159
+ // NULL when absent.
1160
+ let projectId;
1161
+ if (input.sessionId) {
1162
+ const row = this.db.prepare('SELECT project_id FROM sessions WHERE id = ?').get(input.sessionId);
1163
+ projectId = row?.project_id ?? null;
1164
+ }
1165
+ else {
1166
+ projectId = input.projectId ?? null;
1167
+ }
1168
+ const info = this.db.prepare(`
1169
+ INSERT INTO memories (session_id, message_id, content, type, confidence, project_id)
1170
+ VALUES (?, ?, ?, ?, ?, ?)
1171
+ `).run(input.sessionId, input.messageId, input.content, input.type, input.confidence ?? 0.8, projectId);
1172
+ return Number(info.lastInsertRowid);
1173
+ }
1174
+ queryMemories(query, limit, projectId) {
1175
+ const q = Database.fts5QuoteIfNeeded(query);
1176
+ if (!q)
1177
+ return [];
1178
+ try {
1179
+ const cappedLimit = Math.min(limit, 100);
1180
+ const rows = projectId
1181
+ ? this.db.prepare(`
1182
+ SELECT m.id, m.session_id, m.message_id, m.content, m.type, m.confidence, m.created_at
1183
+ FROM memories_fts
1184
+ JOIN memories m ON m.id = memories_fts.rowid
1185
+ LEFT JOIN sessions s ON m.session_id = s.id
1186
+ WHERE memories_fts MATCH ?
1187
+ AND (
1188
+ (m.session_id IS NOT NULL AND s.project_id = ?) OR
1189
+ (m.session_id IS NULL AND m.project_id = ?)
1190
+ )
1191
+ ORDER BY rank, ${Database.EFFECTIVE_CONFIDENCE} DESC, m.id DESC
1192
+ LIMIT ?
1193
+ `).all(q, projectId, projectId, cappedLimit)
1194
+ : this.db.prepare(`
1195
+ SELECT m.id, m.session_id, m.message_id, m.content, m.type, m.confidence, m.created_at
1196
+ FROM memories_fts
1197
+ JOIN memories m ON m.id = memories_fts.rowid
1198
+ WHERE memories_fts MATCH ?
1199
+ ORDER BY rank, ${Database.EFFECTIVE_CONFIDENCE} DESC, m.id DESC
1200
+ LIMIT ?
1201
+ `).all(q, cappedLimit);
1202
+ return rows.map(mapMemoryRow);
1203
+ }
1204
+ catch (err) {
1205
+ // FTS5 errors echo the user-supplied query verbatim; scrub to avoid
1206
+ // log injection via control chars.
1207
+ console.warn('[memories] queryMemories error:', scrubErrorMessage(err));
1208
+ return [];
1209
+ }
1210
+ }
1211
+ /** Phase 4b: Increment access_count + stamp last_accessed for each id.
1212
+ * Caller is responsible for dedup (use MemoryService.touch for that). Runs
1213
+ * inside a transaction so partial failure does not leave half-applied state. */
1214
+ touchMemory(ids) {
1215
+ if (ids.length === 0)
1216
+ return;
1217
+ const stmt = this.db.prepare(`
1218
+ UPDATE memories
1219
+ SET access_count = access_count + 1,
1220
+ last_accessed = datetime('now')
1221
+ WHERE id = ?
1222
+ `);
1223
+ const run = this.db.transaction((memIds) => {
1224
+ for (const id of memIds)
1225
+ stmt.run(id);
1226
+ });
1227
+ run(ids);
1228
+ }
1229
+ /** Phase 4b: Delete a memory by id. memories_ad trigger syncs memories_fts. */
1230
+ deleteMemory(id) {
1231
+ const info = this.db.prepare('DELETE FROM memories WHERE id = ?').run(id);
1232
+ return info.changes > 0;
1233
+ }
1234
+ /** Phase 4b: Update memory content and stamp compression metadata atomically.
1235
+ * memories_au trigger syncs memories_fts. Used by the compression pipeline. */
1236
+ updateMemoryContent(id, content, level, compressedAt) {
1237
+ const info = this.db.prepare(`
1238
+ UPDATE memories
1239
+ SET content = ?, compression_level = ?, compressed_at = ?
1240
+ WHERE id = ?
1241
+ `).run(content, level, compressedAt, id);
1242
+ return info.changes > 0;
1243
+ }
1244
+ getMemoriesBySessionId(sessionId) {
1245
+ const rows = this.db.prepare(`
1246
+ SELECT id, session_id, message_id, content, type, confidence, created_at
1247
+ FROM memories
1248
+ WHERE session_id = ?
1249
+ ORDER BY id ASC
1250
+ `).all(sessionId);
1251
+ return rows.map(mapMemoryRow);
1252
+ }
1253
+ getMemoryCount() {
1254
+ const row = this.db.prepare('SELECT COUNT(*) AS c FROM memories').get();
1255
+ return row.c;
1256
+ }
1257
+ /** Phase 4d lint: memories whose session_id references a session row that no
1258
+ * longer exists (user scrubbed ~/.claude/projects, or pre-index race). Manual
1259
+ * memories (session_id IS NULL) are not orphan candidates by definition. */
1260
+ getOrphanMemoryIds(limit = 1000) {
1261
+ // Capped — a bulk wipe of ~/.claude could otherwise return tens of thousands
1262
+ // of rows into one JSON response, blocking the event loop on serialisation.
1263
+ const rows = this.db.prepare(`
1264
+ SELECT m.id AS memory_id, m.session_id
1265
+ FROM memories m
1266
+ WHERE m.session_id IS NOT NULL
1267
+ AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.id = m.session_id)
1268
+ ORDER BY m.id ASC
1269
+ LIMIT ?
1270
+ `).all(limit);
1271
+ return rows.map(r => ({ memoryId: r.memory_id, sessionId: r.session_id }));
1272
+ }
1273
+ /** Phase 4d lint: memories that have fully decayed — effective confidence under
1274
+ * threshold AND never accessed AND older than `ageDays`. Strict `>` on age so
1275
+ * callers can use 90 as "more than 90 days" without fencepost ambiguity. */
1276
+ getStaleMemoryIds(opts) {
1277
+ const rows = this.db.prepare(`
1278
+ SELECT memory_id, age_days, effective_confidence
1279
+ FROM (
1280
+ SELECT
1281
+ m.id AS memory_id,
1282
+ m.access_count,
1283
+ (julianday('now') - julianday(COALESCE(m.last_accessed, m.created_at))) AS age_days,
1284
+ ${Database.EFFECTIVE_CONFIDENCE} AS effective_confidence
1285
+ FROM memories m
1286
+ )
1287
+ WHERE access_count = 0 AND age_days > ? AND effective_confidence < ?
1288
+ ORDER BY memory_id ASC
1289
+ `).all(opts.ageDays, opts.effectiveConfidence);
1290
+ return rows.map(r => ({
1291
+ memoryId: r.memory_id,
1292
+ ageDays: r.age_days,
1293
+ effectiveConfidence: r.effective_confidence,
1294
+ }));
1295
+ }
1296
+ /** Phase 4d: fetch a batch of memories with everything the compression pipeline
1297
+ * needs to plan a transition — current level / access / age / effective confidence
1298
+ * plus the owning session's summary_text / intent_text for content rewrite.
1299
+ *
1300
+ * LEFT JOIN sessions so session-backed memories whose session row was deleted
1301
+ * (manual scrub of ~/.claude) still surface; the pipeline detects the NULL
1302
+ * summary and falls back to syntactic truncation, matching manual memories. */
1303
+ getCompressionCandidates(limit) {
1304
+ // Only return rows that match at least one level's transition gates — ORDER
1305
+ // BY id ASC + LIMIT would otherwise stall on the first `batchSize` of rows
1306
+ // whose access_count permanently disqualifies them (e.g. high-access L0
1307
+ // that can never reach L1), starving truly eligible rows further back.
1308
+ //
1309
+ // `session_exists = 1` is surfaced so the pipeline can block auto-delete
1310
+ // of orphaned rows (sessionId points at a session that was scrubbed) —
1311
+ // deleting an orphan is irreversible data loss since the source JSONL is
1312
+ // already gone.
1313
+ const rows = this.db.prepare(`
1314
+ SELECT id, session_id, content, compression_level, access_count,
1315
+ age_days, effective_confidence, summary_text, intent_text,
1316
+ session_exists
1317
+ FROM (
1318
+ SELECT
1319
+ m.id, m.session_id, m.content, m.compression_level, m.access_count,
1320
+ (julianday('now') - julianday(COALESCE(m.last_accessed, m.created_at))) AS age_days,
1321
+ ${Database.EFFECTIVE_CONFIDENCE} AS effective_confidence,
1322
+ s.summary_text, s.intent_text,
1323
+ CASE WHEN s.id IS NULL THEN 0 ELSE 1 END AS session_exists
1324
+ FROM memories m
1325
+ LEFT JOIN sessions s ON m.session_id = s.id
1326
+ )
1327
+ WHERE
1328
+ (compression_level = 0 AND age_days >= 7 AND access_count < 2 AND effective_confidence < 0.5)
1329
+ OR (compression_level = 1 AND age_days >= 30 AND access_count < 4)
1330
+ OR (compression_level = 2 AND age_days >= 60 AND session_id IS NOT NULL
1331
+ AND session_exists = 1 AND access_count = 0)
1332
+ ORDER BY id ASC
1333
+ LIMIT ?
1334
+ `).all(limit);
1335
+ return rows.map(r => ({
1336
+ id: r.id,
1337
+ sessionId: r.session_id,
1338
+ content: r.content,
1339
+ compressionLevel: r.compression_level,
1340
+ accessCount: r.access_count,
1341
+ ageDays: r.age_days,
1342
+ effectiveConfidence: r.effective_confidence ?? 0,
1343
+ summaryText: r.summary_text,
1344
+ intentText: r.intent_text,
1345
+ sessionExists: r.session_exists === 1,
1346
+ }));
1347
+ }
1348
+ // ── Knowledge Map / Topics (Phase 3a) ──
1349
+ saveSessionTopics(sessionId, projectId, topicKeys) {
1350
+ const run = this.db.transaction((keys) => {
1351
+ this.db.prepare('DELETE FROM session_topics WHERE session_id = ?').run(sessionId);
1352
+ if (keys.length === 0)
1353
+ return;
1354
+ const stmt = this.db.prepare('INSERT INTO session_topics (session_id, topic_key, project_id) VALUES (?, ?, ?)');
1355
+ for (const k of keys)
1356
+ stmt.run(sessionId, k, projectId);
1357
+ });
1358
+ run(topicKeys);
1359
+ }
1360
+ saveMemoryTopics(memoryId, projectId, topicKeys) {
1361
+ const run = this.db.transaction((keys) => {
1362
+ this.db.prepare('DELETE FROM memory_topics WHERE memory_id = ?').run(memoryId);
1363
+ if (keys.length === 0)
1364
+ return;
1365
+ const stmt = this.db.prepare('INSERT INTO memory_topics (memory_id, topic_key, project_id) VALUES (?, ?, ?)');
1366
+ for (const k of keys)
1367
+ stmt.run(memoryId, k, projectId);
1368
+ });
1369
+ run(topicKeys);
1370
+ }
1371
+ /** Full rebuild of knowledge_map for a project. project_id for memory_topics is derived
1372
+ from sessions.project_id (not the denormalized column) so repo renames / reindex won't
1373
+ leave stale cross-project associations. */
1374
+ rebuildKnowledgeMap(projectId) {
1375
+ const run = this.db.transaction(() => {
1376
+ this.db.prepare('DELETE FROM knowledge_map WHERE project_id = ?').run(projectId);
1377
+ this.db.prepare(`
1378
+ INSERT INTO knowledge_map (topic_key, project_id, mention_count, last_touched)
1379
+ SELECT topic_key, project_id, COUNT(*) AS mention_count, MAX(touched_at) AS last_touched
1380
+ FROM (
1381
+ SELECT st.topic_key, s.project_id,
1382
+ COALESCE(s.ended_at, s.started_at, datetime('now')) AS touched_at
1383
+ FROM session_topics st
1384
+ JOIN sessions s ON s.id = st.session_id
1385
+ WHERE s.project_id = ?
1386
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
1387
+ UNION ALL
1388
+ SELECT mt.topic_key, s.project_id,
1389
+ COALESCE(m.created_at, datetime('now')) AS touched_at
1390
+ FROM memory_topics mt
1391
+ JOIN memories m ON m.id = mt.memory_id
1392
+ JOIN sessions s ON s.id = m.session_id
1393
+ WHERE s.project_id = ?
1394
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
1395
+ )
1396
+ GROUP BY topic_key, project_id
1397
+ `).run(projectId, projectId);
1398
+ });
1399
+ run();
1400
+ }
1401
+ static TOPIC_ORDER_BY = {
1402
+ mention: 'mention_count DESC, last_touched DESC',
1403
+ recent: 'last_touched DESC',
1404
+ stale: 'last_touched ASC',
1405
+ };
1406
+ getKnowledgeMap(projectId, opts = {}) {
1407
+ const { limit = 50, sortBy = 'mention' } = opts;
1408
+ const cappedLimit = Math.min(limit, 500);
1409
+ const rows = this.db.prepare(`
1410
+ SELECT topic_key, project_id, mention_count, last_touched
1411
+ FROM knowledge_map
1412
+ WHERE project_id = ?
1413
+ ORDER BY ${Database.TOPIC_ORDER_BY[sortBy]}
1414
+ LIMIT ?
1415
+ `).all(projectId, cappedLimit);
1416
+ return rows.map(mapTopicRow);
1417
+ }
1418
+ getMemoriesByTopics(projectId, topicKeys, limit) {
1419
+ if (topicKeys.length === 0)
1420
+ return [];
1421
+ const cappedLimit = Math.min(limit, 100);
1422
+ const placeholders = topicKeys.map(() => '?').join(',');
1423
+ const rows = this.db.prepare(`
1424
+ SELECT DISTINCT m.id, m.session_id, m.message_id, m.content, m.type, m.confidence, m.created_at
1425
+ FROM memory_topics mt
1426
+ JOIN memories m ON m.id = mt.memory_id
1427
+ JOIN sessions s ON s.id = m.session_id
1428
+ WHERE s.project_id = ?
1429
+ AND s.id ${Database.EXCLUDE_SUBAGENTS}
1430
+ AND mt.topic_key IN (${placeholders})
1431
+ ORDER BY ${Database.EFFECTIVE_CONFIDENCE} DESC, m.id DESC
1432
+ LIMIT ?
1433
+ `).all(projectId, ...topicKeys, cappedLimit);
1434
+ return rows.map(mapMemoryRow);
1435
+ }
1436
+ getTopicCount(projectId) {
1437
+ const row = projectId
1438
+ ? this.db.prepare('SELECT COUNT(*) AS c FROM knowledge_map WHERE project_id = ?').get(projectId)
1439
+ : this.db.prepare('SELECT COUNT(*) AS c FROM knowledge_map').get();
1440
+ return row.c;
1441
+ }
1442
+ getSessionTopicKeys(sessionId) {
1443
+ const rows = this.db.prepare('SELECT topic_key FROM session_topics WHERE session_id = ? ORDER BY topic_key').all(sessionId);
1444
+ return rows.map(r => r.topic_key);
1445
+ }
1446
+ getTopic(topicKey, projectId) {
1447
+ const row = this.db.prepare(`
1448
+ SELECT topic_key, project_id, mention_count, last_touched
1449
+ FROM knowledge_map
1450
+ WHERE topic_key = ? AND project_id = ?
1451
+ `).get(topicKey, projectId);
1452
+ return row ? mapTopicRow(row) : null;
1453
+ }
1454
+ /** 共現 topics(與目標共享 session 或 memory),按共現次數排序。
1455
+ project_id 透過 JOIN sessions 決定,避免 memory_topics 過時的 project_id 汙染。 */
1456
+ getRelatedTopics(topicKey, projectId, limit) {
1457
+ const cappedLimit = Math.min(limit, 50);
1458
+ const rows = this.db.prepare(`
1459
+ SELECT k, COUNT(*) AS c FROM (
1460
+ SELECT st2.topic_key AS k
1461
+ FROM session_topics st1
1462
+ JOIN session_topics st2 ON st1.session_id = st2.session_id AND st2.topic_key != st1.topic_key
1463
+ JOIN sessions s ON s.id = st1.session_id
1464
+ WHERE st1.topic_key = ? AND s.project_id = ? AND s.id ${Database.EXCLUDE_SUBAGENTS}
1465
+ UNION ALL
1466
+ SELECT mt2.topic_key AS k
1467
+ FROM memory_topics mt1
1468
+ JOIN memory_topics mt2 ON mt1.memory_id = mt2.memory_id AND mt2.topic_key != mt1.topic_key
1469
+ JOIN memories m ON m.id = mt1.memory_id
1470
+ JOIN sessions s ON s.id = m.session_id
1471
+ WHERE mt1.topic_key = ? AND s.project_id = ? AND s.id ${Database.EXCLUDE_SUBAGENTS}
1472
+ )
1473
+ GROUP BY k ORDER BY c DESC, k ASC LIMIT ?
1474
+ `).all(topicKey, projectId, topicKey, projectId, cappedLimit);
1475
+ return rows.map(r => r.k);
1476
+ }
1477
+ getKnowledgeMapCounts(projectId) {
1478
+ const topics = this.db.prepare('SELECT COUNT(*) AS c FROM knowledge_map WHERE project_id = ?').get(projectId);
1479
+ const mems = this.db.prepare(`
1480
+ SELECT COUNT(*) AS c FROM memories m
1481
+ JOIN sessions s ON s.id = m.session_id
1482
+ WHERE s.project_id = ? AND s.id ${Database.EXCLUDE_SUBAGENTS}
1483
+ `).get(projectId);
1484
+ const sess = this.db.prepare(`
1485
+ SELECT COUNT(*) AS c FROM sessions WHERE project_id = ? AND id ${Database.EXCLUDE_SUBAGENTS}
1486
+ `).get(projectId);
1487
+ return { totalTopics: topics.c, totalMemories: mems.c, totalSessions: sess.c };
1488
+ }
1489
+ // ── Session Checkpoints (Phase 3d) ──
1490
+ saveCheckpoint(sessionId, projectId, snapshotText) {
1491
+ const info = this.db.prepare(`
1492
+ INSERT INTO session_checkpoints (session_id, project_id, snapshot_text)
1493
+ VALUES (?, ?, ?)
1494
+ `).run(sessionId, projectId, snapshotText);
1495
+ return Number(info.lastInsertRowid);
1496
+ }
1497
+ getCheckpointById(id) {
1498
+ const row = this.db.prepare(`
1499
+ SELECT id, session_id, project_id, snapshot_text, created_at
1500
+ FROM session_checkpoints WHERE id = ?
1501
+ `).get(id);
1502
+ return row ? mapCheckpointRow(row) : null;
1503
+ }
1504
+ getCheckpointsBySessionId(sessionId) {
1505
+ const rows = this.db.prepare(`
1506
+ SELECT id, session_id, project_id, snapshot_text, created_at
1507
+ FROM session_checkpoints WHERE session_id = ?
1508
+ ORDER BY created_at DESC, id DESC
1509
+ `).all(sessionId);
1510
+ return rows.map(mapCheckpointRow);
1511
+ }
1512
+ }
1513
+ //# sourceMappingURL=database.js.map