@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.
- package/LICENSE +202 -0
- package/README.md +290 -0
- package/README_ZH.md +288 -0
- package/dist/api/routes.d.ts +13 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +428 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/server.d.ts +7 -0
- package/dist/api/server.d.ts.map +1 -0
- package/dist/api/server.js +44 -0
- package/dist/api/server.js.map +1 -0
- package/dist/cli/daemon.d.ts +35 -0
- package/dist/cli/daemon.d.ts.map +1 -0
- package/dist/cli/daemon.js +163 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/core/compression.d.ts +37 -0
- package/dist/core/compression.d.ts.map +1 -0
- package/dist/core/compression.js +129 -0
- package/dist/core/compression.js.map +1 -0
- package/dist/core/database.d.ts +219 -0
- package/dist/core/database.d.ts.map +1 -0
- package/dist/core/database.js +1513 -0
- package/dist/core/database.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/indexer.d.ts +15 -0
- package/dist/core/indexer.d.ts.map +1 -0
- package/dist/core/indexer.js +224 -0
- package/dist/core/indexer.js.map +1 -0
- package/dist/core/lint.d.ts +17 -0
- package/dist/core/lint.d.ts.map +1 -0
- package/dist/core/lint.js +51 -0
- package/dist/core/lint.js.map +1 -0
- package/dist/core/log-safe.d.ts +6 -0
- package/dist/core/log-safe.d.ts.map +1 -0
- package/dist/core/log-safe.js +11 -0
- package/dist/core/log-safe.js.map +1 -0
- package/dist/core/maintenance-coordinator.d.ts +26 -0
- package/dist/core/maintenance-coordinator.d.ts.map +1 -0
- package/dist/core/maintenance-coordinator.js +67 -0
- package/dist/core/maintenance-coordinator.js.map +1 -0
- package/dist/core/memory-service.d.ts +31 -0
- package/dist/core/memory-service.d.ts.map +1 -0
- package/dist/core/memory-service.js +47 -0
- package/dist/core/memory-service.js.map +1 -0
- package/dist/core/parser.d.ts +17 -0
- package/dist/core/parser.d.ts.map +1 -0
- package/dist/core/parser.js +205 -0
- package/dist/core/parser.js.map +1 -0
- package/dist/core/scanner.d.ts +8 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +123 -0
- package/dist/core/scanner.js.map +1 -0
- package/dist/core/summarizer.d.ts +12 -0
- package/dist/core/summarizer.d.ts.map +1 -0
- package/dist/core/summarizer.js +430 -0
- package/dist/core/summarizer.js.map +1 -0
- package/dist/core/topic-extractor.d.ts +11 -0
- package/dist/core/topic-extractor.d.ts.map +1 -0
- package/dist/core/topic-extractor.js +51 -0
- package/dist/core/topic-extractor.js.map +1 -0
- package/dist/core/types.d.ts +329 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +12 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/watcher.d.ts +30 -0
- package/dist/core/watcher.d.ts.map +1 -0
- package/dist/core/watcher.js +146 -0
- package/dist/core/watcher.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +32 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +39 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +215 -0
- package/dist/mcp/tools.js.map +1 -0
- package/hooks/README.md +78 -0
- package/hooks/session-end.mjs +92 -0
- package/hooks/session-start.mjs +112 -0
- 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
|