dsh-layered-memory 0.7.1 → 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.
Files changed (42) hide show
  1. package/README.en.md +115 -68
  2. package/README.md +128 -100
  3. package/assets/img/EmbeddingSource.png +0 -0
  4. package/assets/img/MemoryTools.png +0 -0
  5. package/assets/img/ToolTrajectory.png +0 -0
  6. package/dist/client.js +11 -2
  7. package/dist/config.d.ts +26 -1
  8. package/dist/config.js +9 -3
  9. package/dist/hooks/recall.d.ts +11 -8
  10. package/dist/hooks/recall.js +70 -51
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.js +5 -1
  13. package/dist/llm.d.ts +13 -0
  14. package/dist/llm.js +17 -0
  15. package/dist/pipeline/l1.js +5 -4
  16. package/dist/pipeline/l2.js +7 -2
  17. package/dist/pipeline/l3.js +7 -2
  18. package/dist/pipeline/runner.d.ts +36 -5
  19. package/dist/pipeline/runner.js +148 -35
  20. package/dist/pipeline/trigger.d.ts +38 -0
  21. package/dist/pipeline/trigger.js +64 -0
  22. package/dist/store/bm25.js +2 -1
  23. package/dist/store/embedding.d.ts +12 -6
  24. package/dist/store/embedding.js +10 -7
  25. package/dist/store/l0.d.ts +2 -0
  26. package/dist/store/l0.js +4 -0
  27. package/dist/store/l1.d.ts +2 -0
  28. package/dist/store/l1.js +2 -2
  29. package/dist/store/pending.d.ts +27 -6
  30. package/dist/store/pending.js +49 -9
  31. package/dist/store/search-utils.js +3 -2
  32. package/dist/store/session-modes.d.ts +4 -0
  33. package/dist/store/session-modes.js +16 -0
  34. package/dist/store/sqlite.d.ts +12 -0
  35. package/dist/store/sqlite.js +103 -5
  36. package/dist/util/recall-budget.d.ts +32 -0
  37. package/dist/util/recall-budget.js +85 -0
  38. package/dist/util/text.d.ts +8 -3
  39. package/dist/util/text.js +41 -15
  40. package/dist/util/tokenizer.d.ts +14 -0
  41. package/dist/util/tokenizer.js +53 -0
  42. package/package.json +2 -1
@@ -7,6 +7,12 @@
7
7
  */
8
8
  import * as path from 'node:path';
9
9
  import { atomicWriteJson, readJsonIfExists } from './io.js';
10
+ /** 旧格式(无 sessionId 字段)条目加载时归属的会话组。 */
11
+ export const LEGACY_SESSION = 'legacy';
12
+ /** 全新起步:三档都从 1 爬坡(首轮即触发抽取)。 */
13
+ export function freshWarmup() {
14
+ return { auto: 1, chat: 1, work: 1 };
15
+ }
10
16
  export function emptyPending() {
11
17
  return { auto: [], chat: [], work: [] };
12
18
  }
@@ -16,7 +22,8 @@ function isMessage(m) {
16
22
  const r = m;
17
23
  return typeof r.id === 'string' && typeof r.content === 'string' && (r.role === 'user' || r.role === 'assistant');
18
24
  }
