dsh-layered-memory 0.6.1 → 0.7.1

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 (49) hide show
  1. package/README.en.md +106 -81
  2. package/README.md +93 -80
  3. package/assets/img/Hero.png +0 -0
  4. package/assets/img/Layers.png +0 -0
  5. package/assets/img/Modes.png +0 -0
  6. package/assets/img/ui-dark.jpg +0 -0
  7. package/assets/img/ui-light.jpg +0 -0
  8. package/assets/readme/flow.svg +189 -0
  9. package/assets/readme/storage.svg +115 -0
  10. package/dist/client.js +1100 -402
  11. package/dist/config.d.ts +12 -0
  12. package/dist/config.js +20 -17
  13. package/dist/hooks/capture.d.ts +17 -1
  14. package/dist/hooks/capture.js +45 -11
  15. package/dist/hooks/recall.d.ts +7 -0
  16. package/dist/hooks/recall.js +20 -4
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.js +107 -44
  19. package/dist/pipeline/rebuild.d.ts +2 -0
  20. package/dist/pipeline/rebuild.js +7 -0
  21. package/dist/pipeline/runner.d.ts +7 -1
  22. package/dist/pipeline/runner.js +22 -7
  23. package/dist/runtime-package-lock.json +982 -0
  24. package/dist/settings.js +79 -20
  25. package/dist/stats.d.ts +7 -1
  26. package/dist/stats.js +131 -14
  27. package/dist/store/download-queue.d.ts +71 -0
  28. package/dist/store/download-queue.js +313 -0
  29. package/dist/store/embedding-source.d.ts +160 -0
  30. package/dist/store/embedding-source.js +421 -0
  31. package/dist/store/embedding.d.ts +3 -1
  32. package/dist/store/embedding.js +5 -0
  33. package/dist/store/l0.d.ts +13 -5
  34. package/dist/store/l0.js +40 -10
  35. package/dist/store/l1.d.ts +15 -5
  36. package/dist/store/l1.js +47 -19
  37. package/dist/store/local-embedding.d.ts +64 -0
  38. package/dist/store/local-embedding.js +120 -0
  39. package/dist/store/model-catalog.d.ts +45 -0
  40. package/dist/store/model-catalog.js +78 -0
  41. package/dist/store/runtime-installer.d.ts +65 -0
  42. package/dist/store/runtime-installer.js +220 -0
  43. package/dist/store/sqlite.d.ts +68 -7
  44. package/dist/store/sqlite.js +422 -73
  45. package/dist/tools/index.js +6 -4
  46. package/dist/util/filelog.d.ts +2 -0
  47. package/dist/util/filelog.js +20 -3
  48. package/package.json +1 -1
  49. package/assets/readme/hero.svg +0 -58
@@ -23,11 +23,24 @@ const require = createRequire(import.meta.url);
23
23
  const TAG = '[memory][sqlite]';
24
24
  /** vec0 KNN 对遗留零向量的补偿缓冲(官方同款)。 */
25
25
  const ZERO_VEC_BUFFER = 10;
