dsh-layered-memory 0.6.1 → 0.7.0

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