dsh-layered-memory 0.6.0 → 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.
- package/README.en.md +101 -81
- package/README.md +91 -71
- package/assets/img/Hero.png +0 -0
- package/assets/img/Layers.png +0 -0
- package/assets/img/Modes.png +0 -0
- package/assets/img/ui-dark.jpg +0 -0
- package/assets/img/ui-light.jpg +0 -0
- package/assets/readme/flow.svg +189 -0
- package/assets/readme/storage.svg +115 -0
- package/dist/client.js +1242 -325
- package/dist/config.d.ts +12 -0
- package/dist/config.js +20 -17
- package/dist/hooks/capture.d.ts +17 -1
- package/dist/hooks/capture.js +45 -11
- package/dist/hooks/recall.d.ts +7 -0
- package/dist/hooks/recall.js +20 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +112 -44
- package/dist/pipeline/rebuild.d.ts +80 -0
- package/dist/pipeline/rebuild.js +307 -0
- package/dist/pipeline/runner.d.ts +42 -4
- package/dist/pipeline/runner.js +125 -21
- package/dist/settings.js +79 -20
- package/dist/stats.d.ts +8 -1
- package/dist/stats.js +156 -14
- package/dist/store/download-queue.d.ts +71 -0
- package/dist/store/download-queue.js +313 -0
- package/dist/store/embedding-source.d.ts +160 -0
- package/dist/store/embedding-source.js +421 -0
- package/dist/store/embedding.d.ts +3 -1
- package/dist/store/embedding.js +5 -0
- package/dist/store/l0.d.ts +13 -5
- package/dist/store/l0.js +33 -6
- package/dist/store/l1.d.ts +15 -5
- package/dist/store/l1.js +40 -15
- package/dist/store/local-embedding.d.ts +64 -0
- package/dist/store/local-embedding.js +120 -0
- package/dist/store/model-catalog.d.ts +45 -0
- package/dist/store/model-catalog.js +78 -0
- package/dist/store/pending.d.ts +15 -0
- package/dist/store/pending.js +56 -0
- package/dist/store/runtime-installer.d.ts +60 -0
- package/dist/store/runtime-installer.js +181 -0
- package/dist/store/sqlite.d.ts +68 -7
- package/dist/store/sqlite.js +414 -71
- package/dist/store/state.d.ts +6 -0
- package/dist/store/state.js +11 -0
- package/dist/tools/index.js +6 -4
- package/dist/util/filelog.d.ts +2 -0
- package/dist/util/filelog.js +20 -3
- package/package.json +1 -1
- package/assets/readme/hero.svg +0 -58
package/dist/store/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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.
|
|
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,32 +559,156 @@ export class MemoryDb {
|
|
|
490
559
|
return false;
|
|
491
560
|
}
|
|
492
561
|
}
|
|
493
|
-
/**
|
|
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;
|
|
571
|
+
try {
|
|
572
|
+
this.db.exec('BEGIN');
|
|
573
|
+
try {
|
|
574
|
+
for (let i = 0; i < records.length; i++) {
|
|
575
|
+
this.upsertL1InTx(records[i], embeddings?.[i]);
|
|
576
|
+
}
|
|
577
|
+
this.db.exec('COMMIT');
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
try {
|
|
581
|
+
this.db.exec('ROLLBACK');
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
/* ignore */
|
|
585
|
+
}
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
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 分块(避变量数上限)。 */
|
|
494
622
|
deleteL1Batch(ids) {
|
|
495
623
|
if (this.degraded || ids.length === 0)
|
|
496
624
|
return 0;
|
|
497
625
|
try {
|
|
498
|
-
const placeholders = ids.map(() => '?').join(',');
|
|
499
626
|
this.db.exec('BEGIN');
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
627
|
+
try {
|
|
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;
|
|
503
640
|
}
|
|
504
|
-
|
|
505
|
-
|
|
641
|
+
catch (err) {
|
|
642
|
+
try {
|
|
643
|
+
this.db.exec('ROLLBACK');
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
/* ignore */
|
|
647
|
+
}
|
|
648
|
+
throw err;
|
|
506
649
|
}
|
|
507
|
-
this.db.exec('COMMIT');
|
|
508
|
-
return ids.length;
|
|
509
650
|
}
|
|
510
651
|
catch (err) {
|
|
652
|
+
this.logger?.warn(`${TAG} L1 批量删除失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
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
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
|
|
673
|
+
* 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
|
|
674
|
+
* 会连 l0_vec 一起删——L0 向量必须保留——故此处单独处理 l1_vec)。
|
|
675
|
+
* L0 表与 embedding_meta 不动:backfill 的行数比对天然重新一致。
|
|
676
|
+
*/
|
|
677
|
+
clearL1() {
|
|
678
|
+
if (this.degraded)
|
|
679
|
+
return false;
|
|
680
|
+
try {
|
|
681
|
+
this.db.exec('BEGIN');
|
|
511
682
|
try {
|
|
512
|
-
this.db.exec('
|
|
683
|
+
this.db.exec('DELETE FROM l1_records');
|
|
684
|
+
if (this.ftsAvailable)
|
|
685
|
+
this.db.exec('DELETE FROM l1_fts');
|
|
686
|
+
this.db.exec('COMMIT');
|
|
513
687
|
}
|
|
514
|
-
catch {
|
|
515
|
-
|
|
688
|
+
catch (err) {
|
|
689
|
+
try {
|
|
690
|
+
this.db.exec('ROLLBACK');
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* ignore */
|
|
694
|
+
}
|
|
695
|
+
throw err;
|
|
516
696
|
}
|
|
517
|
-
|
|
518
|
-
|
|
697
|
+
// vec0 全表 DELETE 语义不可靠 → DROP + 重建空表;放事务外(vtab DDL 事务性弱)。
|
|
698
|
+
// 中间态:向量行短暂孤儿——检索侧对缺 meta 的向量本就跳过(searchL1Vector 回查过滤)。
|
|
699
|
+
if (this.stmtDeleteL1Vec) {
|
|
700
|
+
this.db.exec('DROP TABLE IF EXISTS l1_vec');
|
|
701
|
+
this.prepareL1VecStatements();
|
|
702
|
+
this.inStmts.clear();
|
|
703
|
+
}
|
|
704
|
+
// 重建后 L1 id 全新,旧 skip 集是死数据,清空让新记录获得一次嵌入机会
|
|
705
|
+
this.clearVecSkipIds('l1');
|
|
706
|
+
this.logger?.info(`${TAG} L1 检索库已清空(重建)`);
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
this.logger?.warn(`${TAG} L1 清空失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
711
|
+
return false;
|
|
519
712
|
}
|
|
520
713
|
}
|
|
521
714
|
countL1() {
|
|
@@ -542,10 +735,10 @@ export class MemoryDb {
|
|
|
542
735
|
getL1ByIds(ids) {
|
|
543
736
|
if (this.degraded || ids.length === 0)
|
|
544
737
|
return [];
|
|
545
|
-
const
|
|
546
|
-
const
|
|
547
|
-
.
|
|
548
|
-
|
|
738
|
+
const rows = [];
|
|
739
|
+
for (const chunk of chunkIds(ids)) {
|
|
740
|
+
rows.push(...this.inStatement('l1_records', 'select', chunk.length).all(...chunk));
|
|
741
|
+
}
|
|
549
742
|
return rows.map(rowToRecord);
|
|
550
743
|
}
|
|
551
744
|
/** 浏览列表(UI 用):按更新时间倒序,支持类型/场景/族过滤与分页。失败返回空。 */
|
|
@@ -676,13 +869,9 @@ export class MemoryDb {
|
|
|
676
869
|
}
|
|
677
870
|
}
|
|
678
871
|
if (this.ftsAvailable) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
catch (ftsErr) {
|
|
684
|
-
this.logger?.warn(`${TAG} L0 FTS 写入失败(非致命)id=${r.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
|
|
685
|
-
}
|
|
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);
|
|
686
875
|
}
|
|
687
876
|
}
|
|
688
877
|
this.db.exec('COMMIT');
|
|
@@ -724,6 +913,42 @@ export class MemoryDb {
|
|
|
724
913
|
return 0;
|
|
725
914
|
}
|
|
726
915
|
}
|
|
916
|
+
/** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
|
|
917
|
+
listL0All() {
|
|
918
|
+
if (this.degraded)
|
|
919
|
+
return [];
|
|
920
|
+
try {
|
|
921
|
+
const rows = this.db
|
|
922
|
+
.prepare('SELECT record_id, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations ORDER BY timestamp ASC')
|
|
923
|
+
.all();
|
|
924
|
+
return rows.map((r) => ({
|
|
925
|
+
sessionId: r.session_id,
|
|
926
|
+
recordedAt: r.recorded_at,
|
|
927
|
+
id: r.record_id,
|
|
928
|
+
role: r.role,
|
|
929
|
+
content: r.message_text,
|
|
930
|
+
timestamp: r.timestamp ?? 0,
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
this.logger?.warn(`${TAG} L0 全量列举失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
|
|
939
|
+
l0RebuildEstimate() {
|
|
940
|
+
if (this.degraded)
|
|
941
|
+
return { sessions: 0, messages: 0, chars: 0 };
|
|
942
|
+
try {
|
|
943
|
+
const row = this.db
|
|
944
|
+
.prepare('SELECT COUNT(DISTINCT session_id) AS s, COUNT(*) AS n, COALESCE(SUM(LENGTH(message_text)), 0) AS c FROM l0_conversations')
|
|
945
|
+
.get();
|
|
946
|
+
return { sessions: row?.s ?? 0, messages: row?.n ?? 0, chars: row?.c ?? 0 };
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
return { sessions: 0, messages: 0, chars: 0 };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
727
952
|
/** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
|
|
728
953
|
countL1Vec() {
|
|
729
954
|
if (this.degraded || !this.stmtSearchL1Vec)
|
|
@@ -783,9 +1008,7 @@ export class MemoryDb {
|
|
|
783
1008
|
for (const { record_id, distance } of rows) {
|
|
784
1009
|
if (distance == null || Number.isNaN(distance))
|
|
785
1010
|
continue;
|
|
786
|
-
const row = this.
|
|
787
|
-
.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?')
|
|
788
|
-
.get(record_id);
|
|
1011
|
+
const row = this.stmtGetL0.get(record_id);
|
|
789
1012
|
if (!row)
|
|
790
1013
|
continue;
|
|
791
1014
|
hits.push({
|
|
@@ -808,19 +1031,124 @@ export class MemoryDb {
|
|
|
808
1031
|
// ============================
|
|
809
1032
|
// 重嵌入(reindexAll 用)
|
|
810
1033
|
// ============================
|
|
811
|
-
|
|
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) {
|
|
812
1075
|
if (this.degraded)
|
|
813
1076
|
return [];
|
|
814
|
-
|
|
815
|
-
.
|
|
816
|
-
|
|
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
|
+
}
|
|
817
1088
|
}
|
|
818
|
-
|
|
1089
|
+
/** 待重嵌入的 L0(增量 + 排除 skip 集,同 getL1ForReindex)。 */
|
|
1090
|
+
getL0ForReindex(exclude) {
|
|
819
1091
|
if (this.degraded)
|
|
820
1092
|
return [];
|
|
821
|
-
|
|
822
|
-
.
|
|
823
|
-
|
|
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
|
+
}
|
|
824
1152
|
}
|
|
825
1153
|
/** 只更新向量行(重嵌入用)。 */
|
|
826
1154
|
updateL1Vec(id, embedding) {
|
|
@@ -904,13 +1232,28 @@ function toIso(epochMs) {
|
|
|
904
1232
|
return '';
|
|
905
1233
|
return new Date(epochMs).toISOString();
|
|
906
1234
|
}
|
|
907
|
-
|
|
1235
|
+
/** 全零向量(cosine 未定义,不可入向量表)。reindex 侧用它区分"不可嵌入"与"写入失败"。 */
|
|
1236
|
+
export function isZeroVector(vec) {
|
|
908
1237
|
for (const v of vec) {
|
|
909
1238
|
if (v !== 0)
|
|
910
1239
|
return false;
|
|
911
1240
|
}
|
|
912
1241
|
return true;
|
|
913
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
|
+
}
|
|
914
1257
|
/** DB 字符串 → 族(异常值按 type 前缀兜底归一)。 */
|
|
915
1258
|
function normFamily(raw, type) {
|
|
916
1259
|
if (raw === 'work' || raw === 'chat')
|
package/dist/store/state.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export declare class StateStore {
|
|
|
30
30
|
get didMigrate(): boolean;
|
|
31
31
|
/** 取某族的 checkpoint(活引用——改字段后 save 生效)。 */
|
|
32
32
|
forFamily(family: MemoryFamily): MemoryState;
|
|
33
|
+
/**
|
|
34
|
+
* 重建用:两族 checkpoint 重置为默认值。
|
|
35
|
+
* 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
|
|
36
|
+
* 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
|
|
37
|
+
*/
|
|
38
|
+
reset(): void;
|
|
33
39
|
save(): Promise<void>;
|
|
34
40
|
static pathFor(dataDir: string): string;
|
|
35
41
|
}
|
package/dist/store/state.js
CHANGED
|
@@ -52,6 +52,17 @@ export class StateStore {
|
|
|
52
52
|
forFamily(family) {
|
|
53
53
|
return this.buckets[family];
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 重建用:两族 checkpoint 重置为默认值。
|
|
57
|
+
* 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
|
|
58
|
+
* 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
|
|
59
|
+
*/
|
|
60
|
+
reset() {
|
|
61
|
+
for (const family of ['chat', 'work']) {
|
|
62
|
+
Object.assign(this.buckets[family], defaultState());
|
|
63
|
+
this.buckets[family].personaRequestedReason = undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
55
66
|
async save() {
|
|
56
67
|
const file = { version: 2, families: this.buckets };
|
|
57
68
|
await atomicWriteJson(this.file, file);
|