26
+ /** IN 查询/删除的分块大小(保守避开 SQLite 变量数上限:现代构建 32766,老版 999)。 */
27
+ const IN_CHUNK = 900;
28
+ /** 零向量 skip 集上限(≤ IN_CHUNK:notInClause 不分块,占位符数即集合大小,须避开老构建 999 上限)。 */
29
+ const VEC_SKIP_CAP = 900;
30
+ /** 把 id 列表切成 ≤IN_CHUNK 的块(分块执行后合并语义等价于单次 IN)。 */
31
+ function chunkIds(ids) {
32
+ const out = [];
33
+ for (let i = 0; i < ids.length; i += IN_CHUNK)
34
+ out.push(ids.slice(i, i + IN_CHUNK));
35
+ return out;
36
+ }
26
37
  export class MemoryDb {
27
38
  db;
28
39
  degraded = false;
29
40
  ftsAvailable = false;
30
41
  vecLoaded = false;
42
+ vecLoadWarned = false;
43
+ /** 向量维度:活切换嵌入源(D5)时会变——vec0 表随维度重建。 */
31
44
  dimensions;
32
45
  logger;
33
46
  stmtUpsertL1;
@@ -41,6 +54,7 @@ export class MemoryDb {
41
54
  stmtL1FtsSearch;
42
55
  stmtL1FtsSearchFamily;
43
56
  stmtUpsertL0;
57
+ stmtGetL0;
44
58
  stmtDeleteL0Vec;
45
59
  stmtInsertL0Vec;
46
60
  stmtSearchL0Vec;
@@ -58,9 +72,12 @@ export class MemoryDb {
58
72
  mkdirSync(dbDir, { recursive: true });
59
73
  const { DatabaseSync: DbSync } = require('node:sqlite');
60
74
  this.db = new DbSync(dbPath, { allowExtension: true });
61
- // 并发读优化 + 有界内存(照搬官方 PRAGMA 组合)
75
+ // 并发读优化 + 有界内存(照搬官方 PRAGMA 组合,synchronous 为本仓新增:
76
+ // WAL 下官方推荐 NORMAL——批量写从"每事务一次 fsync"降为"每 checkpoint 一次",
77
+ // 重嵌入/导入提速明显;代价仅是断电时丢最后若干已提交事务(只丢不损,无损坏风险))
62
78
  this.db.exec('PRAGMA busy_timeout = 5000');
63
79
  this.db.exec('PRAGMA journal_mode = WAL');
80
+ this.db.exec('PRAGMA synchronous = NORMAL');
64
81
  this.db.exec('PRAGMA cache_size = -65536');
65
82
  this.db.exec('PRAGMA mmap_size = 134217728');
66
83
  this.db.exec('PRAGMA wal_autocheckpoint = 1000');
@@ -87,28 +104,93 @@ export class MemoryDb {
87
104
  // 构造期已降级(开库失败)→ 直接短路
88
105
  if (this.degraded)
89
106
  return { needsReindex: false, reason: 'database open failed' };
90
- // dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义)
91
- if (this.dimensions > 0) {
107
+ // dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义);
108
+ // 后续活切换本地嵌入(维度 > 0)时由 swapProvider 补加载
109
+ this.ensureVecLoaded();
110
+ try {
111
+ return this.initSchema(providerInfo);
112
+ }
113
+ catch (err) {
114
+ const message = err instanceof Error ? err.message : String(err);
115
+ this.logger?.error(`${TAG} schema 初始化失败,存储进入降级模式: ${message}`);
116
+ this.degraded = true;
117
+ return { needsReindex: false, reason: `schema init failed: ${message}` };
118
+ }
119
+ }
120
+ /** 惰性加载 sqlite-vec(纯 FTS 起步后切本地嵌入时补加载);失败只停用向量能力并告警一次。 */
121
+ ensureVecLoaded() {
122
+ if (this.vecLoaded || this.dimensions <= 0)
123
+ return;
124
+ try {
125
+ const sqliteVec = require('sqlite-vec');
126
+ this.db.enableLoadExtension(true);
92
127
  try {
93
- const sqliteVec = require('sqlite-vec');
94
- this.db.enableLoadExtension(true);
95
128
  sqliteVec.load(this.db);
96
- this.db.enableLoadExtension(false);
97
129
  this.vecLoaded = true;
98
130
  }
99
- catch (err) {
100
- const message = err instanceof Error ? err.message : String(err);
131
+ finally {
132
+ // 加载失败也必须复位扩展开关,不留常开的扩展加载面
133
+ this.db.enableLoadExtension(false);
134
+ }
135
+ }
136
+ catch (err) {
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ if (!this.vecLoadWarned) {
139
+ this.vecLoadWarned = true;
101
140
  this.logger?.warn(`${TAG} sqlite-vec 加载失败,向量检索停用(降级为纯 FTS): ${message}`);
102
141
  }
103
142
  }
143
+ }
144
+ /**
145
+ * 活切换嵌入源(D5):provider/model/维度任一变化 → drop 向量表按新维度重建,
146
+ * 返回 needsReindex=true(调用方后台重嵌,全部成功后 markEmbeddingSynced);
147
+ * 配置未变化 → false(切回同一模型不重嵌)。
148
+ * 新维度 > 0 但 sqlite-vec 不可用 → ok=false(调用方向用户说明,维持 FTS)。
149
+ */
150
+ swapProvider(info) {
151
+ if (this.degraded)
152
+ return { ok: false, needsReindex: false, error: '数据库降级模式' };
153
+ if (info.dimensions <= 0) {
154
+ // 关闭档不走此路径(只换服务实例);防御性兜底:不动表,无需重嵌
155
+ this.dimensions = 0;
156
+ return { ok: true, needsReindex: false };
157
+ }
158
+ // 先置新维度再懒加载(ensureVecLoaded 以 dimensions>0 为门)——0 维起步的库
159
+ // 在首次活切本地/远程嵌入时此处补加载 sqlite-vec
160
+ this.dimensions = info.dimensions;
161
+ this.ensureVecLoaded();
162
+ if (!this.vecLoaded) {
163
+ return { ok: false, needsReindex: false, error: 'sqlite-vec 扩展不可用,无法启用向量检索' };
164
+ }
165
+ // unchanged 判据不能只信 embedding_meta:取消/崩溃路径可能留下"meta=旧 provider、
166
+ // 物理表=新维度"的错位(切换已 drop 并按新维度重建,但重嵌取消/失败没写 meta)。
167
+ // 只比对 meta 会在切回旧源时跳过 drop → 旧维度服务往新维度表写向量 → vec0 抛错
168
+ // → upsert 整体回滚 → 记录从检索库静默消失。必须同时校验物理表的真实维度。
169
+ const saved = this.readEmbeddingMeta();
170
+ const physical = this.physicalVecDims();
171
+ const unchanged = saved &&
172
+ saved.provider === info.provider &&
173
+ saved.model === info.model &&
174
+ saved.dimensions === info.dimensions &&
175
+ physical === info.dimensions;
176
+ if (unchanged)
177
+ return { ok: true, needsReindex: false };
178
+ this.dropVectorTables();
179
+ return { ok: true, needsReindex: true };
180
+ }
181
+ /** l1_vec 物理表的向量维度(建表 DDL 里的 float[N]);无表返回 null。 */
182
+ physicalVecDims() {
104
183
  try {
105
- return this.initSchema(providerInfo);
184
+ const row = this.db
185
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'l1_vec'")
186
+ .get();
187
+ if (!row?.sql)
188
+ return null;
189
+ const m = /float\[(\d+)\]/.exec(row.sql);
190
+ return m ? Number(m[1]) : null;
106
191
  }
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}` };
192
+ catch {
193
+ return null;
112
194
  }
113
195
  }
114
196
  initSchema(providerInfo) {
@@ -231,6 +313,7 @@ export class MemoryDb {
231
313
  recorded_at=excluded.recorded_at,
232
314
  timestamp=excluded.timestamp
233
315
  `);
316
+ this.stmtGetL0 = this.db.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?');
234
317
  this.prepareL0VecStatements();
235
318
  // ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
236
319
  try {
@@ -374,6 +457,11 @@ export class MemoryDb {
374
457
  dropVectorTables() {
375
458
  this.db.exec('DROP TABLE IF EXISTS l1_vec');
376
459
  this.db.exec('DROP TABLE IF EXISTS l0_vec');
460
+ // 表已重建:作废按块缓存的 IN 语句(旧语句指向已删表,不能依赖引擎自动重编译兜底)
461
+ this.inStmts.clear();
462
+ // provider/model/维度已变:旧的"不可嵌入"判定作废(新 provider 可能嵌入得了),skip 集清空
463
+ this.clearVecSkipIds('l1');
464
+ this.clearVecSkipIds('l0');
377
465
  this.prepareL1VecStatements();
378
466
  this.prepareL0VecStatements();
379
467
  }
@@ -430,9 +518,10 @@ export class MemoryDb {
430
518
  .run('embedding_provider_info', JSON.stringify(info));
431
519
  }
432
520
  /**
433
- * 标记当前向量与 embedding 配置同步完成(持久化 meta)。
434
- * 只应在重嵌入成功(或空库无历史向量)后调用——过早写入会让下次启动
435
- * 比对通过而跳过补齐,向量表永远空着(review P7)。
521
+ * 持久化 embedding meta(语义:物理向量表当前对应的 provider/维度)。
522
+ * 活切换在 swapProvider 成功后即写(表已是新维度);启动/补齐链在
523
+ * 缺失向量补齐收敛(missing=0)后写——缺失行补齐判据是行数差,
524
+ * 不依赖 meta(review P7 语义在 backfill 计数判据下仍然收敛)。
436
525
  */
437
526
  markEmbeddingSynced(info) {
438
527
  if (this.degraded)
@@ -452,26 +541,9 @@ export class MemoryDb {
452
541
  if (this.degraded)
453
542
  return false;
454
543
  try {
455
- const ts = timestampsToDb(record.timestamps);
456
544
  this.db.exec('BEGIN');
457
545
  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
- }
546
+ this.upsertL1InTx(record, embedding);
475
547
  this.db.exec('COMMIT');
476
548
  }
477
549
  catch (err) {
@@ -490,34 +562,115 @@ export class MemoryDb {
490
562
  return false;
491
563
  }
492
564
  }
493
- /** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。 */
494
- deleteL1Batch(ids) {
495
- if (this.degraded || ids.length === 0)
496
- return 0;
565
+ /**
566
+ * 批量 upsert L1(单事务;与单条同语义:FTS 失败整批回滚)。
567
+ * 追加/导入热路径用它——逐条开事务在 WAL FULL 下每条一次 fsync。
568
+ * 整批失败时回退逐条写入:好记录照常入库、坏记录只丢自身——否则
569
+ * JSONL 事实源已先行追加,检索库却整批缺失且无自动重导路径(批次空洞)。
570
+ */
571
+ upsertL1Batch(records, embeddings) {
572
+ if (this.degraded || records.length === 0)
573
+ return false;
497
574
  try {
498
- const placeholders = ids.map(() => '?').join(',');
499
575
  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);
576
+ try {
577
+ for (let i = 0; i < records.length; i++) {
578
+ this.upsertL1InTx(records[i], embeddings?.[i]);
579
+ }
580
+ this.db.exec('COMMIT');
503
581
  }
504
- if (this.ftsAvailable) {
505
- this.db.prepare(`DELETE FROM l1_fts WHERE record_id IN (${placeholders})`).run(...ids);
582
+ catch (err) {
583
+ try {
584
+ this.db.exec('ROLLBACK');
585
+ }
586
+ catch {
587
+ /* ignore */
588
+ }
589
+ throw err;
506
590
  }
507
- this.db.exec('COMMIT');
508
- return ids.length;
591
+ return true;
509
592
  }
510
593
  catch (err) {
594
+ this.logger?.warn(`${TAG} L1 批量写入失败,回退逐条写入: ${err instanceof Error ? err.message : String(err)}`);
595
+ const failed = [];
596
+ for (let i = 0; i < records.length; i++) {
597
+ if (!this.upsertL1(records[i], embeddings?.[i]))
598
+ failed.push(records[i]?.id ?? `#${i}`);
599
+ }
600
+ if (failed.length > 0) {
601
+ this.logger?.warn(`${TAG} 逐条回退后仍失败 ${failed.length}/${records.length} 条: ${failed.slice(0, 5).join(', ')}${failed.length > 5 ? '…' : ''}`);
602
+ }
603
+ return failed.length === 0;
604
+ }
605
+ }
606
+ /** 事务内的单条写入体(upsertL1 / upsertL1Batch 共用;调用方负责 BEGIN/COMMIT)。 */
607
+ upsertL1InTx(record, embedding) {
608
+ const ts = timestampsToDb(record.timestamps);
609
+ 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));
610
+ // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
611
+ if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
612
+ this.stmtDeleteL1Vec.run(record.id);
613
+ if (embedding && !isZeroVector(embedding)) {
614
+ this.stmtInsertL1Vec.run(record.id, vecToBuffer(embedding), toIso(record.updatedAt));
615
+ }
616
+ }
617
+ // FTS 删除/插入与元数据同事务:失败必须整体回滚——若只吞 FTS 错误照常 COMMIT,
618
+ // 已执行的 DELETE 会让该 id 的索引行被删未补,记录从此全文检索不可见(静默丢数据)。
619
+ if (this.ftsAvailable) {
620
+ this.stmtL1FtsDelete.run(record.id);
621
+ 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));
622
+ }
623
+ }
624
+ /** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。IN 按 ≤900 分块(避变量数上限)。 */
625
+ deleteL1Batch(ids) {
626
+ if (this.degraded || ids.length === 0)
627
+ return 0;
628
+ try {
629
+ this.db.exec('BEGIN');
511
630
  try {
512
- this.db.exec('ROLLBACK');
631
+ for (const chunk of chunkIds(ids))
632
+ this.inStatement('l1_records', 'delete', chunk.length).run(...chunk);
633
+ if (this.stmtDeleteL1Vec) {
634
+ for (const chunk of chunkIds(ids))
635
+ this.inStatement('l1_vec', 'delete', chunk.length).run(...chunk);
636
+ }
637
+ if (this.ftsAvailable) {
638
+ for (const chunk of chunkIds(ids))
639
+ this.inStatement('l1_fts', 'delete', chunk.length).run(...chunk);
640
+ }
641
+ this.db.exec('COMMIT');
642
+ return ids.length;
513
643
  }
514
- catch {
515
- /* ignore */
644
+ catch (err) {
645
+ try {
646
+ this.db.exec('ROLLBACK');
647
+ }
648
+ catch {
649
+ /* ignore */
650
+ }
651
+ throw err;
516
652
  }
653
+ }
654
+ catch (err) {
517
655
  this.logger?.warn(`${TAG} L1 批量删除失败: ${err instanceof Error ? err.message : String(err)}`);
518
656
  return 0;
519
657
  }
520
658
  }
659
+ /** 按块缓存的 IN 语句(表名/动作/尺寸 → 预编译语句):热路径不再每次动态 prepare。 */
660
+ inStmts = new Map();
661
+ inStatement(table, action, size) {
662
+ const key = `${table}:${action}:${size}`;
663
+ let stmt = this.inStmts.get(key);
664
+ if (!stmt) {
665
+ const ph = Array.from({ length: size }, () => '?').join(',');
666
+ stmt =
667
+ action === 'delete'
668
+ ? this.db.prepare(`DELETE FROM ${table} WHERE record_id IN (${ph})`)
669
+ : this.db.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family FROM ${table} WHERE record_id IN (${ph})`);
670
+ this.inStmts.set(key, stmt);
671
+ }
672
+ return stmt;
673
+ }
521
674
  /**
522
675
  * 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
523
676
  * 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
@@ -549,7 +702,10 @@ export class MemoryDb {
549
702
  if (this.stmtDeleteL1Vec) {
550
703
  this.db.exec('DROP TABLE IF EXISTS l1_vec');
551
704
  this.prepareL1VecStatements();
705
+ this.inStmts.clear();
552
706
  }
707
+ // 重建后 L1 id 全新,旧 skip 集是死数据,清空让新记录获得一次嵌入机会
708
+ this.clearVecSkipIds('l1');
553
709
  this.logger?.info(`${TAG} L1 检索库已清空(重建)`);
554
710
  return true;
555
711
  }
@@ -582,10 +738,10 @@ export class MemoryDb {
582
738
  getL1ByIds(ids) {
583
739
  if (this.degraded || ids.length === 0)
584
740
  return [];
585
- const placeholders = ids.map(() => '?').join(',');
586
- const rows = this.db
587
- .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})`)
588
- .all(...ids);
741
+ const rows = [];
742
+ for (const chunk of chunkIds(ids)) {
743
+ rows.push(...this.inStatement('l1_records', 'select', chunk.length).all(...chunk));
744
+ }
589
745
  return rows.map(rowToRecord);
590
746
  }
591
747
  /** 浏览列表(UI 用):按更新时间倒序,支持类型/场景/族过滤与分页。失败返回空。 */
@@ -716,13 +872,9 @@ export class MemoryDb {
716
872
  }
717
873
  }
718
874
  if (this.ftsAvailable) {
719
- try {
720
- this.stmtL0FtsDelete.run(r.id);
721
- this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
722
- }
723
- catch (ftsErr) {
724
- this.logger?.warn(`${TAG} L0 FTS 写入失败(非致命)id=${r.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
725
- }
875
+ // 同 upsertL1:FTS 失败冒泡触发整批回滚,禁止"删了没补"的索引空洞。
876
+ this.stmtL0FtsDelete.run(r.id);
877
+ this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
726
878
  }
727
879
  }
728
880
  this.db.exec('COMMIT');
@@ -859,9 +1011,7 @@ export class MemoryDb {
859
1011
  for (const { record_id, distance } of rows) {
860
1012
  if (distance == null || Number.isNaN(distance))
861
1013
  continue;
862
- const row = this.db
863
- .prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?')
864
- .get(record_id);
1014
+ const row = this.stmtGetL0.get(record_id);
865
1015
  if (!row)
866
1016
  continue;
867
1017
  hits.push({
@@ -884,19 +1034,124 @@ export class MemoryDb {
884
1034
  // ============================
885
1035
  // 重嵌入(reindexAll 用)
886
1036
  // ============================
887
- getL1ForReindex() {
1037
+ /** L1 缺失向量的记录数(排除 skip 集后的补齐判据;向量能力不可用返回 -1)。 */
1038
+ countL1VecMissing(exclude) {
1039
+ if (this.degraded || !this.stmtSearchL1Vec)
1040
+ return -1;
1041
+ try {
1042
+ const row = this.db
1043
+ .prepare(`SELECT COUNT(*) AS n FROM l1_records r
1044
+ LEFT JOIN l1_vec v ON v.record_id = r.record_id
1045
+ WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
1046
+ .all(...notInParams(exclude))[0];
1047
+ return row?.n ?? 0;
1048
+ }
1049
+ catch (err) {
1050
+ // -1 会让补齐判据(> 0)按"无缺失"处理——必须留痕,不能静默停摆
1051
+ this.logger?.warn(`${TAG} L1 缺失向量计数失败(补齐判据按无缺失处理): ${err instanceof Error ? err.message : String(err)}`);
1052
+ return -1;
1053
+ }
1054
+ }
1055
+ /** L0 缺失向量的记录数(同上)。 */
1056
+ countL0VecMissing(exclude) {
1057
+ if (this.degraded || !this.stmtSearchL0Vec)
1058
+ return -1;
1059
+ try {
1060
+ const row = this.db
1061
+ .prepare(`SELECT COUNT(*) AS n FROM l0_conversations r
1062
+ LEFT JOIN l0_vec v ON v.record_id = r.record_id
1063
+ WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
1064
+ .all(...notInParams(exclude))[0];
1065
+ return row?.n ?? 0;
1066
+ }
1067
+ catch (err) {
1068
+ this.logger?.warn(`${TAG} L0 缺失向量计数失败(补齐判据按无缺失处理): ${err instanceof Error ? err.message : String(err)}`);
1069
+ return -1;
1070
+ }
1071
+ }
1072
+ /**
1073
+ * 待重嵌入的 L1:只取缺失向量的记录(增量),排除 skip 集里已判定
1074
+ * "当前 provider 下不可嵌入(零向量)"的 id——缺 1 条不再全量重嵌,
1075
+ * 零向量记录也不再反复喂给 embeddings API(H1 死循环双根因)。
1076
+ */
1077
+ getL1ForReindex(exclude) {
888
1078
  if (this.degraded)
889
1079
  return [];
890
- return this.db
891
- .prepare('SELECT record_id AS id, content FROM l1_records')
892
- .all();
1080
+ try {
1081
+ return this.db
1082
+ .prepare(`SELECT r.record_id AS id, r.content FROM l1_records r
1083
+ LEFT JOIN l1_vec v ON v.record_id = r.record_id
1084
+ WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
1085
+ .all(...notInParams(exclude));
1086
+ }
1087
+ catch (err) {
1088
+ this.logger?.warn(`${TAG} L1 重嵌入取数失败(返回空,本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
1089
+ return [];
1090
+ }
893
1091
  }
894
- getL0ForReindex() {
1092
+ /** 待重嵌入的 L0(增量 + 排除 skip 集,同 getL1ForReindex)。 */
1093
+ getL0ForReindex(exclude) {
895
1094
  if (this.degraded)
896
1095
  return [];
897
- return this.db
898
- .prepare('SELECT record_id AS id, message_text AS text FROM l0_conversations')
899
- .all();
1096
+ try {
1097
+ return this.db
1098
+ .prepare(`SELECT r.record_id AS id, r.message_text AS text FROM l0_conversations r
1099
+ LEFT JOIN l0_vec v ON v.record_id = r.record_id
1100
+ WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
1101
+ .all(...notInParams(exclude));
1102
+ }
1103
+ catch (err) {
1104
+ this.logger?.warn(`${TAG} L0 重嵌入取数失败(返回空,本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
1105
+ return [];
1106
+ }
1107
+ }
1108
+ // ── 零向量 skip 集(embedding_meta 持久化;provider 变化时随向量表一起清空) ──
1109
+ getVecSkipSet(kind) {
1110
+ if (this.degraded)
1111
+ return new Set();
1112
+ try {
1113
+ const row = this.db
1114
+ .prepare('SELECT value FROM embedding_meta WHERE key = ?')
1115
+ .get(vecSkipKey(kind));
1116
+ if (!row)
1117
+ return new Set();
1118
+ const parsed = JSON.parse(row.value);
1119
+ if (!Array.isArray(parsed))
1120
+ return new Set();
1121
+ return new Set(parsed.filter((x) => typeof x === 'string'));
1122
+ }
1123
+ catch {
1124
+ return new Set();
1125
+ }
1126
+ }
1127
+ addVecSkippedIds(kind, ids) {
1128
+ if (this.degraded || ids.length === 0)
1129
+ return;
1130
+ try {
1131
+ let merged = [...new Set([...this.getVecSkipSet(kind), ...ids])];
1132
+ // 上限防 NOT IN 占位符无界膨胀(老构建变量上限 999);达到上限本身
1133
+ // 说明 embedding 服务大面积返回零向量,被挤出的旧 id 只是多一次重试
1134
+ if (merged.length > VEC_SKIP_CAP) {
1135
+ merged = merged.slice(-VEC_SKIP_CAP);
1136
+ this.logger?.warn(`${TAG} skip 集达上限 ${VEC_SKIP_CAP}(零向量记录过多,embedding 服务疑似异常)`);
1137
+ }
1138
+ this.db
1139
+ .prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
1140
+ .run(vecSkipKey(kind), JSON.stringify(merged));
1141
+ }
1142
+ catch (err) {
1143
+ this.logger?.warn(`${TAG} skip 集写入失败: ${err instanceof Error ? err.message : String(err)}`);
1144
+ }
1145
+ }
1146
+ clearVecSkipIds(kind) {
1147
+ if (this.degraded)
1148
+ return;
1149
+ try {
1150
+ this.db.prepare('DELETE FROM embedding_meta WHERE key = ?').run(vecSkipKey(kind));
1151
+ }
1152
+ catch {
1153
+ /* 空集语义,失败无影响 */
1154
+ }
900
1155
  }
901
1156
  /** 只更新向量行(重嵌入用)。 */
902
1157
  updateL1Vec(id, embedding) {
@@ -927,6 +1182,85 @@ export class MemoryDb {
927
1182
  return false;
928
1183
  }
929
1184
  }
1185
+ /**
1186
+ * 批量更新 L1 向量行(重嵌入热路径):单事务写入整批,替代逐条裸写——
1187
+ * 逐条每行一次隐式事务,批量场景(万级记录重嵌)开销集中在 fsync 上。
1188
+ * 整批失败回退逐条:好行照常入库,坏行只丢自身(向量行 id 寻址,无顺序依赖)。
1189
+ * 返回成功写入的行数(零向量行防御性跳过、不计入)。
1190
+ */
1191
+ updateL1VecBatch(items) {
1192
+ if (this.degraded || !this.stmtDeleteL1Vec || !this.stmtInsertL1Vec || items.length === 0)
1193
+ return 0;
1194
+ try {
1195
+ this.db.exec('BEGIN');
1196
+ try {
1197
+ let written = 0;
1198
+ for (const it of items) {
1199
+ if (isZeroVector(it.embedding))
1200
+ continue;
1201
+ this.stmtDeleteL1Vec.run(it.id);
1202
+ this.stmtInsertL1Vec.run(it.id, vecToBuffer(it.embedding), new Date().toISOString());
1203
+ written++;
1204
+ }
1205
+ this.db.exec('COMMIT');
1206
+ return written;
1207
+ }
1208
+ catch (err) {
1209
+ try {
1210
+ this.db.exec('ROLLBACK');
1211
+ }
1212
+ catch {
1213
+ /* ignore */
1214
+ }
1215
+ throw err;
1216
+ }
1217
+ }
1218
+ catch (err) {
1219
+ this.logger?.warn(`${TAG} L1 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
1220
+ let ok = 0;
1221
+ for (const it of items)
1222
+ if (this.updateL1Vec(it.id, it.embedding))
1223
+ ok++;
1224
+ return ok;
1225
+ }
1226
+ }
1227
+ /** L0 版 updateL1VecBatch(语义同:单事务 + 失败回退逐条)。recordedAt 整批统一。 */
1228
+ updateL0VecBatch(items, recordedAt) {
1229
+ if (this.degraded || !this.stmtDeleteL0Vec || !this.stmtInsertL0Vec || items.length === 0)
1230
+ return 0;
1231
+ try {
1232
+ this.db.exec('BEGIN');
1233
+ try {
1234
+ let written = 0;
1235
+ for (const it of items) {
1236
+ if (isZeroVector(it.embedding))
1237
+ continue;
1238
+ this.stmtDeleteL0Vec.run(it.id);
1239
+ this.stmtInsertL0Vec.run(it.id, vecToBuffer(it.embedding), recordedAt);
1240
+ written++;
1241
+ }
1242
+ this.db.exec('COMMIT');
1243
+ return written;
1244
+ }
1245
+ catch (err) {
1246
+ try {
1247
+ this.db.exec('ROLLBACK');
1248
+ }
1249
+ catch {
1250
+ /* ignore */
1251
+ }
1252
+ throw err;
1253
+ }
1254
+ }
1255
+ catch (err) {
1256
+ this.logger?.warn(`${TAG} L0 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
1257
+ let ok = 0;
1258
+ for (const it of items)
1259
+ if (this.updateL0Vec(it.id, it.embedding, recordedAt))
1260
+ ok++;
1261
+ return ok;
1262
+ }
1263
+ }
930
1264
  close() {
931
1265
  try {
932
1266
  this.db.close();
@@ -980,13 +1314,28 @@ function toIso(epochMs) {
980
1314
  return '';
981
1315
  return new Date(epochMs).toISOString();
982
1316
  }
983
- function isZeroVector(vec) {
1317
+ /** 全零向量(cosine 未定义,不可入向量表)。reindex 侧用它区分"不可嵌入"与"写入失败"。 */
1318
+ export function isZeroVector(vec) {
984
1319
  for (const v of vec) {
985
1320
  if (v !== 0)
986
1321
  return false;
987
1322
  }
988
1323
  return true;
989
1324
  }
1325
+ /** NOT IN 片段(空集 → 空串;配合 notInParams 使用)。 */
1326
+ function notInClause(column, exclude) {
1327
+ if (!exclude || exclude.size === 0)
1328
+ return '';
1329
+ return ` AND ${column} NOT IN (${[...exclude].map(() => '?').join(',')})`;
1330
+ }
1331
+ function notInParams(exclude) {
1332
+ if (!exclude || exclude.size === 0)
1333
+ return [];
1334
+ return [...exclude];
1335
+ }
1336
+ function vecSkipKey(kind) {
1337
+ return kind === 'l1' ? 'embedding_zero_vec_l1' : 'embedding_zero_vec_l0';
1338
+ }
990
1339
  /** DB 字符串 → 族(异常值按 type 前缀兜底归一)。 */
991
1340
  function normFamily(raw, type) {
992
1341
  if (raw === 'work' || raw === 'chat')