19
- /** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。 */
25
+ /** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。
26
+ * 旧格式条目(无 sessionId)归 legacy 组;warmup 缺省 = 全新起步。 */
20
27
  export async function loadPending(file, logger) {
21
28
  const out = emptyPending();
22
29
  let raw;
@@ -26,27 +33,60 @@ export async function loadPending(file, logger) {
26
33
  catch {
27
34
  raw = undefined;
28
35
  }
29
- if (!raw || typeof raw !== 'object' || !raw.buckets || typeof raw.buckets !== 'object')
30
- return out;
36
+ if (!raw || typeof raw !== 'object' || !raw.buckets || typeof raw.buckets !== 'object') {
37
+ return { buckets: out, warmup: freshWarmup() };
38
+ }
31
39
  let dropped = 0;
40
+ let legacy = 0;
32
41
  for (const key of ['auto', 'chat', 'work']) {
33
42
  const arr = raw.buckets[key];
34
43
  if (!Array.isArray(arr))
35
44
  continue;
36
45
  for (const m of arr) {
37
- if (isMessage(m))
38
- out[key].push(m);
39
- else
46
+ if (!isMessage(m)) {
40
47
  dropped++;
48
+ continue;
49
+ }
50
+ const sid = m.sessionId;
51
+ if (typeof sid === 'string' && sid) {
52
+ out[key].push({ ...m, sessionId: sid });
53
+ }
54
+ else {
55
+ legacy++;
56
+ out[key].push({ ...m, sessionId: LEGACY_SESSION });
57
+ }
41
58
  }
42
59
  }
43
60
  if (dropped > 0)
44
61
  logger?.warn(`[memory] 未蒸馏缓冲文件含 ${dropped} 条坏记录,已丢弃`);
45
- return out;
62
+ if (legacy > 0)
63
+ logger?.info(`[memory] 未蒸馏缓冲含 ${legacy} 条旧格式条目,归入 legacy 会话组`);
64
+ const warmup = freshWarmup();
65
+ for (const key of ['auto', 'chat', 'work']) {
66
+ const w = raw.warmup?.[key];
67
+ if (typeof w === 'number' && Number.isFinite(w) && w >= 0)
68
+ warmup[key] = Math.floor(w);
69
+ }
70
+ return { buckets: out, warmup };
71
+ }
72
+ /** 按会话分组(会话切片):组按首条时间排序、组内按时间稳定排序——
73
+ * 蒸馏的一切触发都以切片为单位,切片内永不跨会话混装(ADR-0003)。 */
74
+ export function groupPendingBySession(messages) {
75
+ const groups = new Map();
76
+ for (const m of messages) {
77
+ const g = groups.get(m.sessionId);
78
+ if (g)
79
+ g.push(m);
80
+ else
81
+ groups.set(m.sessionId, [m]);
82
+ }
83
+ return [...groups.entries()]
84
+ .map(([sessionId, msgs]) => ({ sessionId, messages: [...msgs].sort((a, b) => a.timestamp - b.timestamp) }))
85
+ .sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
46
86
  }
47
87
  /** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
48
- export async function savePending(file, buckets) {
49
- const payload = { version: 1, buckets };
88
+ export async function savePending(file, buckets, warmup) {
89
+ const payload = { version: 1, buckets, ...(warmup ? { warmup } : {}) };
50
90
  await atomicWriteJson(file, payload);
51
91
  }
52
92
  export function pendingPathFor(dataDir) {
@@ -3,8 +3,9 @@
3
3
  * - rrfMerge:RRF(Reciprocal Rank Fusion,k=60)多路结果融合,hybrid 检索用;
4
4
  * - bm25RankToScore:FTS5 bm25 rank(负值=更相关)转 0~1 分数;
5
5
  * - buildFtsQuery / tokenizeForFts:FTS5 查询构造与写入侧分词。
6
- * 官方用 jieba 分词;这里用项目自带的 CJK 二元组 + 英文词分词(util/text.ts),
7
- * 读写两侧共用同一分词器,保证查询 token 与索引 token 对齐,且零原生依赖。
6
+ * 分词走 util/text.ts tokenize(jieba + CJK 二元组并集,@node-rs/jieba
7
+ * 预编译二进制,加载失败自动回退纯二元组),读写两侧共用同一分词器,
8
+ * 保证查询 token 与索引 token 对齐;FTS 索引按分词器版本戳自动重建(sqlite.ts)。
8
9
  */
9
10
  import { tokenize } from '../util/text.js';
10
11
  /** 标准 RRF 常数(原论文值);k 越大越偏向低排名项(分布更平滑)。 */
@@ -7,6 +7,8 @@ export declare class SessionModeStore {
7
7
  private readonly entries;
8
8
  private readonly loaded;
9
9
  private persistFailed;
10
+ /** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
11
+ private onModeChange?;
10
12
  /** 串行化持久化写(避免并发原子写撞临时文件名)。 */
11
13
  private writeChain;
12
14
  constructor(dataDir: string, defaultMode: Extract<MemoryMode, 'auto' | 'chat' | 'work'>, logger?: MemoryLogger | undefined);
@@ -15,6 +17,8 @@ export declare class SessionModeStore {
15
17
  get default(): MemoryMode;
16
18
  /** 同步读取:未设置过的会话返回默认档。 */
17
19
  get(sessionId: string): MemoryMode;
20
+ /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
21
+ setModeChangeHandler(cb: (sessionId: string, oldMode: MemoryMode, newMode: MemoryMode) => void): void;
18
22
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
19
23
  set(sessionId: string, mode: MemoryMode): void;
20
24
  /** 等待在途持久化写完成(测试/停机用)。 */
@@ -5,6 +5,7 @@
5
5
  * 存储失败只降级为内存态(warn 不崩),与插件的存储降级不变量一致。
6
6
  */
7
7
  import * as path from 'node:path';
8
+ import { errDetail } from '../util/filelog.js';
8
9
  import { atomicWriteJson, ensureDir, readJsonIfExists } from './io.js';
9
10
  const MODES = ['auto', 'chat', 'work', 'off'];
10
11
  const PRUNE_MS = 90 * 24 * 3600_000;
@@ -19,6 +20,8 @@ export class SessionModeStore {
19
20
  entries = new Map();
20
21
  loaded;
21
22
  persistFailed = false;
23
+ /** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
24
+ onModeChange;
22
25
  /** 串行化持久化写(避免并发原子写撞临时文件名)。 */
23
26
  writeChain = Promise.resolve();
24
27
  constructor(dataDir, defaultMode, logger) {
@@ -52,10 +55,23 @@ export class SessionModeStore {
52
55
  get(sessionId) {
53
56
  return this.entries.get(sessionId)?.mode ?? this.loaded;
54
57
  }
58
+ /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
59
+ setModeChangeHandler(cb) {
60
+ this.onModeChange = cb;
61
+ }
55
62
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
56
63
  set(sessionId, mode) {
64
+ const old = this.get(sessionId);
57
65
  this.entries.set(sessionId, { mode, updatedAt: Date.now() });
58
66
  this.writeChain = this.writeChain.then(() => this.persist());
67
+ if (old !== mode && this.onModeChange) {
68
+ try {
69
+ this.onModeChange(sessionId, old, mode);
70
+ }
71
+ catch (err) {
72
+ this.logger?.warn(`[memory] 档位切换回调失败: ${errDetail(err)}`);
73
+ }
74
+ }
59
75
  }
60
76
  /** 等待在途持久化写完成(测试/停机用)。 */
61
77
  flush() {
@@ -34,6 +34,8 @@ export declare class MemoryDb {
34
34
  private readonly logger?;
35
35
  private stmtUpsertL1;
36
36
  private stmtGetL1;
37
+ /** 主表存在性点查(防御性 FTS 删除的前置判断,走主键索引)。 */
38
+ private stmtL1Exists;
37
39
  private stmtDeleteL1Meta;
38
40
  private stmtDeleteL1Vec?;
39
41
  private stmtInsertL1Vec?;
@@ -44,6 +46,8 @@ export declare class MemoryDb {
44
46
  private stmtL1FtsSearchFamily;
45
47
  private stmtUpsertL0;
46
48
  private stmtGetL0;
49
+ /** 主表存在性点查(同 L1:防御性 FTS 删除的前置判断)。 */
50
+ private stmtL0Exists;
47
51
  private stmtDeleteL0Vec?;
48
52
  private stmtInsertL0Vec?;
49
53
  private stmtSearchL0Vec?;
@@ -81,8 +85,13 @@ export declare class MemoryDb {
81
85
  private hasColumn;
82
86
  /** 重建后的 l1_fts 从 l1_records 全量回灌(仅在 drop 重建时调用)。 */
83
87
  private backfillL1Fts;
88
+ /** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式防大库内存峰值)。 */
89
+ private backfillL0Fts;
84
90
  private readEmbeddingMeta;
85
91
  private writeEmbeddingMeta;
92
+ /** 通用字符串 kv(embedding_meta 表兼作元数据 kv 存储,如 FTS 分词器版本戳)。 */
93
+ private readMetaString;
94
+ private writeMetaString;
86
95
  /**
87
96
  * 持久化 embedding meta(语义:物理向量表当前对应的 provider/维度)。
88
97
  * 活切换在 swapProvider 成功后即写(表已是新维度);启动/补齐链在
@@ -139,6 +148,9 @@ export declare class MemoryDb {
139
148
  countL0(): number;
140
149
  /** 统计 recorded_at >= iso 的消息数(状态面板"今日捕获"用)。 */
141
150
  countL0Since(iso: string): number;
151
+ /** 按会话取最近消息(时间升序返回;走 idx_l0_session_id 索引)。
152
+ * 蒸馏背景参考专用——按会话现查替代全局内存数组(ADR-0003)。 */
153
+ recentL0BySession(sessionId: string, limit: number): L0MessageRecord[];
142
154
  /** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
143
155
  listL0All(): L0MessageRecord[];
144
156
  /** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
@@ -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;
@@ -107,6 +112,9 @@ export class MemoryDb {
107
112
  // dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义);
108
113
  // 后续活切换本地嵌入(维度 > 0)时由 swapProvider 补加载
109
114
  this.ensureVecLoaded();
115
+ // 分词器在首次 FTS 写入(迁移回灌)前定死模式:jieba 就绪 info / 回退 warn 一次
116
+ ensureTokenizer();
117
+ this.logger?.info(`${TAG} 分词器:${describeTokenizer()}`);
110
118
  try {
111
119
  return this.initSchema(providerInfo);
112
120
  }
@@ -287,6 +295,7 @@ export class MemoryDb {
287
295
  timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family
288
296
  FROM l1_records WHERE record_id = ?
289
297
  `);
298
+ this.stmtL1Exists = this.db.prepare('SELECT 1 FROM l1_records WHERE record_id = ?');
290
299
  this.stmtDeleteL1Meta = this.db.prepare('DELETE FROM l1_records WHERE record_id = ?');
291
300
  this.prepareL1VecStatements();
292
301
  // ── L0 schema ──
@@ -314,15 +323,28 @@ export class MemoryDb {
314
323
  timestamp=excluded.timestamp
315
324
  `);
316
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 = ?');
317
327
  this.prepareL0VecStatements();
318
328
  // ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
319
329
  try {
320
- // 旧 l1_fts 无 family 列(FTS5 无法 ALTER)→ drop 后从 l1_records 全量重建
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;
321
337
  let ftsRebuilt = false;
322
- if (this.tableExists('l1_fts') && !this.hasColumn('l1_fts', 'family')) {
338
+ if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || tokenizerChanged)) {
323
339
  this.db.exec('DROP TABLE l1_fts');
324
340
  ftsRebuilt = true;
325
- 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}),重建全文索引`);
326
348
  }
327
349
  this.db.exec(`
328
350
  CREATE VIRTUAL TABLE IF NOT EXISTS l1_fts USING fts5(
@@ -392,6 +414,15 @@ export class MemoryDb {
392
414
  ORDER BY rank ASC
393
415
  LIMIT ?
394
416
  `);
417
+ if (l0FtsRebuilt)
418
+ this.backfillL0Fts();
419
+ // 戳如实记录"构建当前 FTS 内容的分词器"(含全新空表:后续写入即该分词器)
420
+ try {
421
+ this.writeMetaString('fts_tokenizer', wantStamp);
422
+ }
423
+ catch {
424
+ /* 戳写失败只影响下次启动多一次重建,不阻断 */
425
+ }
395
426
  this.ftsAvailable = true;
396
427
  }
397
428
  catch (err) {
@@ -492,6 +523,22 @@ export class MemoryDb {
492
523
  if (rows.length > 0)
493
524
  this.logger?.info(`${TAG} l1_fts 回灌 ${rows.length} 行`);
494
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
+ }
495
542
  readEmbeddingMeta() {
496
543
  try {
497
544
  const row = this.db
@@ -517,6 +564,23 @@ export class MemoryDb {
517
564
  .prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
518
565
  .run('embedding_provider_info', JSON.stringify(info));
519
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
+ }
520
584
  /**
521
585
  * 持久化 embedding meta(语义:物理向量表当前对应的 provider/维度)。
522
586
  * 活切换在 swapProvider 成功后即写(表已是新维度);启动/补齐链在
@@ -606,6 +670,11 @@ export class MemoryDb {
606
670
  /** 事务内的单条写入体(upsertL1 / upsertL1Batch 共用;调用方负责 BEGIN/COMMIT)。 */
607
671
  upsertL1InTx(record, embedding) {
608
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;
609
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));
610
679
  // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
611
680
  if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
@@ -617,7 +686,8 @@ export class MemoryDb {
617
686
  // FTS 删除/插入与元数据同事务:失败必须整体回滚——若只吞 FTS 错误照常 COMMIT,
618
687
  // 已执行的 DELETE 会让该 id 的索引行被删未补,记录从此全文检索不可见(静默丢数据)。
619
688
  if (this.ftsAvailable) {
620
- this.stmtL1FtsDelete.run(record.id);
689
+ if (ftsExisted)
690
+ this.stmtL1FtsDelete.run(record.id);
621
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));
622
692
  }
623
693
  }
@@ -863,6 +933,8 @@ export class MemoryDb {
863
933
  this.db.exec('BEGIN');
864
934
  for (let i = 0; i < records.length; i++) {
865
935
  const r = records[i];
936
+ // 同 upsertL1 的点查预判:全新增路径跳过 UNINDEXED 列的 FTS 全扫删除
937
+ const ftsExisted = this.ftsAvailable ? this.stmtL0Exists.get(r.id) !== undefined : false;
866
938
  this.stmtUpsertL0.run(r.id, r.sessionId, r.role, r.content, r.recordedAt, r.timestamp);
867
939
  if (this.stmtDeleteL0Vec && this.stmtInsertL0Vec) {
868
940
  this.stmtDeleteL0Vec.run(r.id);
@@ -873,7 +945,8 @@ export class MemoryDb {
873
945
  }
874
946
  if (this.ftsAvailable) {
875
947
  // 同 upsertL1:FTS 失败冒泡触发整批回滚,禁止"删了没补"的索引空洞。
876
- this.stmtL0FtsDelete.run(r.id);
948
+ if (ftsExisted)
949
+ this.stmtL0FtsDelete.run(r.id);
877
950
  this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
878
951
  }
879
952
  }
@@ -916,6 +989,31 @@ export class MemoryDb {
916
989
  return 0;
917
990
  }
918
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
+ }
919
1017
  /** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
920
1018
  listL0All() {
921
1019
  if (this.degraded)
@@ -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;
@@ -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
- * 与 MemoryCore 的 BM25 思路一致(无外部分词依赖)。
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
- * MemoryCore 的 BM25 思路一致(无外部分词依赖)。
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 lower = text.toLowerCase();
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.filter((t) => t.length >= 2 || CJK_RE.test(t));
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;