dsh-layered-memory 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/assets/readme/hero.svg +58 -0
  4. package/cordis.patch.yml +16 -0
  5. package/dist/client.js +1066 -0
  6. package/dist/config.d.ts +263 -0
  7. package/dist/config.js +65 -0
  8. package/dist/hooks/capture.d.ts +16 -0
  9. package/dist/hooks/capture.js +160 -0
  10. package/dist/hooks/recall.d.ts +31 -0
  11. package/dist/hooks/recall.js +201 -0
  12. package/dist/index.d.ts +190 -0
  13. package/dist/index.js +234 -0
  14. package/dist/llm.d.ts +32 -0
  15. package/dist/llm.js +128 -0
  16. package/dist/pipeline/l1.d.ts +19 -0
  17. package/dist/pipeline/l1.js +226 -0
  18. package/dist/pipeline/l2.d.ts +13 -0
  19. package/dist/pipeline/l2.js +77 -0
  20. package/dist/pipeline/l3.d.ts +15 -0
  21. package/dist/pipeline/l3.js +72 -0
  22. package/dist/pipeline/runner.d.ts +44 -0
  23. package/dist/pipeline/runner.js +132 -0
  24. package/dist/prompts/l1-dedup.d.ts +20 -0
  25. package/dist/prompts/l1-dedup.js +244 -0
  26. package/dist/prompts/l1-extraction.d.ts +18 -0
  27. package/dist/prompts/l1-extraction.js +434 -0
  28. package/dist/prompts/persona.d.ts +23 -0
  29. package/dist/prompts/persona.js +234 -0
  30. package/dist/prompts/scene.d.ts +33 -0
  31. package/dist/prompts/scene.js +408 -0
  32. package/dist/settings.d.ts +27 -0
  33. package/dist/settings.js +73 -0
  34. package/dist/stats.d.ts +46 -0
  35. package/dist/stats.js +234 -0
  36. package/dist/store/bm25.d.ts +19 -0
  37. package/dist/store/bm25.js +62 -0
  38. package/dist/store/embedding.d.ts +68 -0
  39. package/dist/store/embedding.js +133 -0
  40. package/dist/store/io.d.ts +13 -0
  41. package/dist/store/io.js +75 -0
  42. package/dist/store/l0.d.ts +28 -0
  43. package/dist/store/l0.js +148 -0
  44. package/dist/store/l1.d.ts +70 -0
  45. package/dist/store/l1.js +239 -0
  46. package/dist/store/persona.d.ts +15 -0
  47. package/dist/store/persona.js +60 -0
  48. package/dist/store/scenes.d.ts +23 -0
  49. package/dist/store/scenes.js +150 -0
  50. package/dist/store/search-utils.d.ts +22 -0
  51. package/dist/store/search-utils.js +71 -0
  52. package/dist/store/session-modes.d.ts +24 -0
  53. package/dist/store/session-modes.js +102 -0
  54. package/dist/store/sqlite.d.ts +121 -0
  55. package/dist/store/sqlite.js +922 -0
  56. package/dist/store/state.d.ts +35 -0
  57. package/dist/store/state.js +62 -0
  58. package/dist/tools/index.d.ts +21 -0
  59. package/dist/tools/index.js +169 -0
  60. package/dist/types.d.ts +86 -0
  61. package/dist/types.js +7 -0
  62. package/dist/util/filelog.d.ts +4 -0
  63. package/dist/util/filelog.js +43 -0
  64. package/dist/util/sanitize.d.ts +10 -0
  65. package/dist/util/sanitize.js +66 -0
  66. package/dist/util/text.d.ts +11 -0
  67. package/dist/util/text.js +43 -0
  68. package/package.json +80 -0
