@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +130 -1
  2. package/README.en.md +225 -2
  3. package/README.md +208 -2
  4. package/cordis.patch.yml +32 -0
  5. package/lib/deploy-guard.mjs +463 -275
  6. package/lib/guard-http.mjs +59 -62
  7. package/lib/pitfall-form-map.mjs +94 -84
  8. package/lib/pitfall-score.mjs +262 -185
  9. package/lib/session-file-name.mjs +116 -100
  10. package/lib/zstd-frames.mjs +88 -67
  11. package/memory-db-tools/README.md +29 -1
  12. package/memory-db-tools/audit-analyze.mjs +1 -1
  13. package/memory-db-tools/audit-export.mjs +60 -16
  14. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  15. package/memory-db-tools/lib/known-devices.mjs +137 -0
  16. package/memory-engine/README.md +58 -4
  17. package/memory-engine/bench/bench.mjs +1 -1
  18. package/memory-engine/cli/band-sentinel.mjs +164 -0
  19. package/memory-engine/cli/distill-rollback.mjs +112 -44
  20. package/memory-engine/cli/distill-sessions.mjs +237 -0
  21. package/memory-engine/cli/distill.mjs +196 -95
  22. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  23. package/memory-engine/cli/doc-index.mjs +47 -38
  24. package/memory-engine/cli/domain-tags.mjs +246 -162
  25. package/memory-engine/cli/layer-migrate.mjs +176 -0
  26. package/memory-engine/cli/layer-move.mjs +189 -0
  27. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  28. package/memory-engine/cli/recall.mjs +243 -110
  29. package/memory-engine/cli/selfcheck.mjs +93 -44
  30. package/memory-engine/cli/serve-start.mjs +221 -140
  31. package/memory-engine/cli/sweep.mjs +83 -44
  32. package/memory-engine/cli/sync.mjs +93 -57
  33. package/memory-engine/config/audit-devices.json +6 -0
  34. package/memory-engine/config/bands.json +7 -0
  35. package/memory-engine/config/layers.json +7 -0
  36. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  37. package/memory-engine/dict/lexicon-config.json +11 -0
  38. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  39. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  40. package/memory-engine/lib/band-sentinel.mjs +417 -0
  41. package/memory-engine/lib/cli-strict.mjs +128 -0
  42. package/memory-engine/lib/cli-util.mjs +147 -125
  43. package/memory-engine/lib/config.mjs +83 -96
  44. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  45. package/memory-engine/lib/distill-rollback.mjs +173 -133
  46. package/memory-engine/lib/distill.mjs +878 -585
  47. package/memory-engine/lib/doc-index-store.mjs +208 -235
  48. package/memory-engine/lib/doc-index.mjs +351 -350
  49. package/memory-engine/lib/doc-scan.mjs +178 -156
  50. package/memory-engine/lib/doc-summarize.mjs +220 -205
  51. package/memory-engine/lib/domain-tags.mjs +358 -277
  52. package/memory-engine/lib/embedder.mjs +175 -194
  53. package/memory-engine/lib/hashes.mjs +22 -7
  54. package/memory-engine/lib/layer-migrate.mjs +330 -0
  55. package/memory-engine/lib/layer-move.mjs +307 -0
  56. package/memory-engine/lib/layers.mjs +191 -0
  57. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  58. package/memory-engine/lib/memory-source.mjs +78 -80
  59. package/memory-engine/lib/pitfall-source.mjs +115 -114
  60. package/memory-engine/lib/policy-source.mjs +279 -261
  61. package/memory-engine/lib/recall-log.mjs +285 -0
  62. package/memory-engine/lib/recall.mjs +565 -240
  63. package/memory-engine/lib/selfcheck.mjs +142 -106
  64. package/memory-engine/lib/session-distill.mjs +603 -0
  65. package/memory-engine/lib/session-source.mjs +421 -0
  66. package/memory-engine/lib/sweep.mjs +107 -130
  67. package/memory-engine/lib/sync.mjs +110 -140
  68. package/memory-engine/lib/tick-hook.mjs +123 -148
  69. package/memory-engine/lib/vectors-store.mjs +213 -188
  70. package/memory-engine/test/12-layering.test.mjs +539 -0
  71. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  72. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  73. package/memory-engine/tools/fetch-model.mjs +167 -87
  74. package/memory-engine/tools/smoke-recall.mjs +94 -38
  75. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  76. package/package.json +11 -1
