dsh-layered-memory 0.7.0 → 0.8.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 +118 -66
- package/README.md +131 -98
- package/assets/img/EmbeddingSource.png +0 -0
- package/assets/img/MemoryTools.png +0 -0
- package/assets/img/Modes.png +0 -0
- package/assets/img/ToolTrajectory.png +0 -0
- package/dist/client.js +11 -2
- package/dist/config.d.ts +26 -1
- package/dist/config.js +9 -3
- package/dist/hooks/recall.d.ts +11 -8
- package/dist/hooks/recall.js +70 -51
- package/dist/index.d.ts +16 -0
- package/dist/index.js +5 -1
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +17 -0
- package/dist/pipeline/l1.js +5 -4
- package/dist/pipeline/l2.js +7 -2
- package/dist/pipeline/l3.js +7 -2
- package/dist/pipeline/runner.d.ts +36 -5
- package/dist/pipeline/runner.js +148 -35
- package/dist/pipeline/trigger.d.ts +38 -0
- package/dist/pipeline/trigger.js +64 -0
- package/dist/runtime-package-lock.json +982 -0
- package/dist/store/bm25.js +2 -1
- package/dist/store/embedding.d.ts +12 -6
- package/dist/store/embedding.js +10 -7
- package/dist/store/l0.d.ts +2 -0
- package/dist/store/l0.js +11 -4
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +9 -6
- package/dist/store/pending.d.ts +27 -6
- package/dist/store/pending.js +49 -9
- package/dist/store/runtime-installer.d.ts +6 -1
- package/dist/store/runtime-installer.js +65 -26
- package/dist/store/search-utils.js +3 -2
- package/dist/store/session-modes.d.ts +4 -0
- package/dist/store/session-modes.js +16 -0
- package/dist/store/sqlite.d.ts +27 -0
- package/dist/store/sqlite.js +186 -6
- package/dist/util/recall-budget.d.ts +32 -0
- package/dist/util/recall-budget.js +85 -0
- package/dist/util/text.d.ts +8 -3
- package/dist/util/text.js +41 -15
- package/dist/util/tokenizer.d.ts +14 -0
- package/dist/util/tokenizer.js +53 -0
- package/package.json +2 -1
package/dist/store/sqlite.js
CHANGED
|
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { familyForType } from '../types.js';
|
|
21
21
|
import { bm25RankToScore, buildFtsQuery, tokenizeForFts } from './search-utils.js';
|
|
22
|
+
import { describeTokenizer, ensureTokenizer, tokenizerStamp } from '../util/tokenizer.js';
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
23
24
|
const TAG = '[memory][sqlite]';
|
|
24
25
|
/** vec0 KNN 对遗留零向量的补偿缓冲(官方同款)。 */
|
|
@@ -45,6 +46,8 @@ export class MemoryDb {
|
|
|
45
46
|
logger;
|
|
46
47
|
stmtUpsertL1;
|
|
47
48
|
stmtGetL1;
|
|
49
|
+
/** 主表存在性点查(防御性 FTS 删除的前置判断,走主键索引)。 */
|
|
50
|
+
stmtL1Exists;
|
|
48
51
|
stmtDeleteL1Meta;
|
|
49
52
|
stmtDeleteL1Vec;
|
|
50
53
|
stmtInsertL1Vec;
|
|
@@ -55,6 +58,8 @@ export class MemoryDb {
|
|
|
55
58
|
stmtL1FtsSearchFamily;
|
|
56
59
|
stmtUpsertL0;
|
|
57
60
|
stmtGetL0;
|
|
61
|
+
/** 主表存在性点查(同 L1:防御性 FTS 删除的前置判断)。 */
|
|
62
|
+
stmtL0Exists;
|
|
58
63
|
stmtDeleteL0Vec;
|
|
59
64
|
stmtInsertL0Vec;
|
|
60
65
|
stmtSearchL0Vec;
|
|
@@ -72,9 +77,12 @@ export class MemoryDb {
|
|
|
72
77
|
mkdirSync(dbDir, { recursive: true });
|
|
73
78
|
const { DatabaseSync: DbSync } = require('node:sqlite');
|
|
74
79
|
this.db = new DbSync(dbPath, { allowExtension: true });
|
|
75
|
-
// 并发读优化 + 有界内存(照搬官方 PRAGMA
|
|
80
|
+
// 并发读优化 + 有界内存(照搬官方 PRAGMA 组合,synchronous 为本仓新增:
|
|
81
|
+
// WAL 下官方推荐 NORMAL——批量写从"每事务一次 fsync"降为"每 checkpoint 一次",
|
|
82
|
+
// 重嵌入/导入提速明显;代价仅是断电时丢最后若干已提交事务(只丢不损,无损坏风险))
|
|
76
83
|
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
77
84
|
this.db.exec('PRAGMA journal_mode = WAL');
|
|
85
|
+
this.db.exec('PRAGMA synchronous = NORMAL');
|
|
78
86
|
this.db.exec('PRAGMA cache_size = -65536');
|
|
79
87
|
this.db.exec('PRAGMA mmap_size = 134217728');
|
|
80
88
|
this.db.exec('PRAGMA wal_autocheckpoint = 1000');
|
|
@@ -104,6 +112,9 @@ export class MemoryDb {
|
|
|
104
112
|
// dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义);
|
|
105
113
|
// 后续活切换本地嵌入(维度 > 0)时由 swapProvider 补加载
|
|
106
114
|
this.ensureVecLoaded();
|
|
115
|
+
// 分词器在首次 FTS 写入(迁移回灌)前定死模式:jieba 就绪 info / 回退 warn 一次
|
|
116
|
+
ensureTokenizer();
|
|
117
|
+
this.logger?.info(`${TAG} 分词器:${describeTokenizer()}`);
|
|
107
118
|
try {
|
|
108
119
|
return this.initSchema(providerInfo);
|
|
109
120
|
}
|
|
@@ -284,6 +295,7 @@ export class MemoryDb {
|
|
|
284
295
|
timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family
|
|
285
296
|
FROM l1_records WHERE record_id = ?
|
|
286
297
|
`);
|
|
298
|
+
this.stmtL1Exists = this.db.prepare('SELECT 1 FROM l1_records WHERE record_id = ?');
|
|
287
299
|
this.stmtDeleteL1Meta = this.db.prepare('DELETE FROM l1_records WHERE record_id = ?');
|
|
288
300
|
this.prepareL1VecStatements();
|
|
289
301
|
// ── L0 schema ──
|
|
@@ -311,15 +323,28 @@ export class MemoryDb {
|
|
|
311
323
|
timestamp=excluded.timestamp
|
|
312
324
|
`);
|
|
313
325
|
this.stmtGetL0 = this.db.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?');
|
|
326
|
+
this.stmtL0Exists = this.db.prepare('SELECT 1 FROM l0_conversations WHERE record_id = ?');
|
|
314
327
|
this.prepareL0VecStatements();
|
|
315
328
|
// ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
|
|
316
329
|
try {
|
|
317
|
-
//
|
|
330
|
+
// 索引重建判据(FTS5 无法 ALTER,只能 drop 后从源表全量回灌):
|
|
331
|
+
// a) 旧 l1_fts 无 family 列;b) FTS 分词器版本戳 ≠ 当前生效分词器
|
|
332
|
+
// (无戳 = jieba 引入前的二元组索引;jieba 升级/降级切换后旧 token
|
|
333
|
+
// 形态不再匹配,须按新分词器重建)。
|
|
334
|
+
const wantStamp = tokenizerStamp();
|
|
335
|
+
const savedStamp = this.readMetaString('fts_tokenizer') ?? 'bigram-v1';
|
|
336
|
+
const tokenizerChanged = savedStamp !== wantStamp;
|
|
318
337
|
let ftsRebuilt = false;
|
|
319
|
-
if (this.tableExists('l1_fts') && !this.hasColumn('l1_fts', 'family')) {
|
|
338
|
+
if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || tokenizerChanged)) {
|
|
320
339
|
this.db.exec('DROP TABLE l1_fts');
|
|
321
340
|
ftsRebuilt = true;
|
|
322
|
-
this.logger?.info(`${TAG} l1_fts 缺 family
|
|
341
|
+
this.logger?.info(`${TAG} l1_fts 缺 family 列或分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
|
|
342
|
+
}
|
|
343
|
+
let l0FtsRebuilt = false;
|
|
344
|
+
if (this.tableExists('l0_fts') && tokenizerChanged) {
|
|
345
|
+
this.db.exec('DROP TABLE l0_fts');
|
|
346
|
+
l0FtsRebuilt = true;
|
|
347
|
+
this.logger?.info(`${TAG} l0_fts 分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
|
|
323
348
|
}
|
|
324
349
|
this.db.exec(`
|
|
325
350
|
CREATE VIRTUAL TABLE IF NOT EXISTS l1_fts USING fts5(
|
|
@@ -389,6 +414,15 @@ export class MemoryDb {
|
|
|
389
414
|
ORDER BY rank ASC
|
|
390
415
|
LIMIT ?
|
|
391
416
|
`);
|
|
417
|
+
if (l0FtsRebuilt)
|
|
418
|
+
this.backfillL0Fts();
|
|
419
|
+
// 戳如实记录"构建当前 FTS 内容的分词器"(含全新空表:后续写入即该分词器)
|
|
420
|
+
try {
|
|
421
|
+
this.writeMetaString('fts_tokenizer', wantStamp);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
/* 戳写失败只影响下次启动多一次重建,不阻断 */
|
|
425
|
+
}
|
|
392
426
|
this.ftsAvailable = true;
|
|
393
427
|
}
|
|
394
428
|
catch (err) {
|
|
@@ -489,6 +523,22 @@ export class MemoryDb {
|
|
|
489
523
|
if (rows.length > 0)
|
|
490
524
|
this.logger?.info(`${TAG} l1_fts 回灌 ${rows.length} 行`);
|
|
491
525
|
}
|
|
526
|
+
/** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式防大库内存峰值)。 */
|
|
527
|
+
backfillL0Fts() {
|
|
528
|
+
let count = 0;
|
|
529
|
+
const stmt = this.db.prepare('SELECT record_id, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations');
|
|
530
|
+
for (const r of stmt.iterate()) {
|
|
531
|
+
try {
|
|
532
|
+
this.stmtL0FtsInsert.run(tokenizeForFts(String(r.message_text ?? '')), String(r.message_text ?? ''), String(r.record_id ?? ''), String(r.session_id ?? 'default'), String(r.role ?? ''), String(r.recorded_at ?? ''), Number(r.timestamp ?? 0));
|
|
533
|
+
count++;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
/* 单行失败跳过 */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (count > 0)
|
|
540
|
+
this.logger?.info(`${TAG} l0_fts 回灌 ${count} 行`);
|
|
541
|
+
}
|
|
492
542
|
readEmbeddingMeta() {
|
|
493
543
|
try {
|
|
494
544
|
const row = this.db
|
|
@@ -514,6 +564,23 @@ export class MemoryDb {
|
|
|
514
564
|
.prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
|
515
565
|
.run('embedding_provider_info', JSON.stringify(info));
|
|
516
566
|
}
|
|
567
|
+
/** 通用字符串 kv(embedding_meta 表兼作元数据 kv 存储,如 FTS 分词器版本戳)。 */
|
|
568
|
+
readMetaString(key) {
|
|
569
|
+
try {
|
|
570
|
+
const row = this.db
|
|
571
|
+
.prepare('SELECT value FROM embedding_meta WHERE key = ?')
|
|
572
|
+
.get(key);
|
|
573
|
+
return row?.value ?? null;
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
writeMetaString(key, value) {
|
|
580
|
+
this.db
|
|
581
|
+
.prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
|
582
|
+
.run(key, value);
|
|
583
|
+
}
|
|
517
584
|
/**
|
|
518
585
|
* 持久化 embedding meta(语义:物理向量表当前对应的 provider/维度)。
|
|
519
586
|
* 活切换在 swapProvider 成功后即写(表已是新维度);启动/补齐链在
|
|
@@ -603,6 +670,11 @@ export class MemoryDb {
|
|
|
603
670
|
/** 事务内的单条写入体(upsertL1 / upsertL1Batch 共用;调用方负责 BEGIN/COMMIT)。 */
|
|
604
671
|
upsertL1InTx(record, embedding) {
|
|
605
672
|
const ts = timestampsToDb(record.timestamps);
|
|
673
|
+
// 防御性 FTS 删除的前置点查(主键索引,微秒级):record_id 在 FTS 表是 UNINDEXED,
|
|
674
|
+
// 按 id DELETE 是 O(N) 全表扫描——导入/重建/重嵌等"全新增"路径曾为每条记录白付一次
|
|
675
|
+
// 全扫(批量写整体 O(N²))。只有主表已有该行(覆盖/合并)才可能有旧 FTS 行需要删。
|
|
676
|
+
// 同批重复 id 也能正确处理:首条插入后,第二条的点查在同一事务内已见新行。
|
|
677
|
+
const ftsExisted = this.ftsAvailable ? this.stmtL1Exists.get(record.id) !== undefined : false;
|
|
606
678
|
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
679
|
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
|
|
608
680
|
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
@@ -614,7 +686,8 @@ export class MemoryDb {
|
|
|
614
686
|
// FTS 删除/插入与元数据同事务:失败必须整体回滚——若只吞 FTS 错误照常 COMMIT,
|
|
615
687
|
// 已执行的 DELETE 会让该 id 的索引行被删未补,记录从此全文检索不可见(静默丢数据)。
|
|
616
688
|
if (this.ftsAvailable) {
|
|
617
|
-
|
|
689
|
+
if (ftsExisted)
|
|
690
|
+
this.stmtL1FtsDelete.run(record.id);
|
|
618
691
|
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
692
|
}
|
|
620
693
|
}
|
|
@@ -860,6 +933,8 @@ export class MemoryDb {
|
|
|
860
933
|
this.db.exec('BEGIN');
|
|
861
934
|
for (let i = 0; i < records.length; i++) {
|
|
862
935
|
const r = records[i];
|
|
936
|
+
// 同 upsertL1 的点查预判:全新增路径跳过 UNINDEXED 列的 FTS 全扫删除
|
|
937
|
+
const ftsExisted = this.ftsAvailable ? this.stmtL0Exists.get(r.id) !== undefined : false;
|
|
863
938
|
this.stmtUpsertL0.run(r.id, r.sessionId, r.role, r.content, r.recordedAt, r.timestamp);
|
|
864
939
|
if (this.stmtDeleteL0Vec && this.stmtInsertL0Vec) {
|
|
865
940
|
this.stmtDeleteL0Vec.run(r.id);
|
|
@@ -870,7 +945,8 @@ export class MemoryDb {
|
|
|
870
945
|
}
|
|
871
946
|
if (this.ftsAvailable) {
|
|
872
947
|
// 同 upsertL1:FTS 失败冒泡触发整批回滚,禁止"删了没补"的索引空洞。
|
|
873
|
-
|
|
948
|
+
if (ftsExisted)
|
|
949
|
+
this.stmtL0FtsDelete.run(r.id);
|
|
874
950
|
this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
|
|
875
951
|
}
|
|
876
952
|
}
|
|
@@ -913,6 +989,31 @@ export class MemoryDb {
|
|
|
913
989
|
return 0;
|
|
914
990
|
}
|
|
915
991
|
}
|
|
992
|
+
/** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
|
|
993
|
+
* 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
|
|
994
|
+
recentL0BySession(sessionId, limit) {
|
|
995
|
+
if (this.degraded || limit <= 0)
|
|
996
|
+
return [];
|
|
997
|
+
try {
|
|
998
|
+
const rows = this.db
|
|
999
|
+
.prepare('SELECT record_id, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE session_id = ? ORDER BY timestamp DESC, rowid DESC LIMIT ?')
|
|
1000
|
+
.all(sessionId, limit);
|
|
1001
|
+
return rows
|
|
1002
|
+
.map((r) => ({
|
|
1003
|
+
sessionId: r.session_id,
|
|
1004
|
+
recordedAt: r.recorded_at,
|
|
1005
|
+
id: r.record_id,
|
|
1006
|
+
role: r.role,
|
|
1007
|
+
content: r.message_text,
|
|
1008
|
+
timestamp: r.timestamp ?? 0,
|
|
1009
|
+
}))
|
|
1010
|
+
.reverse();
|
|
1011
|
+
}
|
|
1012
|
+
catch (err) {
|
|
1013
|
+
this.logger?.warn(`[memory] L0 按会话取最近消息失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
|
|
1014
|
+
return [];
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
916
1017
|
/** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
|
|
917
1018
|
listL0All() {
|
|
918
1019
|
if (this.degraded)
|
|
@@ -1179,6 +1280,85 @@ export class MemoryDb {
|
|
|
1179
1280
|
return false;
|
|
1180
1281
|
}
|
|
1181
1282
|
}
|
|
1283
|
+
/**
|
|
1284
|
+
* 批量更新 L1 向量行(重嵌入热路径):单事务写入整批,替代逐条裸写——
|
|
1285
|
+
* 逐条每行一次隐式事务,批量场景(万级记录重嵌)开销集中在 fsync 上。
|
|
1286
|
+
* 整批失败回退逐条:好行照常入库,坏行只丢自身(向量行 id 寻址,无顺序依赖)。
|
|
1287
|
+
* 返回成功写入的行数(零向量行防御性跳过、不计入)。
|
|
1288
|
+
*/
|
|
1289
|
+
updateL1VecBatch(items) {
|
|
1290
|
+
if (this.degraded || !this.stmtDeleteL1Vec || !this.stmtInsertL1Vec || items.length === 0)
|
|
1291
|
+
return 0;
|
|
1292
|
+
try {
|
|
1293
|
+
this.db.exec('BEGIN');
|
|
1294
|
+
try {
|
|
1295
|
+
let written = 0;
|
|
1296
|
+
for (const it of items) {
|
|
1297
|
+
if (isZeroVector(it.embedding))
|
|
1298
|
+
continue;
|
|
1299
|
+
this.stmtDeleteL1Vec.run(it.id);
|
|
1300
|
+
this.stmtInsertL1Vec.run(it.id, vecToBuffer(it.embedding), new Date().toISOString());
|
|
1301
|
+
written++;
|
|
1302
|
+
}
|
|
1303
|
+
this.db.exec('COMMIT');
|
|
1304
|
+
return written;
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
try {
|
|
1308
|
+
this.db.exec('ROLLBACK');
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
/* ignore */
|
|
1312
|
+
}
|
|
1313
|
+
throw err;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
this.logger?.warn(`${TAG} L1 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
|
|
1318
|
+
let ok = 0;
|
|
1319
|
+
for (const it of items)
|
|
1320
|
+
if (this.updateL1Vec(it.id, it.embedding))
|
|
1321
|
+
ok++;
|
|
1322
|
+
return ok;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/** L0 版 updateL1VecBatch(语义同:单事务 + 失败回退逐条)。recordedAt 整批统一。 */
|
|
1326
|
+
updateL0VecBatch(items, recordedAt) {
|
|
1327
|
+
if (this.degraded || !this.stmtDeleteL0Vec || !this.stmtInsertL0Vec || items.length === 0)
|
|
1328
|
+
return 0;
|
|
1329
|
+
try {
|
|
1330
|
+
this.db.exec('BEGIN');
|
|
1331
|
+
try {
|
|
1332
|
+
let written = 0;
|
|
1333
|
+
for (const it of items) {
|
|
1334
|
+
if (isZeroVector(it.embedding))
|
|
1335
|
+
continue;
|
|
1336
|
+
this.stmtDeleteL0Vec.run(it.id);
|
|
1337
|
+
this.stmtInsertL0Vec.run(it.id, vecToBuffer(it.embedding), recordedAt);
|
|
1338
|
+
written++;
|
|
1339
|
+
}
|
|
1340
|
+
this.db.exec('COMMIT');
|
|
1341
|
+
return written;
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
try {
|
|
1345
|
+
this.db.exec('ROLLBACK');
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
/* ignore */
|
|
1349
|
+
}
|
|
1350
|
+
throw err;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
this.logger?.warn(`${TAG} L0 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
|
|
1355
|
+
let ok = 0;
|
|
1356
|
+
for (const it of items)
|
|
1357
|
+
if (this.updateL0Vec(it.id, it.embedding, recordedAt))
|
|
1358
|
+
ok++;
|
|
1359
|
+
return ok;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1182
1362
|
close() {
|
|
1183
1363
|
try {
|
|
1184
1364
|
this.db.close();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回预算与超时(ADR-0001 / 规格 A 节;机制移植自 MemoryCore auto-recall 的
|
|
3
|
+
* applyRecallBudget,超时为 dsh 侧新增的总预算语义)。
|
|
4
|
+
*
|
|
5
|
+
* 预算:单条记忆截断上限 + 整轮总量上限——超限截断并以后缀引导模型用记忆工具
|
|
6
|
+
* 查全文(截断是引流而不是损失:工具路径返回完整记录);总量超限按融合排名丢尾部。
|
|
7
|
+
* 超时:召回是增强能力,超时跳过本轮注入、绝不阻塞对话(CONTEXT.md「召回超时」语义
|
|
8
|
+
* 在本模块以 raceTimeout 落地)。
|
|
9
|
+
*/
|
|
10
|
+
/** 截断后缀:显式告诉模型全文在工具侧(引导主动深挖,原版同款设计)。 */
|
|
11
|
+
export declare const RECALL_TRUNCATION_SUFFIX = "\u2026\uFF08\u5DF2\u622A\u65AD\uFF1B\u53EF\u7528 memory_search \u6216 conversation_search \u67E5\u770B\u8BE6\u60C5\uFF09";
|
|
12
|
+
export interface RecallBudgetLimits {
|
|
13
|
+
/** 单条记忆注入长度上限(字符);0 = 不限。 */
|
|
14
|
+
maxCharsPerMemory: number;
|
|
15
|
+
/** 整轮注入总量上限(字符);0 = 不限。超限时低分(排名靠后)尾部先丢。 */
|
|
16
|
+
maxTotalRecallChars: number;
|
|
17
|
+
}
|
|
18
|
+
/** 按 code point 计数截断(不劈开代理对),带引导后缀。 */
|
|
19
|
+
export declare function truncateRecallLine(line: string, maxChars: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* 对召回行施加预算:先逐条截断,再按总量预算装填——装不下的尾部整条丢弃。
|
|
22
|
+
* 输入行应按相关性降序(低分先丢)。
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyRecallBudget(lines: string[], limits: RecallBudgetLimits): string[];
|
|
25
|
+
/**
|
|
26
|
+
* 召回总预算:超时返回 undefined(调用方跳过本轮注入),正常 resolve 返回原值。
|
|
27
|
+
* resolve 为空结果(空数组)与超时(undefined)语义不同,调用方据此区分日志。
|
|
28
|
+
*/
|
|
29
|
+
export declare function raceRecallTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined>;
|
|
30
|
+
/** 召回路径远程嵌入 fetch 的内层钳制(固定值):给 FTS 降级留出总预算内的时间。
|
|
31
|
+
* 仅作用于远程 HTTP 调用;本地推理不受钳制(进程内 CPU 推理无挂起风险)。 */
|
|
32
|
+
export declare const RECALL_EMBED_CAP_MS = 3000;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回预算与超时(ADR-0001 / 规格 A 节;机制移植自 MemoryCore auto-recall 的
|
|
3
|
+
* applyRecallBudget,超时为 dsh 侧新增的总预算语义)。
|
|
4
|
+
*
|
|
5
|
+
* 预算:单条记忆截断上限 + 整轮总量上限——超限截断并以后缀引导模型用记忆工具
|
|
6
|
+
* 查全文(截断是引流而不是损失:工具路径返回完整记录);总量超限按融合排名丢尾部。
|
|
7
|
+
* 超时:召回是增强能力,超时跳过本轮注入、绝不阻塞对话(CONTEXT.md「召回超时」语义
|
|
8
|
+
* 在本模块以 raceTimeout 落地)。
|
|
9
|
+
*/
|
|
10
|
+
/** 截断后缀:显式告诉模型全文在工具侧(引导主动深挖,原版同款设计)。 */
|
|
11
|
+
export const RECALL_TRUNCATION_SUFFIX = '…(已截断;可用 memory_search 或 conversation_search 查看详情)';
|
|
12
|
+
/** 剩余预算小于该值时整条丢弃(截出比后缀还短的行没有意义)。 */
|
|
13
|
+
const MIN_TRUNCATED_RECALL_LINE_CHARS = 40;
|
|
14
|
+
function normalizeLimit(value) {
|
|
15
|
+
if (value == null || !Number.isFinite(value) || value <= 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
return Math.floor(value);
|
|
18
|
+
}
|
|
19
|
+
/** 按 code point 计数截断(不劈开代理对),带引导后缀。 */
|
|
20
|
+
export function truncateRecallLine(line, maxChars) {
|
|
21
|
+
const cps = Array.from(line);
|
|
22
|
+
if (cps.length <= maxChars)
|
|
23
|
+
return line;
|
|
24
|
+
if (maxChars <= RECALL_TRUNCATION_SUFFIX.length) {
|
|
25
|
+
return cps.slice(0, maxChars).join('');
|
|
26
|
+
}
|
|
27
|
+
return `${cps.slice(0, maxChars - RECALL_TRUNCATION_SUFFIX.length).join('').trimEnd()}${RECALL_TRUNCATION_SUFFIX}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 对召回行施加预算:先逐条截断,再按总量预算装填——装不下的尾部整条丢弃。
|
|
31
|
+
* 输入行应按相关性降序(低分先丢)。
|
|
32
|
+
*/
|
|
33
|
+
export function applyRecallBudget(lines, limits) {
|
|
34
|
+
const maxCharsPerMemory = normalizeLimit(limits.maxCharsPerMemory);
|
|
35
|
+
const maxTotalRecallChars = normalizeLimit(limits.maxTotalRecallChars);
|
|
36
|
+
if (!maxCharsPerMemory && !maxTotalRecallChars)
|
|
37
|
+
return lines;
|
|
38
|
+
const budgeted = [];
|
|
39
|
+
let usedChars = 0;
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
const perBounded = maxCharsPerMemory ? truncateRecallLine(line, maxCharsPerMemory) : line;
|
|
43
|
+
if (!maxTotalRecallChars) {
|
|
44
|
+
budgeted.push(perBounded);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const separatorChars = budgeted.length > 0 ? 1 : 0; // 行间换行符计入预算
|
|
48
|
+
const remaining = maxTotalRecallChars - usedChars - separatorChars;
|
|
49
|
+
if (remaining <= 0)
|
|
50
|
+
break;
|
|
51
|
+
if (perBounded.length > remaining) {
|
|
52
|
+
const canFit = remaining >= MIN_TRUNCATED_RECALL_LINE_CHARS;
|
|
53
|
+
if (canFit)
|
|
54
|
+
budgeted.push(truncateRecallLine(perBounded, remaining));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
budgeted.push(perBounded);
|
|
58
|
+
usedChars += separatorChars + perBounded.length;
|
|
59
|
+
}
|
|
60
|
+
return budgeted;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 召回总预算:超时返回 undefined(调用方跳过本轮注入),正常 resolve 返回原值。
|
|
64
|
+
* resolve 为空结果(空数组)与超时(undefined)语义不同,调用方据此区分日志。
|
|
65
|
+
*/
|
|
66
|
+
export async function raceRecallTimeout(promise, timeoutMs) {
|
|
67
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
68
|
+
return promise;
|
|
69
|
+
let timer;
|
|
70
|
+
try {
|
|
71
|
+
return await Promise.race([
|
|
72
|
+
promise,
|
|
73
|
+
new Promise((resolve) => {
|
|
74
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (timer)
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** 召回路径远程嵌入 fetch 的内层钳制(固定值):给 FTS 降级留出总预算内的时间。
|
|
84
|
+
* 仅作用于远程 HTTP 调用;本地推理不受钳制(进程内 CPU 推理无挂起风险)。 */
|
|
85
|
+
export const RECALL_EMBED_CAP_MS = 3_000;
|
package/dist/util/text.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 文本工具:ContentBlock → 纯文本;BM25
|
|
2
|
+
* 文本工具:ContentBlock → 纯文本;FTS / BM25 共用分词。
|
|
3
3
|
*/
|
|
4
4
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
5
5
|
/** 把消息的 ContentBlock[] 展平成纯文本(仅 text 块)。 */
|
|
6
6
|
export declare function blocksToText(blocks: readonly ContentBlock[] | undefined): string;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 中英混排分词:jieba 词元 ∪ 拉丁词 ∪ CJK 二元组,按首次出现顺序去重。
|
|
9
|
+
*
|
|
10
|
+
* - 词元给 BM25 提供高精度整词命中("负载均衡"作为词,idf 远高于碎片二元组);
|
|
11
|
+
* - 二元组保住子词召回底线:查询"负载"仍能命中只含"负载均衡"词元的行,
|
|
12
|
+
* 且旧库纯二元组索引无需迁移即可被新查询命中(新查询仍含二元组 token);
|
|
13
|
+
* - 去重防 2 字词与其自身二元组重复计数(FTS tf / bm25.ts 词频被同一出现双计);
|
|
14
|
+
* - jieba 加载失败时 jiebaCut 返回 undefined,自动退化为纯二元组(原 0.7 行为)。
|
|
10
15
|
*/
|
|
11
16
|
export declare function tokenize(text: string): string[];
|
package/dist/util/text.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { jiebaCut } from './tokenizer.js';
|
|
1
2
|
/** 把消息的 ContentBlock[] 展平成纯文本(仅 text 块)。 */
|
|
2
3
|
export function blocksToText(blocks) {
|
|
3
4
|
if (!blocks)
|
|
@@ -13,17 +14,12 @@ export function blocksToText(blocks) {
|
|
|
13
14
|
}
|
|
14
15
|
const CJK_RE = /[\u3400-\u9fff\uf900-\ufaff]/;
|
|
15
16
|
const WORD_RE = /[a-zA-Z0-9][a-zA-Z0-9_-]{1,}/g;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export function tokenize(text) {
|
|
17
|
+
/** token 里至少要有一个字母/数字/CJK 字(输入已小写;滤掉 jieba 切出的纯标点 token)。 */
|
|
18
|
+
const TOKEN_KEEP_RE = /[a-z0-9\u3400-\u9fff\uf900-\ufaff]/;
|
|
19
|
+
/** CJK 连续段二元组(jieba 失败回退时的唯一分词,也是并集模式的子词召回底线)。 */
|
|
20
|
+
function cjkBigrams(text) {
|
|
21
21
|
const tokens = [];
|
|
22
|
-
const
|
|
23
|
-
for (const m of lower.matchAll(WORD_RE))
|
|
24
|
-
tokens.push(m[0]);
|
|
25
|
-
// CJK 二元组
|
|
26
|
-
const cjk = lower.replace(/[^\u3400-\u9fff\uf900-\ufaff]/g, ' ');
|
|
22
|
+
const cjk = text.replace(/[^\u3400-\u9fff\uf900-\ufaff]/g, ' ');
|
|
27
23
|
let i = 0;
|
|
28
24
|
while (i < cjk.length) {
|
|
29
25
|
const ch = cjk[i];
|
|
@@ -33,11 +29,41 @@ export function tokenize(text) {
|
|
|
33
29
|
tokens.push(ch + next);
|
|
34
30
|
else
|
|
35
31
|
tokens.push(ch);
|
|
36
|
-
i += 1;
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
i += 1;
|
|
40
32
|
}
|
|
33
|
+
i += 1;
|
|
41
34
|
}
|
|
42
|
-
return tokens
|
|
35
|
+
return tokens;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 中英混排分词:jieba 词元 ∪ 拉丁词 ∪ CJK 二元组,按首次出现顺序去重。
|
|
39
|
+
*
|
|
40
|
+
* - 词元给 BM25 提供高精度整词命中("负载均衡"作为词,idf 远高于碎片二元组);
|
|
41
|
+
* - 二元组保住子词召回底线:查询"负载"仍能命中只含"负载均衡"词元的行,
|
|
42
|
+
* 且旧库纯二元组索引无需迁移即可被新查询命中(新查询仍含二元组 token);
|
|
43
|
+
* - 去重防 2 字词与其自身二元组重复计数(FTS tf / bm25.ts 词频被同一出现双计);
|
|
44
|
+
* - jieba 加载失败时 jiebaCut 返回 undefined,自动退化为纯二元组(原 0.7 行为)。
|
|
45
|
+
*/
|
|
46
|
+
export function tokenize(text) {
|
|
47
|
+
const lower = text.toLowerCase();
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const tokens = [];
|
|
50
|
+
const push = (t) => {
|
|
51
|
+
if (t.length >= 2 && TOKEN_KEEP_RE.test(t) && !seen.has(t)) {
|
|
52
|
+
seen.add(t);
|
|
53
|
+
tokens.push(t);
|
|
54
|
+
}
|
|
55
|
+
else if (t.length === 1 && CJK_RE.test(t) && !seen.has(t)) {
|
|
56
|
+
seen.add(t);
|
|
57
|
+
tokens.push(t);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const words = jiebaCut(lower);
|
|
61
|
+
if (words)
|
|
62
|
+
for (const w of words)
|
|
63
|
+
push(w.trim());
|
|
64
|
+
for (const m of lower.matchAll(WORD_RE))
|
|
65
|
+
push(m[0]);
|
|
66
|
+
for (const bg of cjkBigrams(lower))
|
|
67
|
+
push(bg);
|
|
68
|
+
return tokens;
|
|
43
69
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type TokenizerMode = 'jieba' | 'bigram';
|
|
2
|
+
/** 启动期主动初始化并返回模式(MemoryDb.init 记日志用)。 */
|
|
3
|
+
export declare function ensureTokenizer(): TokenizerMode;
|
|
4
|
+
/**
|
|
5
|
+
* FTS 分词器版本戳(存 embedding_meta 表,键 fts_tokenizer)。
|
|
6
|
+
* 戳 ≠ 当前生效分词器 → FTS 表 drop 后从源表全量回灌(与 family 列迁移同款语义)。
|
|
7
|
+
* 回退模式下戳为 bigram-v1:若历史索引是 jieba 分词建的,同样触发重建,
|
|
8
|
+
* 保证戳永远如实反映"构建当前 FTS 内容的分词器"。
|
|
9
|
+
*/
|
|
10
|
+
export declare function tokenizerStamp(): string;
|
|
11
|
+
/** 模式描述(日志用)。 */
|
|
12
|
+
export declare function describeTokenizer(): string;
|
|
13
|
+
/** jieba 切词(回退模式下返回 undefined,调用方走二元组路径)。 */
|
|
14
|
+
export declare function jiebaCut(text: string): string[] | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分词器装配:jieba 词级分词(@node-rs/jieba,Rust napi 预编译二进制)优先,
|
|
3
|
+
* 加载失败(平台无预编译二进制等)永久回退 CJK 二元组。
|
|
4
|
+
*
|
|
5
|
+
* - 惰性单例:首次调用即定死本进程的分词模式,不会运行中漂移——
|
|
6
|
+
* FTS 读写两侧共用同一实例(util/text.ts 的 tokenize),索引/查询天然对齐;
|
|
7
|
+
* - require 走 createRequire(与 sqlite-vec 同款):失败只降级不抛出,
|
|
8
|
+
* 由 MemoryDb.init() 在启动时主动 ensure 并记录模式日志。
|
|
9
|
+
*/
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
let mode;
|
|
13
|
+
let cutFn;
|
|
14
|
+
/** 惰性初始化(永不抛出)。词典约 5MB,首次加载 ~100ms。 */
|
|
15
|
+
function ensure() {
|
|
16
|
+
if (mode !== undefined)
|
|
17
|
+
return mode;
|
|
18
|
+
try {
|
|
19
|
+
const { Jieba } = require('@node-rs/jieba');
|
|
20
|
+
const { dict } = require('@node-rs/jieba/dict');
|
|
21
|
+
const jieba = Jieba.withDict(dict);
|
|
22
|
+
cutFn = (text) => jieba.cut(text, true);
|
|
23
|
+
mode = 'jieba';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
cutFn = undefined;
|
|
27
|
+
mode = 'bigram';
|
|
28
|
+
}
|
|
29
|
+
return mode;
|
|
30
|
+
}
|
|
31
|
+
/** 启动期主动初始化并返回模式(MemoryDb.init 记日志用)。 */
|
|
32
|
+
export function ensureTokenizer() {
|
|
33
|
+
return ensure();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* FTS 分词器版本戳(存 embedding_meta 表,键 fts_tokenizer)。
|
|
37
|
+
* 戳 ≠ 当前生效分词器 → FTS 表 drop 后从源表全量回灌(与 family 列迁移同款语义)。
|
|
38
|
+
* 回退模式下戳为 bigram-v1:若历史索引是 jieba 分词建的,同样触发重建,
|
|
39
|
+
* 保证戳永远如实反映"构建当前 FTS 内容的分词器"。
|
|
40
|
+
*/
|
|
41
|
+
export function tokenizerStamp() {
|
|
42
|
+
return ensure() === 'jieba' ? 'jieba-v1' : 'bigram-v1';
|
|
43
|
+
}
|
|
44
|
+
/** 模式描述(日志用)。 */
|
|
45
|
+
export function describeTokenizer() {
|
|
46
|
+
return ensure() === 'jieba'
|
|
47
|
+
? 'jieba 词级分词(@node-rs/jieba)+ CJK 二元组并集'
|
|
48
|
+
: 'jieba 加载失败,回退 CJK 二元组分词(子词召回降级)';
|
|
49
|
+
}
|
|
50
|
+
/** jieba 切词(回退模式下返回 undefined,调用方走二元组路径)。 */
|
|
51
|
+
export function jiebaCut(text) {
|
|
52
|
+
return ensure() === 'jieba' ? cutFn(text) : undefined;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-layered-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "L0~L3 分层蒸馏记忆插件 for DeepSeek Harness:自动捕获对话(L0)、抽取原子记忆(L1)、整合场景块(L2)、蒸馏核心画像/团队方法论(L3),并在模型步骤前自动召回注入。移植自 MemoryCore (TencentDB Agent Memory) 的管线设计。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"license": "MIT",
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@deepseek-ai/schemastery": "3.18.1",
|
|
54
|
+
"@node-rs/jieba": "^2.0.2",
|
|
54
55
|
"sqlite-vec": "^0.1.7-alpha.2"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|