@@ -0,0 +1,922 @@
1
+ /**
2
+ * SQLite 主检索引擎(移植 MemoryCore sqlite 后端的单机裁剪版)。
3
+ *
4
+ * 双写架构(官方语义):JSONL 追加文件是备份/恢复的事实源,
5
+ * 本库承担全部检索——L0/L1 的检索不再扫文件、不再全量载入内存。
6
+ *
7
+ * - l1_records / l0_conversations:结构化元数据表;
8
+ * - l1_fts / l0_fts:FTS5 BM25 全文索引(content 列存分词结果,其余列 UNINDEXED 随行携带);
9
+ * - l1_vec / l0_vec:sqlite-vec vec0 余弦向量表(仅 embedding 启用且维度 > 0 时创建)。
10
+ *
11
+ * 降级规则(degrade-don't-crash):
12
+ * - sqlite-vec 加载失败 → 纯 FTS 模式,能力位 vectorSearch=false;
13
+ * - FTS5 建表失败 → ftsSearch=false;
14
+ * - 任何一步 schema 初始化失败 → degraded=true,全部读写变为安全 no-op,
15
+ * 由上层走 storageOk=false 降级链路,绝不拖垮宿主启动。
16
+ */
17
+ import { createRequire } from 'node:module';
18
+ import { existsSync, mkdirSync } from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { familyForType } from '../types.js';
21
+ import { bm25RankToScore, buildFtsQuery, tokenizeForFts } from './search-utils.js';
22
+ const require = createRequire(import.meta.url);
23
+ const TAG = '[memory][sqlite]';
24
+ /** vec0 KNN 对遗留零向量的补偿缓冲(官方同款)。 */
25
+ const ZERO_VEC_BUFFER = 10;
26
+ export class MemoryDb {
27
+ db;
28
+ degraded = false;
29
+ ftsAvailable = false;
30
+ vecLoaded = false;
31
+ dimensions;
32
+ logger;
33
+ stmtUpsertL1;
34
+ stmtGetL1;
35
+ stmtDeleteL1Meta;
36
+ stmtDeleteL1Vec;
37
+ stmtInsertL1Vec;
38
+ stmtSearchL1Vec;
39
+ stmtL1FtsInsert;
40
+ stmtL1FtsDelete;
41
+ stmtL1FtsSearch;
42
+ stmtL1FtsSearchFamily;
43
+ stmtUpsertL0;
44
+ stmtDeleteL0Vec;
45
+ stmtInsertL0Vec;
46
+ stmtSearchL0Vec;
47
+ stmtL0FtsInsert;
48
+ stmtL0FtsDelete;
49
+ stmtL0FtsSearch;
50
+ constructor(dbPath, dimensions, logger) {
51
+ this.dimensions = dimensions;
52
+ this.logger = logger;
53
+ // 构造永不抛出(storage-degrade 不变量):开库/PRAGMA 任一失败 → 降级模式,
54
+ // 全部读写变为安全 no-op,宿主照常启动。
55
+ try {
56
+ const dbDir = path.dirname(dbPath);
57
+ if (!existsSync(dbDir))
58
+ mkdirSync(dbDir, { recursive: true });
59
+ const { DatabaseSync: DbSync } = require('node:sqlite');
60
+ this.db = new DbSync(dbPath, { allowExtension: true });
61
+ // 并发读优化 + 有界内存(照搬官方 PRAGMA 组合)
62
+ this.db.exec('PRAGMA busy_timeout = 5000');
63
+ this.db.exec('PRAGMA journal_mode = WAL');
64
+ this.db.exec('PRAGMA cache_size = -65536');
65
+ this.db.exec('PRAGMA mmap_size = 134217728');
66
+ this.db.exec('PRAGMA wal_autocheckpoint = 1000');
67
+ }
68
+ catch (err) {
69
+ this.degraded = true;
70
+ this.logger?.error(`${TAG} 数据库打开失败,存储进入降级模式(记忆读写停用): ${err instanceof Error ? err.message : String(err)}`);
71
+ }
72
+ }
73
+ isDegraded() {
74
+ return this.degraded;
75
+ }
76
+ getCapabilities() {
77
+ return {
78
+ ftsSearch: this.ftsAvailable && !this.degraded,
79
+ vectorSearch: this.vecLoaded && this.dimensions > 0 && !this.degraded,
80
+ };
81
+ }
82
+ /**
83
+ * 加载 sqlite-vec 扩展并建 schema。构造后必须调用一次。
84
+ * providerInfo 变化(provider/model/维度)时 drop 向量表并返回 needsReindex。
85
+ */
86
+ init(providerInfo) {
87
+ // 构造期已降级(开库失败)→ 直接短路
88
+ if (this.degraded)
89
+ return { needsReindex: false, reason: 'database open failed' };
90
+ // dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义)
91
+ if (this.dimensions > 0) {
92
+ try {
93
+ const sqliteVec = require('sqlite-vec');
94
+ this.db.enableLoadExtension(true);
95
+ sqliteVec.load(this.db);
96
+ this.db.enableLoadExtension(false);
97
+ this.vecLoaded = true;
98
+ }
99
+ catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ this.logger?.warn(`${TAG} sqlite-vec 加载失败,向量检索停用(降级为纯 FTS): ${message}`);
102
+ }
103
+ }
104
+ try {
105
+ return this.initSchema(providerInfo);
106
+ }
107
+ catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ this.logger?.error(`${TAG} schema 初始化失败,存储进入降级模式: ${message}`);
110
+ this.degraded = true;
111
+ return { needsReindex: false, reason: `schema init failed: ${message}` };
112
+ }
113
+ }
114
+ initSchema(providerInfo) {
115
+ this.db.exec(`
116
+ CREATE TABLE IF NOT EXISTS embedding_meta (
117
+ key TEXT PRIMARY KEY,
118
+ value TEXT NOT NULL
119
+ )
120
+ `);
121
+ // ── embedding 配置变化检测 → 重建向量表 ──
122
+ let needsReindex = false;
123
+ let reason;
124
+ const savedMeta = this.readEmbeddingMeta();
125
+ if (providerInfo) {
126
+ if (savedMeta) {
127
+ const providerChanged = savedMeta.provider !== providerInfo.provider;
128
+ const modelChanged = savedMeta.model !== providerInfo.model;
129
+ const dimsChanged = savedMeta.dimensions !== this.dimensions;
130
+ if (providerChanged || modelChanged || dimsChanged) {
131
+ const reasons = [];
132
+ if (providerChanged)
133
+ reasons.push(`provider: ${savedMeta.provider} → ${providerInfo.provider}`);
134
+ if (modelChanged)
135
+ reasons.push(`model: ${savedMeta.model} → ${providerInfo.model}`);
136
+ if (dimsChanged)
137
+ reasons.push(`dimensions: ${savedMeta.dimensions} → ${this.dimensions}`);
138
+ reason = reasons.join(', ');
139
+ this.logger?.info(`${TAG} embedding 配置变化(${reason}),重建向量表`);
140
+ this.dropVectorTables();
141
+ needsReindex = true;
142
+ }
143
+ }
144
+ else if (this.countL1() > 0 || this.countL0() > 0) {
145
+ // 已有数据但无 meta 记录(首次启用 embedding)→ 需要全量重嵌入
146
+ this.dropVectorTables();
147
+ needsReindex = true;
148
+ reason = 'embedding 首次启用,已有数据需要重嵌入';
149
+ }
150
+ }
151
+ // ── L1 schema ──
152
+ this.db.exec(`
153
+ CREATE TABLE IF NOT EXISTS l1_records (
154
+ record_id TEXT PRIMARY KEY,
155
+ content TEXT NOT NULL,
156
+ type TEXT DEFAULT '',
157
+ priority INTEGER DEFAULT 50,
158
+ scene_name TEXT DEFAULT '',
159
+ session_id TEXT DEFAULT 'default',
160
+ version INTEGER NOT NULL DEFAULT 0,
161
+ timestamp_str TEXT DEFAULT '',
162
+ timestamp_start TEXT DEFAULT '',
163
+ timestamp_end TEXT DEFAULT '',
164
+ created_time TEXT DEFAULT '',
165
+ updated_time TEXT DEFAULT '',
166
+ metadata_json TEXT DEFAULT '{}',
167
+ family TEXT NOT NULL DEFAULT 'chat'
168
+ )
169
+ `);
170
+ // 旧库缺 family 列 → ALTER 补列,并按 type 前缀回填(幂等:已正确的行不再命中)
171
+ if (!this.hasColumn('l1_records', 'family')) {
172
+ this.db.exec("ALTER TABLE l1_records ADD COLUMN family TEXT NOT NULL DEFAULT 'chat'");
173
+ this.logger?.info(`${TAG} l1_records 补 family 列(旧数据按 type 前缀回填)`);
174
+ }
175
+ const backfilled = this.db
176
+ .prepare("UPDATE l1_records SET family = 'work' WHERE type LIKE 'work\\_%' ESCAPE '\\' AND family != 'work'")
177
+ .run().changes;
178
+ if (backfilled > 0)
179
+ this.logger?.info(`${TAG} family 回填 ${backfilled} 条 work 记录`);
180
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l1_type ON l1_records(type)');
181
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l1_scene ON l1_records(scene_name)');
182
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l1_ts_start ON l1_records(timestamp_start)');
183
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l1_updated ON l1_records(updated_time)');
184
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l1_family ON l1_records(family)');
185
+ this.stmtUpsertL1 = this.db.prepare(`
186
+ INSERT INTO l1_records (
187
+ record_id, content, type, priority, scene_name, session_id, version,
188
+ timestamp_str, timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family
189
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
190
+ ON CONFLICT(record_id) DO UPDATE SET
191
+ content=excluded.content,
192
+ type=excluded.type,
193
+ priority=excluded.priority,
194
+ scene_name=excluded.scene_name,
195
+ version=excluded.version,
196
+ timestamp_str=excluded.timestamp_str,
197
+ timestamp_start=excluded.timestamp_start,
198
+ timestamp_end=excluded.timestamp_end,
199
+ updated_time=excluded.updated_time,
200
+ metadata_json=excluded.metadata_json,
201
+ family=excluded.family
202
+ `);
203
+ this.stmtGetL1 = this.db.prepare(`
204
+ SELECT record_id, content, type, priority, scene_name, version, timestamp_str,
205
+ timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family
206
+ FROM l1_records WHERE record_id = ?
207
+ `);
208
+ this.stmtDeleteL1Meta = this.db.prepare('DELETE FROM l1_records WHERE record_id = ?');
209
+ this.prepareL1VecStatements();
210
+ // ── L0 schema ──
211
+ this.db.exec(`
212
+ CREATE TABLE IF NOT EXISTS l0_conversations (
213
+ record_id TEXT PRIMARY KEY,
214
+ session_id TEXT DEFAULT 'default',
215
+ role TEXT NOT NULL DEFAULT '',
216
+ message_text TEXT NOT NULL,
217
+ recorded_at TEXT DEFAULT '',
218
+ timestamp INTEGER DEFAULT 0
219
+ )
220
+ `);
221
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l0_session_id ON l0_conversations(session_id)');
222
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l0_recorded ON l0_conversations(recorded_at)');
223
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_l0_timestamp ON l0_conversations(timestamp)');
224
+ this.stmtUpsertL0 = this.db.prepare(`
225
+ INSERT INTO l0_conversations (record_id, session_id, role, message_text, recorded_at, timestamp)
226
+ VALUES (?, ?, ?, ?, ?, ?)
227
+ ON CONFLICT(record_id) DO UPDATE SET
228
+ session_id=excluded.session_id,
229
+ role=excluded.role,
230
+ message_text=excluded.message_text,
231
+ recorded_at=excluded.recorded_at,
232
+ timestamp=excluded.timestamp
233
+ `);
234
+ this.prepareL0VecStatements();
235
+ // ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
236
+ try {
237
+ // 旧 l1_fts 无 family 列(FTS5 无法 ALTER)→ drop 后从 l1_records 全量重建
238
+ let ftsRebuilt = false;
239
+ if (this.tableExists('l1_fts') && !this.hasColumn('l1_fts', 'family')) {
240
+ this.db.exec('DROP TABLE l1_fts');
241
+ ftsRebuilt = true;
242
+ this.logger?.info(`${TAG} l1_fts 缺 family 列,重建全文索引`);
243
+ }
244
+ this.db.exec(`
245
+ CREATE VIRTUAL TABLE IF NOT EXISTS l1_fts USING fts5(
246
+ content,
247
+ content_original UNINDEXED,
248
+ record_id UNINDEXED,
249
+ type UNINDEXED,
250
+ priority UNINDEXED,
251
+ scene_name UNINDEXED,
252
+ session_id UNINDEXED,
253
+ version UNINDEXED,
254
+ timestamp_str UNINDEXED,
255
+ timestamp_start UNINDEXED,
256
+ timestamp_end UNINDEXED,
257
+ metadata_json UNINDEXED,
258
+ family UNINDEXED
259
+ )
260
+ `);
261
+ this.db.exec(`
262
+ CREATE VIRTUAL TABLE IF NOT EXISTS l0_fts USING fts5(
263
+ message_text,
264
+ message_text_original UNINDEXED,
265
+ record_id UNINDEXED,
266
+ session_id UNINDEXED,
267
+ role UNINDEXED,
268
+ recorded_at UNINDEXED,
269
+ timestamp UNINDEXED
270
+ )
271
+ `);
272
+ this.stmtL1FtsInsert = this.db.prepare(`
273
+ INSERT INTO l1_fts (content, content_original, record_id, type, priority, scene_name,
274
+ session_id, version, timestamp_str, timestamp_start, timestamp_end, metadata_json, family)
275
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
276
+ `);
277
+ this.stmtL1FtsDelete = this.db.prepare('DELETE FROM l1_fts WHERE record_id = ?');
278
+ this.stmtL1FtsSearch = this.db.prepare(`
279
+ SELECT record_id, content_original AS content, type, priority, scene_name, version,
280
+ timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
281
+ bm25(l1_fts) AS rank
282
+ FROM l1_fts
283
+ WHERE l1_fts MATCH ?
284
+ ORDER BY rank ASC
285
+ LIMIT ?
286
+ `);
287
+ // 族过滤版(FTS5 UNINDEXED 列可作行级过滤条件)
288
+ this.stmtL1FtsSearchFamily = this.db.prepare(`
289
+ SELECT record_id, content_original AS content, type, priority, scene_name, version,
290
+ timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
291
+ bm25(l1_fts) AS rank
292
+ FROM l1_fts
293
+ WHERE l1_fts MATCH ? AND family = ?
294
+ ORDER BY rank ASC
295
+ LIMIT ?
296
+ `);
297
+ if (ftsRebuilt)
298
+ this.backfillL1Fts();
299
+ this.stmtL0FtsInsert = this.db.prepare(`
300
+ INSERT INTO l0_fts (message_text, message_text_original, record_id, session_id, role, recorded_at, timestamp)
301
+ VALUES (?, ?, ?, ?, ?, ?, ?)
302
+ `);
303
+ this.stmtL0FtsDelete = this.db.prepare('DELETE FROM l0_fts WHERE record_id = ?');
304
+ this.stmtL0FtsSearch = this.db.prepare(`
305
+ SELECT record_id, message_text_original AS message_text, session_id, role, recorded_at, timestamp,
306
+ bm25(l0_fts) AS rank
307
+ FROM l0_fts
308
+ WHERE l0_fts MATCH ?
309
+ ORDER BY rank ASC
310
+ LIMIT ?
311
+ `);
312
+ this.ftsAvailable = true;
313
+ }
314
+ catch (err) {
315
+ this.ftsAvailable = false;
316
+ this.logger?.warn(`${TAG} FTS5 不可用(可能未编译进 SQLite): ${err instanceof Error ? err.message : String(err)}`);
317
+ }
318
+ if (providerInfo && this.countL1() === 0 && this.countL0() === 0) {
319
+ // 空库 + 全新 meta:无历史向量,直接标记同步完成
320
+ this.writeEmbeddingMeta(providerInfo);
321
+ }
322
+ return { needsReindex, reason };
323
+ }
324
+ prepareL1VecStatements() {
325
+ if (this.vecLoaded && this.dimensions > 0) {
326
+ this.db.exec(`
327
+ CREATE VIRTUAL TABLE IF NOT EXISTS l1_vec USING vec0(
328
+ record_id TEXT PRIMARY KEY,
329
+ embedding float[${this.dimensions}] distance_metric=cosine,
330
+ updated_time TEXT DEFAULT ''
331
+ )
332
+ `);
333
+ this.stmtDeleteL1Vec = this.db.prepare('DELETE FROM l1_vec WHERE record_id = ?');
334
+ this.stmtInsertL1Vec = this.db.prepare('INSERT INTO l1_vec (record_id, embedding, updated_time) VALUES (?, ?, ?)');
335
+ this.stmtSearchL1Vec = this.db.prepare(`
336
+ SELECT record_id, distance
337
+ FROM l1_vec
338
+ WHERE embedding MATCH ?
339
+ AND k = ?
340
+ ORDER BY distance
341
+ `);
342
+ }
343
+ else {
344
+ this.stmtDeleteL1Vec = undefined;
345
+ this.stmtInsertL1Vec = undefined;
346
+ this.stmtSearchL1Vec = undefined;
347
+ }
348
+ }
349
+ prepareL0VecStatements() {
350
+ if (this.vecLoaded && this.dimensions > 0) {
351
+ this.db.exec(`
352
+ CREATE VIRTUAL TABLE IF NOT EXISTS l0_vec USING vec0(
353
+ record_id TEXT PRIMARY KEY,
354
+ embedding float[${this.dimensions}] distance_metric=cosine,
355
+ recorded_at TEXT DEFAULT ''
356
+ )
357
+ `);
358
+ this.stmtDeleteL0Vec = this.db.prepare('DELETE FROM l0_vec WHERE record_id = ?');
359
+ this.stmtInsertL0Vec = this.db.prepare('INSERT INTO l0_vec (record_id, embedding, recorded_at) VALUES (?, ?, ?)');
360
+ this.stmtSearchL0Vec = this.db.prepare(`
361
+ SELECT record_id, distance
362
+ FROM l0_vec
363
+ WHERE embedding MATCH ?
364
+ AND k = ?
365
+ ORDER BY distance
366
+ `);
367
+ }
368
+ else {
369
+ this.stmtDeleteL0Vec = undefined;
370
+ this.stmtInsertL0Vec = undefined;
371
+ this.stmtSearchL0Vec = undefined;
372
+ }
373
+ }
374
+ dropVectorTables() {
375
+ this.db.exec('DROP TABLE IF EXISTS l1_vec');
376
+ this.db.exec('DROP TABLE IF EXISTS l0_vec');
377
+ this.prepareL1VecStatements();
378
+ this.prepareL0VecStatements();
379
+ }
380
+ tableExists(table) {
381
+ const row = this.db
382
+ .prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name = ?")
383
+ .get(table);
384
+ return row !== undefined;
385
+ }
386
+ hasColumn(table, column) {
387
+ const rows = this.db.prepare(`PRAGMA table_info(${table})`).all();
388
+ return rows.some((r) => r.name === column);
389
+ }
390
+ /** 重建后的 l1_fts 从 l1_records 全量回灌(仅在 drop 重建时调用)。 */
391
+ backfillL1Fts() {
392
+ const rows = this.db
393
+ .prepare(`SELECT record_id, content, type, priority, scene_name, session_id, version,
394
+ timestamp_str, timestamp_start, timestamp_end, metadata_json, family FROM l1_records`)
395
+ .all();
396
+ for (const r of rows) {
397
+ try {
398
+ this.stmtL1FtsInsert.run(tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'));
399
+ }
400
+ catch {
401
+ /* 单行失败跳过 */
402
+ }
403
+ }
404
+ if (rows.length > 0)
405
+ this.logger?.info(`${TAG} l1_fts 回灌 ${rows.length} 行`);
406
+ }
407
+ readEmbeddingMeta() {
408
+ try {
409
+ const row = this.db
410
+ .prepare('SELECT value FROM embedding_meta WHERE key = ?')
411
+ .get('embedding_provider_info');
412
+ if (!row)
413
+ return undefined;
414
+ const parsed = JSON.parse(row.value);
415
+ if (typeof parsed.provider !== 'string')
416
+ return undefined;
417
+ return {
418
+ provider: parsed.provider,
419
+ model: parsed.model ?? '',
420
+ dimensions: parsed.dimensions ?? 0,
421
+ };
422
+ }
423
+ catch {
424
+ return undefined;
425
+ }
426
+ }
427
+ writeEmbeddingMeta(info) {
428
+ this.db
429
+ .prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
430
+ .run('embedding_provider_info', JSON.stringify(info));
431
+ }
432
+ /**
433
+ * 标记当前向量与 embedding 配置同步完成(持久化 meta)。
434
+ * 只应在重嵌入成功(或空库无历史向量)后调用——过早写入会让下次启动
435
+ * 比对通过而跳过补齐,向量表永远空着(review P7)。
436
+ */
437
+ markEmbeddingSynced(info) {
438
+ if (this.degraded)
439
+ return;
440
+ try {
441
+ this.writeEmbeddingMeta(info);
442
+ }
443
+ catch (err) {
444
+ this.logger?.warn(`${TAG} embedding meta 写入失败: ${err instanceof Error ? err.message : String(err)}`);
445
+ }
446
+ }
447
+ // ============================
448
+ // L1 写入 / 删除 / 读取
449
+ // ============================
450
+ /** upsert 一条 L1(元数据 + FTS 同步;embedding 非零时写向量)。失败返回 false 不抛。 */
451
+ upsertL1(record, embedding) {
452
+ if (this.degraded)
453
+ return false;
454
+ try {
455
+ const ts = timestampsToDb(record.timestamps);
456
+ this.db.exec('BEGIN');
457
+ try {
458
+ this.stmtUpsertL1.run(record.id, record.content, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
459
+ // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
460
+ if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
461
+ this.stmtDeleteL1Vec.run(record.id);
462
+ if (embedding && !isZeroVector(embedding)) {
463
+ this.stmtInsertL1Vec.run(record.id, vecToBuffer(embedding), toIso(record.updatedAt));
464
+ }
465
+ }
466
+ if (this.ftsAvailable) {
467
+ try {
468
+ this.stmtL1FtsDelete.run(record.id);
469
+ this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
470
+ }
471
+ catch (ftsErr) {
472
+ this.logger?.warn(`${TAG} L1 FTS 写入失败(非致命)id=${record.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
473
+ }
474
+ }
475
+ this.db.exec('COMMIT');
476
+ }
477
+ catch (err) {
478
+ try {
479
+ this.db.exec('ROLLBACK');
480
+ }
481
+ catch {
482
+ /* ignore */
483
+ }
484
+ throw err;
485
+ }
486
+ return true;
487
+ }
488
+ catch (err) {
489
+ this.logger?.warn(`${TAG} L1 upsert 失败(非致命)id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
490
+ return false;
491
+ }
492
+ }
493
+ /** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。 */
494
+ deleteL1Batch(ids) {
495
+ if (this.degraded || ids.length === 0)
496
+ return 0;
497
+ try {
498
+ const placeholders = ids.map(() => '?').join(',');
499
+ this.db.exec('BEGIN');
500
+ this.db.prepare(`DELETE FROM l1_records WHERE record_id IN (${placeholders})`).run(...ids);
501
+ if (this.stmtDeleteL1Vec) {
502
+ this.db.prepare(`DELETE FROM l1_vec WHERE record_id IN (${placeholders})`).run(...ids);
503
+ }
504
+ if (this.ftsAvailable) {
505
+ this.db.prepare(`DELETE FROM l1_fts WHERE record_id IN (${placeholders})`).run(...ids);
506
+ }
507
+ this.db.exec('COMMIT');
508
+ return ids.length;
509
+ }
510
+ catch (err) {
511
+ try {
512
+ this.db.exec('ROLLBACK');
513
+ }
514
+ catch {
515
+ /* ignore */
516
+ }
517
+ this.logger?.warn(`${TAG} L1 批量删除失败: ${err instanceof Error ? err.message : String(err)}`);
518
+ return 0;
519
+ }
520
+ }
521
+ countL1() {
522
+ if (this.degraded)
523
+ return 0;
524
+ try {
525
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM l1_records').get();
526
+ return row?.n ?? 0;
527
+ }
528
+ catch {
529
+ // 建表前(init 的 embedding 变更检测)会先调用计数
530
+ return 0;
531
+ }
532
+ }
533
+ /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
534
+ getAllL1() {
535
+ if (this.degraded)
536
+ return [];
537
+ const rows = this.db
538
+ .prepare('SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family FROM l1_records')
539
+ .all();
540
+ return rows.map(rowToRecord);
541
+ }
542
+ getL1ByIds(ids) {
543
+ if (this.degraded || ids.length === 0)
544
+ return [];
545
+ const placeholders = ids.map(() => '?').join(',');
546
+ const rows = this.db
547
+ .prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family FROM l1_records WHERE record_id IN (${placeholders})`)
548
+ .all(...ids);
549
+ return rows.map(rowToRecord);
550
+ }
551
+ /** 浏览列表(UI 用):按更新时间倒序,支持类型/场景/族过滤与分页。失败返回空。 */
552
+ listL1(opts) {
553
+ if (this.degraded)
554
+ return { items: [], total: 0 };
555
+ try {
556
+ const where = [];
557
+ const params = [];
558
+ if (opts.type) {
559
+ where.push('type = ?');
560
+ params.push(opts.type);
561
+ }
562
+ if (opts.scene) {
563
+ where.push('scene_name = ?');
564
+ params.push(opts.scene);
565
+ }
566
+ if (opts.family) {
567
+ where.push('family = ?');
568
+ params.push(opts.family);
569
+ }
570
+ const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
571
+ const totalRow = this.db.prepare(`SELECT COUNT(*) AS n FROM l1_records${whereSql}`).get(...params);
572
+ const rows = this.db
573
+ .prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family FROM l1_records${whereSql} ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
574
+ .all(...params, opts.limit, opts.offset);
575
+ return { items: rows.map(rowToRecord), total: totalRow?.n ?? 0 };
576
+ }
577
+ catch (err) {
578
+ this.logger?.warn(`${TAG} L1 浏览列表查询失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
579
+ return { items: [], total: 0 };
580
+ }
581
+ }
582
+ /** 场景名去重列表(UI 筛选器数据源)。失败返回空。 */
583
+ distinctL1Scenes() {
584
+ if (this.degraded)
585
+ return [];
586
+ try {
587
+ const rows = this.db
588
+ .prepare('SELECT DISTINCT scene_name FROM l1_records ORDER BY scene_name')
589
+ .all();
590
+ return rows.map((r) => r.scene_name).filter(Boolean);
591
+ }
592
+ catch {
593
+ return [];
594
+ }
595
+ }
596
+ // ============================
597
+ // L1 检索
598
+ // ============================
599
+ /** FTS5 BM25 检索(family 缺省不过滤)。失败返回空数组(调用方降级)。 */
600
+ searchL1Fts(query, limit, family) {
601
+ if (this.degraded || !this.ftsAvailable)
602
+ return [];
603
+ const ftsQuery = buildFtsQuery(query);
604
+ if (!ftsQuery)
605
+ return [];
606
+ try {
607
+ const rows = (family
608
+ ? this.stmtL1FtsSearchFamily.all(ftsQuery, family, limit)
609
+ : this.stmtL1FtsSearch.all(ftsQuery, limit));
610
+ return rows.map((r) => ({
611
+ id: r.record_id,
612
+ content: r.content,
613
+ type: r.type,
614
+ priority: r.priority,
615
+ scene_name: r.scene_name,
616
+ family: normFamily(r.family, r.type),
617
+ score: bm25RankToScore(r.rank),
618
+ }));
619
+ }
620
+ catch (err) {
621
+ this.logger?.warn(`${TAG} L1 FTS 检索失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
622
+ return [];
623
+ }
624
+ }
625
+ /** vec0 余弦 KNN 检索(score = 1 - cosine distance;family 过滤走过度召回 + 回查过滤,vec0 无法 WHERE)。失败返回空数组。 */
626
+ searchL1Vector(embedding, topK, family) {
627
+ if (this.degraded || !this.stmtSearchL1Vec)
628
+ return [];
629
+ try {
630
+ // 过度召回补偿遗留零向量;带族过滤时再放大(不命中本族的行会被丢弃)
631
+ const retrieveCount = (topK + ZERO_VEC_BUFFER) * (family ? 3 : 1);
632
+ const rows = this.stmtSearchL1Vec.all(vecToBuffer(embedding), retrieveCount);
633
+ const hits = [];
634
+ for (const { record_id, distance } of rows) {
635
+ if (distance == null || Number.isNaN(distance))
636
+ continue;
637
+ const meta = this.stmtGetL1.get(record_id);
638
+ if (!meta)
639
+ continue;
640
+ if (family && normFamily(meta.family, meta.type) !== family)
641
+ continue;
642
+ hits.push({
643
+ id: record_id,
644
+ content: meta.content,
645
+ type: meta.type,
646
+ priority: meta.priority,
647
+ scene_name: meta.scene_name,
648
+ family: normFamily(meta.family, meta.type),
649
+ score: 1.0 - distance,
650
+ });
651
+ }
652
+ return hits.slice(0, topK);
653
+ }
654
+ catch (err) {
655
+ this.logger?.warn(`${TAG} L1 向量检索失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
656
+ return [];
657
+ }
658
+ }
659
+ // ============================
660
+ // L0 写入 / 删除 / 读取
661
+ // ============================
662
+ /** 批量 upsert L0 消息(元数据 + FTS;embeddings 与 records 等长,可省略)。 */
663
+ upsertL0Batch(records, embeddings) {
664
+ if (this.degraded || records.length === 0)
665
+ return false;
666
+ try {
667
+ this.db.exec('BEGIN');
668
+ for (let i = 0; i < records.length; i++) {
669
+ const r = records[i];
670
+ this.stmtUpsertL0.run(r.id, r.sessionId, r.role, r.content, r.recordedAt, r.timestamp);
671
+ if (this.stmtDeleteL0Vec && this.stmtInsertL0Vec) {
672
+ this.stmtDeleteL0Vec.run(r.id);
673
+ const vec = embeddings?.[i];
674
+ if (vec && !isZeroVector(vec)) {
675
+ this.stmtInsertL0Vec.run(r.id, vecToBuffer(vec), r.recordedAt);
676
+ }
677
+ }
678
+ if (this.ftsAvailable) {
679
+ try {
680
+ this.stmtL0FtsDelete.run(r.id);
681
+ this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
682
+ }
683
+ catch (ftsErr) {
684
+ this.logger?.warn(`${TAG} L0 FTS 写入失败(非致命)id=${r.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
685
+ }
686
+ }
687
+ }
688
+ this.db.exec('COMMIT');
689
+ return true;
690
+ }
691
+ catch (err) {
692
+ try {
693
+ this.db.exec('ROLLBACK');
694
+ }
695
+ catch {
696
+ /* ignore */
697
+ }
698
+ this.logger?.warn(`${TAG} L0 批量写入失败: ${err instanceof Error ? err.message : String(err)}`);
699
+ return false;
700
+ }
701
+ }
702
+ countL0() {
703
+ if (this.degraded)
704
+ return 0;
705
+ try {
706
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM l0_conversations').get();
707
+ return row?.n ?? 0;
708
+ }
709
+ catch {
710
+ return 0;
711
+ }
712
+ }
713
+ /** 统计 recorded_at >= iso 的消息数(状态面板"今日捕获"用)。 */
714
+ countL0Since(iso) {
715
+ if (this.degraded)
716
+ return 0;
717
+ try {
718
+ const row = this.db
719
+ .prepare('SELECT COUNT(*) AS n FROM l0_conversations WHERE recorded_at >= ?')
720
+ .get(iso);
721
+ return row?.n ?? 0;
722
+ }
723
+ catch {
724
+ return 0;
725
+ }
726
+ }
727
+ /** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
728
+ countL1Vec() {
729
+ if (this.degraded || !this.stmtSearchL1Vec)
730
+ return -1;
731
+ try {
732
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM l1_vec').get();
733
+ return row?.n ?? -1;
734
+ }
735
+ catch {
736
+ return -1;
737
+ }
738
+ }
739
+ countL0Vec() {
740
+ if (this.degraded || !this.stmtSearchL0Vec)
741
+ return -1;
742
+ try {
743
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM l0_vec').get();
744
+ return row?.n ?? -1;
745
+ }
746
+ catch {
747
+ return -1;
748
+ }
749
+ }
750
+ // ============================
751
+ // L0 检索
752
+ // ============================
753
+ searchL0Fts(query, limit) {
754
+ if (this.degraded || !this.ftsAvailable)
755
+ return [];
756
+ const ftsQuery = buildFtsQuery(query);
757
+ if (!ftsQuery)
758
+ return [];
759
+ try {
760
+ const rows = this.stmtL0FtsSearch.all(ftsQuery, limit);
761
+ return rows.map((r) => ({
762
+ sessionId: r.session_id,
763
+ recordedAt: r.recorded_at,
764
+ id: r.record_id,
765
+ role: r.role,
766
+ content: r.message_text,
767
+ timestamp: r.timestamp ?? 0,
768
+ score: bm25RankToScore(r.rank),
769
+ }));
770
+ }
771
+ catch (err) {
772
+ this.logger?.warn(`${TAG} L0 FTS 检索失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
773
+ return [];
774
+ }
775
+ }
776
+ searchL0Vector(embedding, topK) {
777
+ if (this.degraded || !this.stmtSearchL0Vec)
778
+ return [];
779
+ try {
780
+ const retrieveCount = topK + ZERO_VEC_BUFFER;
781
+ const rows = this.stmtSearchL0Vec.all(vecToBuffer(embedding), retrieveCount);
782
+ const hits = [];
783
+ for (const { record_id, distance } of rows) {
784
+ if (distance == null || Number.isNaN(distance))
785
+ continue;
786
+ const row = this.db
787
+ .prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?')
788
+ .get(record_id);
789
+ if (!row)
790
+ continue;
791
+ hits.push({
792
+ sessionId: row.session_id,
793
+ recordedAt: row.recorded_at,
794
+ id: record_id,
795
+ role: row.role,
796
+ content: row.message_text,
797
+ timestamp: row.timestamp ?? 0,
798
+ score: 1.0 - distance,
799
+ });
800
+ }
801
+ return hits.slice(0, topK);
802
+ }
803
+ catch (err) {
804
+ this.logger?.warn(`${TAG} L0 向量检索失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
805
+ return [];
806
+ }
807
+ }
808
+ // ============================
809
+ // 重嵌入(reindexAll 用)
810
+ // ============================
811
+ getL1ForReindex() {
812
+ if (this.degraded)
813
+ return [];
814
+ return this.db
815
+ .prepare('SELECT record_id AS id, content FROM l1_records')
816
+ .all();
817
+ }
818
+ getL0ForReindex() {
819
+ if (this.degraded)
820
+ return [];
821
+ return this.db
822
+ .prepare('SELECT record_id AS id, message_text AS text FROM l0_conversations')
823
+ .all();
824
+ }
825
+ /** 只更新向量行(重嵌入用)。 */
826
+ updateL1Vec(id, embedding) {
827
+ if (this.degraded || !this.stmtDeleteL1Vec || !this.stmtInsertL1Vec)
828
+ return false;
829
+ if (isZeroVector(embedding))
830
+ return false;
831
+ try {
832
+ this.stmtDeleteL1Vec.run(id);
833
+ this.stmtInsertL1Vec.run(id, vecToBuffer(embedding), new Date().toISOString());
834
+ return true;
835
+ }
836
+ catch {
837
+ return false;
838
+ }
839
+ }
840
+ updateL0Vec(id, embedding, recordedAt) {
841
+ if (this.degraded || !this.stmtDeleteL0Vec || !this.stmtInsertL0Vec)
842
+ return false;
843
+ if (isZeroVector(embedding))
844
+ return false;
845
+ try {
846
+ this.stmtDeleteL0Vec.run(id);
847
+ this.stmtInsertL0Vec.run(id, vecToBuffer(embedding), recordedAt);
848
+ return true;
849
+ }
850
+ catch {
851
+ return false;
852
+ }
853
+ }
854
+ close() {
855
+ try {
856
+ this.db.close();
857
+ }
858
+ catch {
859
+ /* ignore */
860
+ }
861
+ }
862
+ }
863
+ function rowToRecord(row) {
864
+ let metadata = {};
865
+ try {
866
+ metadata = JSON.parse(row.metadata_json || '{}');
867
+ }
868
+ catch {
869
+ /* 坏 JSON 容忍 */
870
+ }
871
+ return {
872
+ id: row.record_id,
873
+ content: row.content,
874
+ type: row.type,
875
+ priority: row.priority,
876
+ scene_name: row.scene_name,
877
+ timestamps: dbToTimestamps(row.timestamp_str),
878
+ createdAt: Date.parse(row.created_time) || 0,
879
+ updatedAt: Date.parse(row.updated_time) || 0,
880
+ version: row.version ?? 0,
881
+ metadata,
882
+ family: normFamily(row.family, row.type),
883
+ };
884
+ }
885
+ function timestampsToDb(ts) {
886
+ if (!ts || ts.length === 0)
887
+ return { str: '', start: '', end: '' };
888
+ const sorted = [...ts].filter((t) => Number.isFinite(t)).sort((a, b) => a - b);
889
+ if (sorted.length === 0)
890
+ return { str: '', start: '', end: '' };
891
+ const isos = sorted.map((t) => new Date(t).toISOString());
892
+ return { str: isos.join(','), start: isos[0], end: isos[isos.length - 1] };
893
+ }
894
+ function dbToTimestamps(str) {
895
+ if (!str)
896
+ return [];
897
+ return str
898
+ .split(',')
899
+ .map((t) => Date.parse(t))
900
+ .filter((t) => !Number.isNaN(t));
901
+ }
902
+ function toIso(epochMs) {
903
+ if (!epochMs || !Number.isFinite(epochMs))
904
+ return '';
905
+ return new Date(epochMs).toISOString();
906
+ }
907
+ function isZeroVector(vec) {
908
+ for (const v of vec) {
909
+ if (v !== 0)
910
+ return false;
911
+ }
912
+ return true;
913
+ }
914
+ /** DB 字符串 → 族(异常值按 type 前缀兜底归一)。 */
915
+ function normFamily(raw, type) {
916
+ if (raw === 'work' || raw === 'chat')
917
+ return raw;
918
+ return familyForType(type);
919
+ }
920
+ function vecToBuffer(vec) {
921
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
922
+ }