@@ -1,127 +1,125 @@
1
- // 记忆引擎 v2 · 离线层 —— 记忆库只读数据源
1
+ // 记忆库的只读数据源。
2
2
  //
3
- // 铁律(方案 §3.1 / §7):对 memory.db 只读,绝不写。只读连接在 DELETE journal 模式下
4
- // 不阻塞写者,也不被写者阻塞(A6 实测:未提交写不阻塞读)。
3
+ // 契约:对 memory.db 只开只读连接;语句在**打开阶段**一次性备好 —— 表结构不对应当是
4
+ // open 当场就炸,而不是拖到某次查询才现形。只读连接在 DELETE journal 下既不挡写者、
5
+ // 也不被写者挡。
5
6
  import fs from 'node:fs';
6
7
  import { DatabaseSync } from 'node:sqlite';
7
8
 
9
+ const BUSY_TIMEOUT_MS = 5000;
10
+
11
+ /** 全部预编译语句(键名即内部代号,SQL 文本集中在这里便于比对 schema)。 */
12
+ const STATEMENT_SQL = {
13
+ count: 'SELECT COUNT(*) AS n FROM memories',
14
+ maxUpdated: 'SELECT MAX(updated_at) AS m FROM memories',
15
+ idRange: 'SELECT MIN(id) AS lo, MAX(id) AS hi, COUNT(*) AS n FROM memories',
16
+ pinned: 'SELECT id FROM memories WHERE pinned = 1 ORDER BY id',
17
+ page: `SELECT id, text, tags, pinned, updated_at FROM memories
18
+ WHERE updated_at >= ? AND id > ? ORDER BY id LIMIT ?`,
19
+ segment: `SELECT id, text, tags, pinned, updated_at FROM memories
20
+ WHERE id >= ? AND id <= ? ORDER BY id`,
21
+ byId: 'SELECT id, text, tags, pinned, created_at, updated_at FROM memories WHERE id = ?',
22
+ allTexts: 'SELECT id, text FROM memories ORDER BY id',
23
+ allTags: 'SELECT id, tags, updated_at FROM memories',
24
+ recent: 'SELECT id FROM memories ORDER BY updated_at DESC, id DESC LIMIT ?',
25
+ };
26
+
27
+ /** FTS5 补位:中文连续串几乎不可用,只补标签/英文/数字。 */
28
+ const FTS_SQL = `SELECT m.id AS id, m.pinned AS pinned, bm25(memories_fts) AS score
29
+ FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid
30
+ WHERE memories_fts MATCH ? AND m.pinned = 0
31
+ ORDER BY score LIMIT ?`;
32
+
33
+ function asVecRow(r) {
34
+ return {
35
+ id: Number(r.id),
36
+ text: r.text,
37
+ tags: r.tags,
38
+ pinned: Number(r.pinned),
39
+ updated_at: Number(r.updated_at),
40
+ };
41
+ }
42
+
43
+ const nullableNumber = (v) => (v == null ? null : Number(v));
44
+
8
45
  export function openMemorySource(dbPath) {
9
46
  if (!fs.existsSync(dbPath)) {
10
47
  throw new Error(`记忆库不存在: ${dbPath}`);
11
48
  }
49
+
12
50
  const db = new DatabaseSync(dbPath, { readOnly: true });
13
- db.exec('PRAGMA busy_timeout = 5000;');
14
-
15
- const S = {
16
- count: db.prepare('SELECT COUNT(*) AS n FROM memories'),
17
- maxUpdated: db.prepare('SELECT MAX(updated_at) AS m FROM memories'),
18
- idRange: db.prepare('SELECT MIN(id) AS lo, MAX(id) AS hi, COUNT(*) AS n FROM memories'),
19
- pinned: db.prepare('SELECT id FROM memories WHERE pinned = 1 ORDER BY id'),
20
- page: db.prepare(
21
- `SELECT id, text, tags, pinned, updated_at FROM memories
22
- WHERE updated_at >= ? AND id > ? ORDER BY id LIMIT ?`,
23
- ),
24
- segment: db.prepare(
25
- `SELECT id, text, tags, pinned, updated_at FROM memories
26
- WHERE id >= ? AND id <= ? ORDER BY id`,
27
- ),
28
- byId: db.prepare(
29
- 'SELECT id, text, tags, pinned, created_at, updated_at FROM memories WHERE id = ?',
30
- ),
31
- allTexts: db.prepare('SELECT id, text FROM memories ORDER BY id'),
32
- tagsOf: db.prepare('SELECT id, tags, updated_at FROM memories'),
33
- };
51
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS};`);
52
+
53
+ const q = {};
54
+ for (const [key, sql] of Object.entries(STATEMENT_SQL)) q[key] = db.prepare(sql);
55
+
56
+ let closed = false;
57
+ // FTS 表可能不存在 / 表达式可能非法 ⇒ 语句**懒备**,失败要走「补位路径空」而不是让 open 失败
58
+ let ftsStmt = null;
34
59
 
35
60
  return {
36
61
  dbPath,
37
62
  readOnly: true,
38
63
 
39
- count() {
40
- return Number(S.count.get().n);
41
- },
64
+ count: () => Number(q.count.get().n),
42
65
 
43
- maxUpdatedAt() {
44
- const v = S.maxUpdated.get().m;
66
+ maxUpdatedAt: () => {
67
+ const v = q.maxUpdated.get().m;
45
68
  return v == null ? 0 : Number(v);
46
69
  },
47
70
 
48
- idRange() {
49
- const r = S.idRange.get();
50
- return { lo: r.lo == null ? null : Number(r.lo), hi: r.hi == null ? null : Number(r.hi), n: Number(r.n) };
71
+ idRange: () => {
72
+ const r = q.idRange.get();
73
+ return { lo: nullableNumber(r.lo), hi: nullableNumber(r.hi), n: Number(r.n) };
51
74
  },
52
75
 
53
- /** 召回用:pinned 集合(方案 §3.3:召回排除 pinned,交给 dsh-memory 注入) */
54
- pinnedIds() {
55
- return S.pinned.all().map((r) => Number(r.id));
56
- },
76
+ /** 召回排除集合(pinned 条目由宿主插件注入,引擎侧不重复给)。 */
77
+ pinnedIds: () => q.pinned.all().map((r) => Number(r.id)),
57
78
 
58
- /** 增量页:updated_at >= sinceTs 且 id > afterId,按 id 升序 */
59
- incrementalPage(sinceTs, afterId, limit) {
60
- return S.page.all(Number(sinceTs), Number(afterId), Number(limit)).map(mapRow);
61
- },
79
+ /** 增量页:updated_at >= sinceTs 且 id > afterId,按 id 升序。 */
80
+ incrementalPage: (sinceTs, afterId, limit) =>
81
+ q.page.all(Number(sinceTs), Number(afterId), Number(limit)).map(asVecRow),
62
82
 
63
- /** id 段(兜底 hash 轮转用) */
64
- segment(lo, hi) {
65
- return S.segment.all(Number(lo), Number(hi)).map(mapRow);
66
- },
83
+ /** id 闭区间段(兜底轮转用)。 */
84
+ segment: (lo, hi) => q.segment.all(Number(lo), Number(hi)).map(asVecRow),
67
85
 
68
- getById(id) {
69
- const r = S.byId.get(Number(id));
86
+ getById: (id) => {
87
+ const r = q.byId.get(Number(id));
70
88
  return r ? { ...r, id: Number(r.id) } : null;
71
89
  },
72
90
 
73
- /** 全表 id + 正文(兜底校验用;570 条量级纯内存可承受) */
74
- allTexts() {
75
- return S.allTexts.all().map((r) => ({ id: Number(r.id), text: r.text }));
76
- },
91
+ /** 全表 id + 正文(兜底校验用,体量在内存可承受范围内)。 */
92
+ allTexts: () => q.allTexts.all().map((r) => ({ id: Number(r.id), text: r.text })),
77
93
 
78
- allTags() {
79
- return S.tagsOf.all().map((r) => ({ id: Number(r.id), tags: r.tags }));
80
- },
94
+ allTags: () => q.allTags.all().map((r) => ({ id: Number(r.id), tags: r.tags })),
95
+
96
+ /** 最近改动的若干条(降级兜底用:编码失败时按这个顺序给固定 Top-K,不依赖向量)。 */
97
+ recent: (limit) => q.recent.all(Math.max(1, Math.trunc(Number(limit) || 1))).map((r) => ({ id: Number(r.id) })),
81
98
 
82
- /**
83
- * FTS5 补位检索(方案 §3.4 A4:中文连续串几近不可用,只补标签/英文/数字)。
84
- * @param {string} matchExpr 已构造好的 FTS5 MATCH 表达式
85
- */
86
99
  ftsSearch(matchExpr, limit) {
87
100
  try {
88
- return db
89
- .prepare(
90
- `SELECT m.id AS id, m.pinned AS pinned, bm25(memories_fts) AS score
91
- FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid
92
- WHERE memories_fts MATCH ? AND m.pinned = 0
93
- ORDER BY score LIMIT ?`,
94
- )
101
+ if (!ftsStmt) ftsStmt = db.prepare(FTS_SQL);
102
+ return ftsStmt
95
103
  .all(String(matchExpr), Number(limit))
96
104
  .map((r) => ({ id: Number(r.id), score: Number(r.score) }));
97
105
  } catch (e) {
98
- // 表达式非法/表缺失 → 补位路径空(方案 §3.3:FTS 缺失时不参与,不做零分占位)
106
+ // 表达式非法 / 表缺失:补位路径返回空,不做零分占位
99
107
  return { error: String(e && e.message ? e.message : e) };
100
108
  }
101
109
  },
102
110
 
103
111
  close() {
104
- // 幂等:重复 close 不得抛错(调用方常在 finally 里再关一次)
105
- if (this._closed) return;
106
- this._closed = true;
112
+ if (closed) return;
113
+ closed = true;
107
114
  try {
108
115
  db.close();
109
116
  } catch {
110
- /* ignore */
117
+ /* 关闭失败不算错:调用方常在 finally 里重复关 */
111
118
  }
112
119
  },
120
+
113
121
  get closed() {
114
- return Boolean(this._closed);
122
+ return closed;
115
123
  },
116
124
  };
117
125
  }
118
-
119
- function mapRow(r) {
120
- return {
121
- id: Number(r.id),
122
- text: r.text,
123
- tags: r.tags,
124
- pinned: Number(r.pinned),
125
- updated_at: Number(r.updated_at),
126
- };
127
- }
@@ -1,47 +1,67 @@
1
- // 记忆引擎 v2 · 离线层 —— **坑库只读数据源**(方案 §3 A 点 · 第二向量来源 source='pitfall')
1
+ // 坑库的只读数据源:把 `butler-pitfalls.json` 摆成与记忆源一模一样的形状。
2
2
  //
3
- // 为什么需要它:开工简报原来无差别取 `topN(pitfalls,3)`(与本次任务语义无关,229 条里永远只看最高 3 条)。
4
- // A 点要把「坑库」变成向量库里的第二个来源,让开工简报按任务语义召回相关坑。
3
+ // 为什么要有它:开工简报原来固定取"出现最多的三条",跟本轮任务毫无关系;
4
+ // 有了这个源,坑条目也能按语义被召回。
5
5
  //
6
- // 铁律(方案 §A.2 / §A.4):
7
- // 1. **只读** `butler-pitfalls.json`(A 点不写它);`readOnly: true` 是自证字段。
8
- // 2. 接口必须与 `memory-source.mjs` 的 `openMemorySource` **同形状**(硬契约)——
9
- // 下游三个消费面按方法名硬调用:`sync.mjs:81 incrementalPage` / `sweep.mjs:36 idRange`+`:69 segment`
10
- // / `recall.mjs:120 pinnedIds`+`:141 ftsSearch`。少一个方法 = 第一次跑就 `is not a function`。
11
- // ⚠️ 方案旧文写的 `{ list(), count(), maxSeen() }` 是**错的**(`maxSeen` 在对照物里不存在,实际名 `maxUpdatedAt`)。
12
- // 3. `segment()` / `incrementalPage()` 返回行**必须含 `id / text / updated_at`**:
13
- // `sweep.mjs:77` 读 `r.text`、`:110` 取 `r.updated_at`,缺 `updated_at` 不报错、只会把 `undefined` 写进向量行。
14
- // 4. 轮转语义**不是 id 段**:坑无数字 id 段 ⇒ `idRange()` 给 `{lo:0,hi:n-1,n}`(稳定等价量,= 排序后下标),
15
- // `segment(lo,hi)` 按 `lastSeen` 升序(平局按 `id` 字典序)切片;`incrementalPage` 用同一排序做**稳定游标**。
6
+ // 四条不许破的口径:
7
+ // 1. 只读那个 JSON,一个字节都不回写;`readOnly:true` 是自证字段。
8
+ // 2. 方法与记忆源一一对应(下游按名字硬调,少一个当场 is not a function):
9
+ // count / maxUpdatedAt / idRange / pinnedIds / incrementalPage / segment /
10
+ // getById / allTexts / allTags / ftsSearch / close。注意名字是 maxUpdatedAt。
11
+ // 3. 分页与分段吐出来的行必须带 id / text / updated_at —— 上层直接取,
12
+ // 缺了不会报错,只会把 undefined 写进向量行。
13
+ // 4. 坑没有数字 id 段:idRange 给"排序后的下标区间",排序键是 lastSeen 升序、
14
+ // 平局按 id 字典序;分页沿用同一套顺序做游标,所以集合中途变了也只多扫不漏、不重复。
15
+
16
16
  import fs from 'node:fs';
17
17
 
18
- /** 坑条目的向量文本(方案 §A.3 定稿;**不含**建议文本 —— 候选池的 suggested/why 不进向量,避免建议变化导致向量漂移) */
18
+ const SEED_MARK = '[seed]';
19
+ const CURSOR_WIDTH = 16;
20
+
21
+ /** 一条坑进向量的文本:seed 标记 + 分类前缀 + 正文 + 工具后缀;建议文本一律不进(会随建议漂移)。 */
19
22
  export function pitfallText(row) {
20
- const cat = row.category ? `[${row.category}] ` : '';
21
- const seed = String(row.source || '') === 'seed' ? '[seed]' : '';
23
+ const seed = String(row.source || '') === 'seed' ? SEED_MARK : '';
24
+ const label = row.category ? `[${row.category}] ` : '';
22
25
  const tools = row.tools && typeof row.tools === 'object' ? Object.keys(row.tools).join(',') : '';
23
- return `${seed}${cat}${row.normText ?? ''}${tools ? ` | tools=${tools}` : ''}`;
26
+ const suffix = tools ? ` | tools=${tools}` : '';
27
+ return `${seed}${label}${row.normText ?? ''}${suffix}`;
24
28
  }
25
29
 
26
- function sortRows(rows) {
27
- return rows
28
- .slice()
29
- .sort((a, b) => a.updated_at - b.updated_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
30
+ // 工具字段的规范形态:对象取键名拼串,其它一律空串。
31
+ function toolsText(item) {
32
+ if (!item.tools || typeof item.tools !== 'object') return '';
33
+ return Object.keys(item.tools).join(',');
30
34
  }
31
35
 
32
- /** 稳定游标键:`<lastSeen 补零>|<id>`(同 `sortRows` 的序即字符串序,平局按 id 字典序) */
33
- function cursorKey(lastSeen, id) {
34
- return `${String(lastSeen).padStart(16, '0')}|${id}`;
36
+ // 稳定顺序:先 lastSeen,再 id 字典序。平局必须先定死,否则翻页会来回抖。
37
+ function byLastSeenThenId(a, b) {
38
+ if (a.updated_at !== b.updated_at) return a.updated_at - b.updated_at;
39
+ if (a.id === b.id) return 0;
40
+ return a.id < b.id ? -1 : 1;
35
41
  }
36
42
 
37
- function mapRow(r) {
38
- return {
39
- id: r.id,
40
- text: r.text,
41
- tags: r.tags,
42
- pinned: 0,
43
- updated_at: r.updated_at,
44
- };
43
+ // 游标的可比较形态:时间戳左补零到定长,再接 '|' 与 id ⇒ 字典序正好等于上面的顺序。
44
+ function cursorOf(updatedAt, id) {
45
+ return `${String(updatedAt).padStart(CURSOR_WIDTH, '0')}|${id}`;
46
+ }
47
+
48
+ // 内层行 → 向量层要的形状(pinned 恒 0:坑库没有这个字段)
49
+ function toPageRow(row) {
50
+ return { id: row.id, text: row.text, tags: row.tags, pinned: 0, updated_at: row.updated_at };
51
+ }
52
+
53
+ // 解析文件并取出条目数组:顶层直接是数组、或外面套一层 `pitfalls` 都认。
54
+ function readEntryList(jsonPath) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
58
+ } catch (e) {
59
+ const why = e && e.message ? e.message : String(e);
60
+ throw new Error(`坑库解析失败: ${jsonPath}(${why})`);
61
+ }
62
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.pitfalls) ? parsed.pitfalls : null;
63
+ if (!list) throw new Error(`坑库结构不认识(应含 pitfalls 数组): ${jsonPath}`);
64
+ return list;
45
65
  }
46
66
 
47
67
  /**
@@ -52,119 +72,100 @@ export function openPitfallSource(jsonPath) {
52
72
  if (!fs.existsSync(jsonPath)) {
53
73
  throw new Error(`坑库不存在: ${jsonPath}`);
54
74
  }
55
- let raw;
56
- try {
57
- raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
58
- } catch (e) {
59
- throw new Error(`坑库解析失败: ${jsonPath}(${String(e && e.message ? e.message : e)})`);
60
- }
61
- const list = Array.isArray(raw) ? raw : Array.isArray(raw.pitfalls) ? raw.pitfalls : null;
62
- if (!list) throw new Error(`坑库结构不认识(应含 pitfalls 数组): ${jsonPath}`);
63
75
 
64
- const rows = sortRows(
65
- list
66
- .filter((x) => x && x.id != null)
67
- .map((x) => ({
68
- id: String(x.id),
69
- text: pitfallText(x),
70
- tags: x.tools && typeof x.tools === 'object' ? Object.keys(x.tools).join(',') : '',
71
- updated_at: Number(x.lastSeen || 0),
72
- })),
73
- );
74
- const byId = new Map(rows.map((r) => [r.id, r]));
75
-
76
- let closed = false;
76
+ const rows = readEntryList(jsonPath)
77
+ .filter((item) => item && item.id != null)
78
+ .map((item) => ({
79
+ id: String(item.id),
80
+ text: pitfallText(item),
81
+ tags: toolsText(item),
82
+ updated_at: Number(item.lastSeen || 0),
83
+ }))
84
+ .sort(byLastSeenThenId);
85
+ const index = new Map(rows.map((row) => [row.id, row]));
86
+
87
+ let shut = false;
77
88
  return {
78
89
  dbPath: jsonPath,
79
90
  readOnly: true,
80
- /** 自证:来源名(下游日志/自检按它报"逐 source") */
91
+ /** 自证:下游日志/自检按它报"逐来源" */
81
92
  sourceName: 'pitfall',
82
- /** 原始条目数(= rows.length;观测用) */
93
+ /** 条目总数(观测用,等于 rows.length) */
83
94
  n: rows.length,
84
95
 
85
- count() {
86
- return rows.length;
87
- },
96
+ count: () => rows.length,
88
97
 
89
- maxUpdatedAt() {
90
- let m = 0;
91
- for (const r of rows) if (r.updated_at > m) m = r.updated_at;
92
- return m;
93
- },
98
+ maxUpdatedAt: () => rows.reduce((peak, row) => (row.updated_at > peak ? row.updated_at : peak), 0),
94
99
 
95
- /** 坑无数字 id 段 ⇒ 给稳定等价量(排序后下标区间) */
96
- idRange() {
97
- if (!rows.length) return { lo: null, hi: null, n: 0 };
98
- return { lo: 0, hi: rows.length - 1, n: rows.length };
99
- },
100
+ /** 坑没有数字 id ⇒ 给稳定等价量:排序后的下标闭区间。 */
101
+ idRange: () => (rows.length ? { lo: 0, hi: rows.length - 1, n: rows.length } : { lo: null, hi: null, n: 0 }),
100
102
 
101
- /** 坑库无 pinned 概念 ⇒ 空集(召回侧据此不排除任何条目) */
102
- pinnedIds() {
103
- return [];
104
- },
103
+ /** 坑库没有 pinned 概念 ⇒ 永远空集(召回侧据此不排除任何条目)。 */
104
+ pinnedIds: () => [],
105
105
 
106
106
  /**
107
- * 增量页:`updated_at >= sinceTs`,按 (lastSeen, id) 升序,游标 = 上一页末条 `id`。
108
- * 游标按「键比较」而非仅按下标:翻页期间即使集合变化也不会重复/跳过(键比较天然幂等)。
107
+ * 增量页:取 `updated_at >= sinceTs` 的条目,按 (lastSeen, id) 升序往后翻,
108
+ * 游标就是上一页末条的 id。游标按"键比较"而不是按下标 ⇒ 中途删改也不会重也不会漏。
109
109
  */
110
- incrementalPage(sinceTs, afterId, limit) {
110
+ incrementalPage: (sinceTs, afterId, limit) => {
111
111
  const since = Number(sinceTs) || 0;
112
- const lim = Number(limit) || 0;
113
- if (!rows.length || lim <= 0) return [];
114
- const cur = afterId == null || afterId === '' ? null : String(afterId);
112
+ const cap = Number(limit) || 0;
113
+ if (!rows.length || cap <= 0) return [];
114
+ const cursorId = afterId == null || afterId === '' ? null : String(afterId);
115
115
  let floor = '';
116
- if (cur != null) {
117
- const hit = byId.get(cur);
118
- if (hit) floor = cursorKey(hit.updated_at, hit.id);
119
- else floor = cursorKey(0, cur); // 游标条已被删:退化成"按 id 起步"(仍单调、不重复)
116
+ if (cursorId != null) {
117
+ const known = index.get(cursorId);
118
+ // 游标条已经被删:退化成"从这个 id 起步",整体仍然单调
119
+ floor = known ? cursorOf(known.updated_at, known.id) : cursorOf(0, cursorId);
120
120
  }
121
- const out = [];
122
- for (const r of rows) {
123
- if (r.updated_at < since) continue;
124
- if (floor && cursorKey(r.updated_at, r.id) <= floor) continue;
125
- out.push(mapRow(r));
126
- if (out.length >= lim) break;
121
+ const page = [];
122
+ for (const row of rows) {
123
+ if (row.updated_at < since) continue;
124
+ if (floor && cursorOf(row.updated_at, row.id) <= floor) continue;
125
+ page.push(toPageRow(row));
126
+ if (page.length >= cap) break;
127
127
  }
128
- return out;
128
+ return page;
129
129
  },
130
130
 
131
- /** 段(下标区间,排序 = lastSeen 升序):返回行含 id/text/updated_at(sweep 硬契约) */
132
- segment(lo, hi) {
133
- const a = Number(lo);
134
- const b = Number(hi);
135
- if (!Number.isFinite(a) || !Number.isFinite(b)) return [];
136
- return rows.slice(Math.max(0, a), Math.max(0, b) + 1).map(mapRow);
131
+ /** 段(下标闭区间,顺序 = lastSeen 升序):行里必须带 text 与 updated_at,上层直接取。 */
132
+ segment: (lo, hi) => {
133
+ const begin = Number(lo);
134
+ const end = Number(hi);
135
+ if (!Number.isFinite(begin) || !Number.isFinite(end)) return [];
136
+ const from = Math.max(0, begin);
137
+ return rows.slice(from, Math.max(0, end) + 1).map(toPageRow);
137
138
  },
138
139
 
139
- getById(id) {
140
- const r = byId.get(String(id));
141
- if (!r) return null;
142
- return { id: r.id, text: r.text, tags: r.tags, pinned: 0, created_at: r.updated_at, updated_at: r.updated_at };
140
+ getById: (id) => {
141
+ const found = index.get(String(id));
142
+ if (!found) return null;
143
+ return {
144
+ id: found.id,
145
+ text: found.text,
146
+ tags: found.tags,
147
+ pinned: 0,
148
+ created_at: found.updated_at,
149
+ updated_at: found.updated_at,
150
+ };
143
151
  },
144
152
 
145
- allTexts() {
146
- return rows.map((r) => ({ id: r.id, text: r.text }));
147
- },
153
+ allTexts: () => rows.map((row) => ({ id: row.id, text: row.text })),
148
154
 
149
- allTags() {
150
- return rows.map((r) => ({ id: r.id, tags: r.tags }));
151
- },
155
+ allTags: () => rows.map((row) => ({ id: row.id, tags: row.tags })),
152
156
 
153
157
  /**
154
- * ⚠️ 坑库**没有 FTS5** ⇒ 必须返回对象而不是抛错(`recall.mjs:141` 期望拿到对象、拿数组才走补位路)。
155
- * 照 `memory-source.mjs:86-101` 的兜底形状。
158
+ * 坑库没有 FTS5 ⇒ 这里必须回一个对象而不是抛错:
159
+ * 召回侧拿对象就走"无关键词补位"那条路,抛错会整条链断掉。
156
160
  */
157
- ftsSearch() {
158
- return { error: '坑库无 FTS5(pitfall source 无 FTS 补位路)' };
159
- },
161
+ ftsSearch: () => ({ error: '坑库无 FTS5(pitfall source 无 FTS 补位路)' }),
160
162
 
161
163
  close() {
162
- // 幂等:重复 close 不得抛错(调用方常在 finally 里再关一次)
163
- if (closed) return;
164
- closed = true;
164
+ // 幂等:调用方常在 finally 里再关一次,重复关不许抛
165
+ shut = true;
165
166
  },
166
167
  get closed() {
167
- return closed;
168
+ return shut;
168
169
  },
169
170
  };
170
171
  }