@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1

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 (132) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +661 -0
  3. package/README.en.md +110 -0
  4. package/README.md +102 -2
  5. package/dsh-memory-local/FORK-BASE.json +24 -0
  6. package/dsh-memory-local/README.md +113 -0
  7. package/dsh-memory-local/build.mjs +101 -0
  8. package/dsh-memory-local/cordis.patch.yml +20 -0
  9. package/dsh-memory-local/deploy.mjs +306 -0
  10. package/dsh-memory-local/entity-status.mjs +75 -0
  11. package/dsh-memory-local/lib/bridge.mjs +108 -0
  12. package/dsh-memory-local/lib/host-adapter.js +1171 -0
  13. package/dsh-memory-local/lib/index.d.ts +128 -0
  14. package/dsh-memory-local/lib/index.js +944 -0
  15. package/dsh-memory-local/lib/injector.mjs +400 -0
  16. package/dsh-memory-local/lib/memory-read.mjs +116 -0
  17. package/dsh-memory-local/lib/module-gate.mjs +68 -0
  18. package/dsh-memory-local/lib/pointer.mjs +152 -0
  19. package/dsh-memory-local/lib/prod-gate.mjs +121 -0
  20. package/dsh-memory-local/lib/recall-log.mjs +118 -0
  21. package/dsh-memory-local/lib/recall.mjs +192 -0
  22. package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
  23. package/dsh-memory-local/lib/user-message.mjs +32 -0
  24. package/dsh-memory-local/lib/vectors-read.mjs +110 -0
  25. package/dsh-memory-local/package.json +51 -0
  26. package/dsh-memory-local/restore.mjs +259 -0
  27. package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
  28. package/dsh-memory-local/src/bridge.mjs +108 -0
  29. package/dsh-memory-local/src/host-adapter.js +1171 -0
  30. package/dsh-memory-local/src/index.d.ts +128 -0
  31. package/dsh-memory-local/src/index.js +944 -0
  32. package/dsh-memory-local/src/injector.mjs +400 -0
  33. package/dsh-memory-local/src/memory-read.mjs +116 -0
  34. package/dsh-memory-local/src/module-gate.mjs +68 -0
  35. package/dsh-memory-local/src/pointer.mjs +152 -0
  36. package/dsh-memory-local/src/recall-log.mjs +118 -0
  37. package/dsh-memory-local/src/recall.mjs +192 -0
  38. package/dsh-memory-local/src/schema-migrate.mjs +365 -0
  39. package/dsh-memory-local/src/user-message.mjs +32 -0
  40. package/dsh-memory-local/src/vectors-read.mjs +110 -0
  41. package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
  42. package/dsh-memory-local/test/probe-entity.mjs +170 -0
  43. package/dsh-memory-local/test/regression.mjs +270 -0
  44. package/dsh-memory-local/test/s2b.test.mjs +568 -0
  45. package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
  46. package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
  47. package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
  48. package/dsh-memory-local/verify-after-restart.mjs +181 -0
  49. package/lib/deploy-guard.mjs +391 -0
  50. package/lib/guard-http.mjs +93 -0
  51. package/lib/pitfall-form-map.mjs +130 -0
  52. package/lib/pitfall-score.mjs +185 -0
  53. package/lib/session-file-name.mjs +160 -0
  54. package/lib/zstd-frames.mjs +78 -0
  55. package/memory-db-tools/README.md +68 -0
  56. package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
  57. package/memory-db-tools/audit-analyze.mjs +352 -0
  58. package/memory-db-tools/audit-dump-ids.mjs +245 -0
  59. package/memory-db-tools/audit-export.mjs +270 -0
  60. package/memory-db-tools/audit-hit-probe.mjs +227 -0
  61. package/memory-db-tools/audit-ledger-probe.mjs +236 -0
  62. package/memory-db-tools/audit-persona-overlap.mjs +277 -0
  63. package/memory-db-tools/audit-struct-probe.mjs +213 -0
  64. package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
  65. package/memory-db-tools/check-memory-db.mjs +8 -0
  66. package/memory-db-tools/dump-memory-db.mjs +12 -0
  67. package/memory-db-tools/lib/memory-scope.mjs +446 -0
  68. package/memory-db-tools/list-memory-summary.mjs +477 -0
  69. package/memory-db-tools/memory-identity-check.mjs +101 -0
  70. package/memory-db-tools/memory-schema-migrate.mjs +290 -0
  71. package/memory-db-tools/memory-scope-fixture.mjs +197 -0
  72. package/memory-db-tools/read-memories.mjs +26 -0
  73. package/memory-db-tools/read-memory-db.mjs +20 -0
  74. package/memory-db-tools/refine-memories.mjs +247 -0
  75. package/memory-db-tools/show-mem-times.mjs +22 -0
  76. package/memory-engine/README.md +70 -0
  77. package/memory-engine/bench/bench.mjs +221 -0
  78. package/memory-engine/cli/distill-rollback.mjs +86 -0
  79. package/memory-engine/cli/distill.mjs +156 -0
  80. package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
  81. package/memory-engine/cli/doc-index.mjs +48 -0
  82. package/memory-engine/cli/domain-tags.mjs +251 -0
  83. package/memory-engine/cli/pitfall-hook.mjs +91 -0
  84. package/memory-engine/cli/recall.mjs +132 -0
  85. package/memory-engine/cli/selfcheck.mjs +57 -0
  86. package/memory-engine/cli/serve-start.mjs +247 -0
  87. package/memory-engine/cli/sweep.mjs +58 -0
  88. package/memory-engine/cli/sync.mjs +69 -0
  89. package/memory-engine/lib/cli-util.mjs +197 -0
  90. package/memory-engine/lib/config.mjs +135 -0
  91. package/memory-engine/lib/dirty-ledger.mjs +174 -0
  92. package/memory-engine/lib/distill-rollback.mjs +178 -0
  93. package/memory-engine/lib/distill.mjs +898 -0
  94. package/memory-engine/lib/doc-index-store.mjs +325 -0
  95. package/memory-engine/lib/doc-index.mjs +529 -0
  96. package/memory-engine/lib/doc-scan.mjs +271 -0
  97. package/memory-engine/lib/doc-summarize.mjs +305 -0
  98. package/memory-engine/lib/domain-tags.mjs +429 -0
  99. package/memory-engine/lib/embedder.mjs +242 -0
  100. package/memory-engine/lib/hashes.mjs +12 -0
  101. package/memory-engine/lib/memory-source.mjs +127 -0
  102. package/memory-engine/lib/pitfall-source.mjs +170 -0
  103. package/memory-engine/lib/policy-source.mjs +371 -0
  104. package/memory-engine/lib/recall.mjs +331 -0
  105. package/memory-engine/lib/selfcheck.mjs +131 -0
  106. package/memory-engine/lib/sweep.mjs +159 -0
  107. package/memory-engine/lib/sync.mjs +180 -0
  108. package/memory-engine/lib/tick-hook.mjs +218 -0
  109. package/memory-engine/lib/vectors-store.mjs +292 -0
  110. package/memory-engine/package-lock.json +1086 -0
  111. package/memory-engine/package.json +22 -0
  112. package/memory-engine/serve.mjs +171 -0
  113. package/memory-engine/test/01-store.test.mjs +127 -0
  114. package/memory-engine/test/02-sync.test.mjs +196 -0
  115. package/memory-engine/test/03-sweep.test.mjs +145 -0
  116. package/memory-engine/test/04-recall.test.mjs +214 -0
  117. package/memory-engine/test/05-selfcheck.test.mjs +175 -0
  118. package/memory-engine/test/06-doc-index.test.mjs +928 -0
  119. package/memory-engine/test/07-domain-tags.test.mjs +298 -0
  120. package/memory-engine/test/08-distill.test.mjs +686 -0
  121. package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
  122. package/memory-engine/test/10-tick-hook.test.mjs +252 -0
  123. package/memory-engine/test/11-policy-source.test.mjs +224 -0
  124. package/memory-engine/test/_tickhook-worker.mjs +56 -0
  125. package/memory-engine/test/fixtures.mjs +205 -0
  126. package/memory-engine/test/harness.mjs +73 -0
  127. package/memory-engine/test/run.mjs +35 -0
  128. package/memory-engine/tools/bridge-smoke.mjs +80 -0
  129. package/memory-engine/tools/fetch-model.mjs +128 -0
  130. package/memory-engine/tools/smoke-recall.mjs +76 -0
  131. package/package.json +53 -4
  132. package/persona-memory-audit.mjs +217 -0
@@ -0,0 +1,180 @@
1
+ // 记忆引擎 v2 · 离线层 —— 增量同步(方案 §3.1 主路径)
2
+ //
3
+ // 主路径:memories.updated_at > meta.lastIncrementalSyncAt 增量扫描(只读 memory.db,不写)
4
+ // 批 50;失败保留进度、下轮续跑;换模型 / 维度变化 → 全量重建。
5
+ // ⚠ 兜底(外置,不与本路径共用候选集)见 sweep.mjs。
6
+ import { textHash } from './hashes.mjs';
7
+ import {
8
+ META_KEYS,
9
+ getMetaFor,
10
+ setMetaFor,
11
+ getMeta,
12
+ setMeta,
13
+ getHashes,
14
+ upsertVectors,
15
+ countVectors,
16
+ deleteSource,
17
+ } from './vectors-store.mjs';
18
+
19
+ /**
20
+ * @param {object} p
21
+ * @param {object} p.source memory-source / pitfall-source(只读)
22
+ * @param {object} p.store vectors-store { db }
23
+ * @param {object} p.embedder 编码器(encode/encodeBatch)
24
+ * @param {string} p.modelId
25
+ * @param {number} p.dim
26
+ * @param {string} [p.sourceName='memory'] 向量来源名(写入 `vectors.source` 与 meta 命名空间)
27
+ */
28
+ export async function runIncrementalSync({
29
+ source,
30
+ store,
31
+ embedder,
32
+ modelId,
33
+ dim,
34
+ sourceName = 'memory',
35
+ batchSize = 50,
36
+ full = false,
37
+ dryRun = false,
38
+ log = () => {},
39
+ now = () => Date.now(),
40
+ untilEmpty = true,
41
+ }) {
42
+ const db = store.db;
43
+ const src = String(sourceName || 'memory');
44
+ const startedAt = now();
45
+ // ⚠️ 水位/模型/维度键一律**按来源**读写(共库不串味:方案 §A.6 首要风险 / §1.3-③.1)
46
+ const metaModel = getMetaFor(db, META_KEYS.modelId, src);
47
+ const metaDim = getMetaFor(db, META_KEYS.dim, src);
48
+ const metaHost = getMeta(db, META_KEYS.hostId);
49
+
50
+ const reasons = [];
51
+ if (metaModel && metaModel !== modelId) reasons.push(`模型变更 ${metaModel} → ${modelId}`);
52
+ if (metaDim && Number(metaDim) !== Number(dim)) reasons.push(`维度变更 ${metaDim} → ${dim}`);
53
+ if (full) reasons.push('显式 --full');
54
+
55
+ let rebuild = reasons.length > 0;
56
+ let watermark = 0;
57
+ let cleared = 0;
58
+
59
+ if (rebuild) {
60
+ if (!dryRun) {
61
+ cleared = deleteSource(db, src);
62
+ setMetaFor(db, META_KEYS.modelId, src, modelId);
63
+ setMetaFor(db, META_KEYS.dim, src, String(dim));
64
+ setMetaFor(db, META_KEYS.lastIncrementalSyncAt, src, '0');
65
+ }
66
+ log(`[sync] 全量重建:${reasons.join(' / ')}(清空 ${cleared} 条向量)`);
67
+ } else {
68
+ watermark = Number(getMetaFor(db, META_KEYS.lastIncrementalSyncAt, src) || 0);
69
+ }
70
+
71
+ const stats = {
72
+ source: src,
73
+ mode: rebuild ? 'rebuild' : 'incremental',
74
+ reasons,
75
+ watermarkStart: watermark,
76
+ watermarkEnd: watermark,
77
+ scanned: 0,
78
+ encoded: 0,
79
+ skippedSameHash: 0,
80
+ upserted: 0,
81
+ batches: 0,
82
+ failures: [],
83
+ cleared,
84
+ vectorsBefore: countVectors(db, src),
85
+ vectorsAfter: 0,
86
+ elapsedMs: 0,
87
+ startedAt,
88
+ finishedAt: 0,
89
+ };
90
+
91
+ let afterId = 0;
92
+ let maxSeenUpdatedAt = watermark;
93
+
94
+ try {
95
+ for (;;) {
96
+ const page = source.incrementalPage(watermark, afterId, batchSize);
97
+ if (!page.length) break;
98
+ stats.batches += 1;
99
+ afterId = page[page.length - 1].id;
100
+ stats.scanned += page.length;
101
+ for (const r of page) if (r.updated_at > maxSeenUpdatedAt) maxSeenUpdatedAt = r.updated_at;
102
+
103
+ const hashes = getHashes(db, src, page.map((r) => r.id));
104
+ const need = [];
105
+ for (const r of page) {
106
+ const h = textHash(r.text);
107
+ if (hashes.get(String(r.id)) === h) {
108
+ stats.skippedSameHash += 1;
109
+ continue;
110
+ }
111
+ need.push({ row: r, hash: h });
112
+ }
113
+
114
+ if (need.length) {
115
+ const vecs = await embedder.encodeBatch(need.map((n) => n.row.text));
116
+ if (vecs.length !== need.length) {
117
+ throw new Error(`编码器返回数量不符:期望 ${need.length},实际 ${vecs.length}`);
118
+ }
119
+ if (!dryRun) {
120
+ upsertVectors(
121
+ db,
122
+ need.map((n, i) => ({
123
+ source: src,
124
+ refId: n.row.id,
125
+ model: modelId,
126
+ dim: vecs[i].length,
127
+ vec: vecs[i],
128
+ textHash: n.hash,
129
+ updatedAt: n.row.updated_at,
130
+ })),
131
+ );
132
+ }
133
+ stats.encoded += need.length;
134
+ stats.upserted += need.length;
135
+ }
136
+
137
+ // 进度前移(失败保留进度可续跑):用「本批已扫到的最大 updated_at」,重扫边界条幂等
138
+ if (!dryRun) {
139
+ setMetaFor(db, META_KEYS.modelId, src, modelId);
140
+ setMetaFor(db, META_KEYS.dim, src, String(dim));
141
+ if (!metaHost) setMeta(db, META_KEYS.hostId, String(store.hostId || ''));
142
+ setMetaFor(db, META_KEYS.lastIncrementalSyncAt, src, String(maxSeenUpdatedAt));
143
+ }
144
+ log(
145
+ `[sync] 批 ${stats.batches}:扫 ${page.length} 条(新编码 ${need.length} / 同哈希跳过 ${
146
+ page.length - need.length
147
+ })水位=${maxSeenUpdatedAt}`,
148
+ );
149
+ if (!untilEmpty) break;
150
+ }
151
+ stats.watermarkEnd = maxSeenUpdatedAt;
152
+ } catch (e) {
153
+ stats.failures.push({ stage: 'sync', message: String(e && e.message ? e.message : e), atBatch: stats.batches });
154
+ stats.watermarkEnd = maxSeenUpdatedAt; // 已成功批次的水位已落盘
155
+ stats.error = stats.failures[0].message;
156
+ }
157
+
158
+ stats.vectorsAfter = countVectors(db, src);
159
+ stats.finishedAt = now();
160
+ stats.elapsedMs = stats.finishedAt - stats.startedAt;
161
+ if (!dryRun) {
162
+ setMetaFor(
163
+ db,
164
+ META_KEYS.lastSyncStats,
165
+ src,
166
+ JSON.stringify({
167
+ source: src,
168
+ mode: stats.mode,
169
+ scanned: stats.scanned,
170
+ encoded: stats.encoded,
171
+ skippedSameHash: stats.skippedSameHash,
172
+ watermarkEnd: stats.watermarkEnd,
173
+ elapsedMs: stats.elapsedMs,
174
+ failures: stats.failures.length,
175
+ at: new Date(stats.finishedAt).toISOString(),
176
+ }),
177
+ );
178
+ }
179
+ return stats;
180
+ }
@@ -0,0 +1,218 @@
1
+ // 记忆引擎 v2 · 离线层 —— 坑库向量同步「标记 → 消费」钩子(方案 §A.2 第 10 行 · A 点)
2
+ //
3
+ // 要解决的问题:坑库(butler-pitfalls.json)变了之后,谁去触发 `source='pitfall'` 的增量编码?
4
+ // 方案选的是**松耦合信号**:`pitfall` job 末尾**只置一个标记**(不直接编码、不拖慢 job),
5
+ // 消费方(本模块,由 `memory-vectors` 档或手动 CLI 拉起)看到标记才跑一次坑源增量同步。
6
+ //
7
+ // ⚠️ 这是全方案**唯一"有意双写者"**的文件(方案 §A.4 · C5 2026-10-08 回修):
8
+ // ① 置标记方 = `pitfall` job ── 原子写(临时文件 + rename),**必须写 `updatedAt`**
9
+ // ② 清标记方 = 本模块消费者 ── **原子读改写**,且**只在标记 `updatedAt` 早于本次消费起点时才清**
10
+ // (否则会把并发新写入的标记一起清掉 = 丢标记、下轮不编码、还无人告警)
11
+ // 两方**不共用同一把锁**(跨 job 共用会把握手串成串行);靠 `updatedAt` stale 判定 + 原子 rename 兜住。
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { runIncrementalSync } from './sync.mjs';
16
+
17
+ export const MARKER_SCHEMA = 1;
18
+ /** 标记文件默认名(登记见 `tools/dsh-butler/lib/state-files.mjs`) */
19
+ export const MARKER_FILENAME = 'butler-pitfall-vector-dirty.json';
20
+
21
+ /** 默认标记文件路径(DSH_HOME 口径;可注入) */
22
+ export function markerPathFor(dshHome = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')) {
23
+ return path.join(dshHome, MARKER_FILENAME);
24
+ }
25
+
26
+ function tmpName(p) {
27
+ return `${p}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
28
+ }
29
+
30
+ /**
31
+ * Windows 上 `rename` 覆盖一个**正被另一个进程读/改名**的目标会概率性抛
32
+ * `EPERM/EBUSY/EACCES/EEXIST`(无 flock 语义)⇒ 有限次重试(毫秒级,不改变原子性:
33
+ * 仍是"临时文件 → rename"两步,读者永远看不到半截内容)。
34
+ */
35
+ function renameWithRetry(from, to, { tries = 50, waitMs = 2 } = {}) {
36
+ let lastErr = null;
37
+ for (let i = 0; i < tries; i += 1) {
38
+ try {
39
+ fs.renameSync(from, to);
40
+ return true;
41
+ } catch (e) {
42
+ lastErr = e;
43
+ if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(e && e.code)) throw e;
44
+ const until = Date.now() + waitMs;
45
+ while (Date.now() < until) {
46
+ /* 忙等几毫秒(同步 API,无法 await) */
47
+ }
48
+ }
49
+ }
50
+ throw lastErr;
51
+ }
52
+
53
+ /** 原子写 JSON(临时文件 + 同卷 rename;读者永远不会看到半截 JSON) */
54
+ export function atomicWriteJson(p, obj) {
55
+ fs.mkdirSync(path.dirname(p), { recursive: true });
56
+ const tmp = tmpName(p);
57
+ fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
58
+ renameWithRetry(tmp, p);
59
+ return p;
60
+ }
61
+
62
+ /**
63
+ * 读标记。**永不抛错**(半截 JSON / 权限问题 ⇒ 返回 error,由调用方决定 fail-open 还是 fail-closed)。
64
+ * @returns {{present:boolean, marker:object|null, error:string|null}}
65
+ */
66
+ export function readMarker(p) {
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(p, 'utf8');
70
+ } catch (e) {
71
+ if (e && e.code === 'ENOENT') return { present: false, marker: null, error: null };
72
+ return { present: false, marker: null, error: String(e && e.message ? e.message : e) };
73
+ }
74
+ try {
75
+ return { present: true, marker: JSON.parse(raw), error: null };
76
+ } catch (e) {
77
+ return { present: false, marker: null, error: `半截/非法 JSON:${String(e && e.message ? e.message : e)}` };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 置标记(**置标记方 API**;`pitfall` job 末尾一行调用)。
83
+ * @param {string} p 标记文件路径
84
+ * @param {{at?:number, reason?:string, by?:string, counters?:object}} [opts] `at` 必写(= 本次置标记时刻)
85
+ */
86
+ export function markDirty(p, opts = {}) {
87
+ const at = Number(opts.at ?? Date.now());
88
+ atomicWriteJson(p, {
89
+ schemaVersion: MARKER_SCHEMA,
90
+ updatedAt: at,
91
+ at: new Date(at).toISOString(),
92
+ reason: opts.reason ?? null,
93
+ by: opts.by ?? null,
94
+ device: os.hostname(),
95
+ counters: opts.counters ?? null,
96
+ });
97
+ return { ok: true, path: p, updatedAt: at };
98
+ }
99
+
100
+ /**
101
+ * 清标记(**消费方 API**):原子读改写 + stale 判定。
102
+ * · 标记缺失 ⇒ 什么都不做
103
+ * · `updatedAt >= consumeStart` ⇒ **保留**(并发新标记,不是这次要消费的那份)
104
+ * · `updatedAt < consumeStart` ⇒ 摘除(先写 `consumed` 落点,再 `rename` 摘除主文件;同卷 rename 是原子的)
105
+ * 残余竞态窗:`复读` 与 `rename` 之间仍有极小窗口(Windows 无 flock 的文件级替代);
106
+ * 实跑频率差 ≥ 3 个数量级(生产置标记 30 分钟一次 / 消费秒级),实际不构成丢标记。
107
+ * @returns {{cleared:boolean, reason:string, consumedUpdatedAt?:number, marker?:object}}
108
+ */
109
+ export function clearMarkerIfStale(p, consumeStart) {
110
+ const first = readMarker(p);
111
+ if (!first.present) return { cleared: false, reason: first.error ? 'unreadable' : 'no-marker' };
112
+ const at = Number(first.marker?.updatedAt || 0);
113
+ if (!(at < Number(consumeStart))) return { cleared: false, reason: 'fresh-marker-kept', marker: first.marker };
114
+
115
+ const consumed = `${p}.consumed`;
116
+ atomicWriteJson(consumed, {
117
+ schemaVersion: MARKER_SCHEMA,
118
+ clearedAt: Date.now(),
119
+ consumedUpdatedAt: at,
120
+ });
121
+
122
+ const second = readMarker(p);
123
+ if (!second.present) {
124
+ try {
125
+ fs.rmSync(consumed, { force: true });
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ return { cleared: false, reason: 'already-gone' };
130
+ }
131
+ if (!(Number(second.marker?.updatedAt || 0) < Number(consumeStart))) {
132
+ try {
133
+ fs.rmSync(consumed, { force: true });
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ return { cleared: false, reason: 'fresh-marker-kept-on-recheck', marker: second.marker };
138
+ }
139
+ try {
140
+ renameWithRetry(p, consumed);
141
+ } catch (e) {
142
+ try {
143
+ fs.rmSync(consumed, { force: true });
144
+ } catch {
145
+ /* ignore */
146
+ }
147
+ return { cleared: false, reason: `rename-failed:${e && e.code ? e.code : String(e)}` };
148
+ }
149
+ try {
150
+ fs.rmSync(consumed, { force: true });
151
+ } catch {
152
+ /* ignore */
153
+ }
154
+ return { cleared: true, reason: 'stale-marker-consumed', consumedUpdatedAt: at };
155
+ }
156
+
157
+ /**
158
+ * 消费一轮:有标记 ⇒ 跑坑源增量同步 ⇒ 成功后清标记(失败**不清**,下轮重试)。
159
+ * @param {object} p
160
+ * @param {string} p.markerPath
161
+ * @param {object} p.store / p.source / p.embedder / p.modelId / p.dim
162
+ */
163
+ export async function runPitfallHook({
164
+ markerPath,
165
+ store,
166
+ source,
167
+ embedder,
168
+ modelId,
169
+ dim,
170
+ batchSize = 50,
171
+ full = false,
172
+ dryRun = false,
173
+ log = () => {},
174
+ now = () => Date.now(),
175
+ }) {
176
+ const consumeStart = now();
177
+ // 🔴 fail-closed 守卫(2026-10-10 实测踩到过):调用方若把**别的来源**当坑源传进来,
178
+ // 本函数会一边按 pitfall 命名空间写向量、一边扫记忆行 ⇒ 静默把记忆行错标成坑向量。
179
+ // ⇒ 来源名不对就**拒跑**(宁可少跑一轮,绝不污染命名空间)。
180
+ const srcName = source && source.sourceName ? String(source.sourceName) : null;
181
+ if (srcName !== 'pitfall') {
182
+ throw new Error(
183
+ `runPitfallHook 只接受坑源(sourceName='pitfall'),收到 ${srcName ?? '(未知来源/无 sourceName)'}`
184
+ + ' ⇒ 拒跑(否则会把该来源的行错标成 pitfall 向量)',
185
+ );
186
+ }
187
+ const r = readMarker(markerPath);
188
+ if (r.error) {
189
+ log(`[tick-hook] 标记文件读不动(本轮跳过、不清除):${r.error}`);
190
+ return { ok: false, skipped: 'marker-unreadable', error: r.error, consumeStart };
191
+ }
192
+ if (!r.present) return { ok: true, skipped: 'no-marker', consumeStart };
193
+
194
+ log(`[tick-hook] 标记在位(updatedAt=${r.marker?.updatedAt ?? '?'},reason=${r.marker?.reason ?? '-'})⇒ 跑坑源增量同步`);
195
+ const stats = await runIncrementalSync({
196
+ source,
197
+ store,
198
+ embedder,
199
+ modelId,
200
+ dim,
201
+ sourceName: 'pitfall',
202
+ batchSize,
203
+ full,
204
+ dryRun,
205
+ log,
206
+ now,
207
+ });
208
+ let cleared = { cleared: false, reason: 'not-attempted' };
209
+ if (dryRun) cleared = { cleared: false, reason: 'dry-run' };
210
+ else if (stats.error) {
211
+ log(`[tick-hook] 同步失败 ⇒ **保留标记**下轮重试:${stats.error}`);
212
+ cleared = { cleared: false, reason: 'sync-failed-keep-marker' };
213
+ } else {
214
+ cleared = clearMarkerIfStale(markerPath, consumeStart);
215
+ log(`[tick-hook] 清标记:${JSON.stringify(cleared)}`);
216
+ }
217
+ return { ok: !stats.error, consumeStart, marker: r.marker, stats, cleared };
218
+ }
@@ -0,0 +1,292 @@
1
+ // 记忆引擎 v2 · 离线层 —— 独立向量库(方案 §3.1)
2
+ //
3
+ // 为什么独立库(D3 已拍):memory.db 是 DELETE 模式 + busy_timeout=0,
4
+ // 任何第三个写者都可能让主人的 memory_write 失败。向量是可重建的派生物。
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { SCHEMA_VERSION } from './config.mjs';
9
+
10
+ const DDL = `
11
+ CREATE TABLE IF NOT EXISTS vectors (
12
+ source TEXT NOT NULL,
13
+ ref_id TEXT NOT NULL,
14
+ model TEXT NOT NULL,
15
+ dim INTEGER NOT NULL,
16
+ vec BLOB NOT NULL,
17
+ text_hash TEXT NOT NULL,
18
+ updated_at INTEGER NOT NULL,
19
+ PRIMARY KEY (source, ref_id)
20
+ );
21
+ CREATE TABLE IF NOT EXISTS meta (
22
+ k TEXT PRIMARY KEY,
23
+ v TEXT NOT NULL
24
+ );
25
+ CREATE INDEX IF NOT EXISTS vectors_source_model ON vectors (source, model);
26
+ `;
27
+
28
+ /** meta 键名(方案 §3.1 要求的前 6 项;后两项为兜底轮转的必需运行状态) */
29
+ export const META_KEYS = {
30
+ schemaVersion: 'schemaVersion',
31
+ hostId: 'hostId',
32
+ modelId: 'modelId',
33
+ dim: 'dim',
34
+ lastIncrementalSyncAt: 'lastIncrementalSyncAt',
35
+ lastFullHashSweepAt: 'lastFullHashSweepAt',
36
+ hashSweepCursor: 'hashSweepCursor',
37
+ lastSyncStats: 'lastSyncStats',
38
+ };
39
+
40
+ /**
41
+ * 按「来源」命名空间的 meta 键前缀(记忆 v2 §3 A.4 / 2026-10-10 落地)。
42
+ *
43
+ * 为什么必须分命名空间:坑库(`source='pitfall'`)与记忆(`source='memory'`)**共用一个 vectors.db**,
44
+ * 两者的水位/游标/模型键若同名会互相覆盖 ⇒ 表现为「记忆增量漏编码且不报错」(方案 §1.3-③.1 / §A.6 首要风险)。
45
+ * ⇒ 这四个键(+ 两个运行状态键)一律写成 `<key>:<source>`。
46
+ */
47
+ export const SOURCE_SCOPED_META_KEYS = [
48
+ 'modelId',
49
+ 'dim',
50
+ 'lastIncrementalSyncAt',
51
+ 'hashSweepCursor',
52
+ 'lastFullHashSweepAt',
53
+ 'lastSyncStats',
54
+ ];
55
+
56
+ /** `<baseKey>:<source>`(来源命名空间的 meta 键名) */
57
+ export function metaKeyFor(baseKey, source) {
58
+ return `${baseKey}:${source}`;
59
+ }
60
+
61
+ /**
62
+ * 读 meta(按来源):先读命名空间键;读不到且来源是 memory ⇒ **兼容读**无后缀旧键
63
+ * (老库尚未迁移的那一次;语义等价,不改变任何默认行为)。
64
+ */
65
+ export function getMetaFor(db, baseKey, source) {
66
+ const v = getMeta(db, metaKeyFor(baseKey, source));
67
+ if (v != null) return v;
68
+ if (String(source) === 'memory') return getMeta(db, baseKey);
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * 写 meta(按来源)+ **冗余写**(方案 §A.4 的 C4 选项①):
74
+ * memory 侧同时把无后缀旧键写成同值 ⇒ 回滚到旧代码时读到的水位仍然正确,
75
+ * 不会走「首次全量」分支把整库 DELETE 重建(626 条 ≈ 102 s 的一次数据面事件)。
76
+ */
77
+ export function setMetaFor(db, baseKey, source, value) {
78
+ setMeta(db, metaKeyFor(baseKey, source), value);
79
+ if (String(source) === 'memory') setMeta(db, baseKey, value);
80
+ }
81
+
82
+ /**
83
+ * 一次性命名空间迁移(**幂等**):把无后缀旧键的现值播种到 `<key>:memory`。
84
+ * 只在「旧键在 且 新键缺」时写 ⇒ 跑多少次都只迁移一次;旧键**保留不动**(= 冗余写)。
85
+ * @returns {string[]} 本次实际迁移的键名
86
+ */
87
+ export function migrateMetaNamespace(db) {
88
+ const migrated = [];
89
+ for (const k of SOURCE_SCOPED_META_KEYS) {
90
+ const old = getMeta(db, k);
91
+ if (old == null) continue;
92
+ if (getMeta(db, metaKeyFor(k, 'memory')) != null) continue;
93
+ setMeta(db, metaKeyFor(k, 'memory'), old);
94
+ migrated.push(k);
95
+ }
96
+ return migrated;
97
+ }
98
+
99
+ function tableExists(db, name) {
100
+ const r = db
101
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
102
+ .get(name);
103
+ return Boolean(r);
104
+ }
105
+
106
+ export function ensureSchema(db) {
107
+ const created = [];
108
+ if (!tableExists(db, 'vectors')) created.push('vectors');
109
+ if (!tableExists(db, 'meta')) created.push('meta');
110
+ db.exec(DDL);
111
+ return { created, tables: { vectors: tableExists(db, 'vectors'), meta: tableExists(db, 'meta') } };
112
+ }
113
+
114
+ /**
115
+ * 打开(必要时创建)向量库。
116
+ * @param {string} dbPath
117
+ * @param {{create?: boolean}} [opts]
118
+ */
119
+ export function openVectorsStore(dbPath, opts = {}) {
120
+ const create = opts.create !== false;
121
+ if (create) fs.mkdirSync(path.dirname(dbPath), { recursive: true });
122
+ if (create === false && !fs.existsSync(dbPath)) {
123
+ throw new Error(`向量库不存在: ${dbPath}`);
124
+ }
125
+ const db = new DatabaseSync(dbPath);
126
+ db.exec('PRAGMA busy_timeout = 5000;');
127
+ try {
128
+ db.exec('PRAGMA journal_mode = WAL;');
129
+ } catch {
130
+ /* 只读介质上 WAL 可能不可用,退回默认模式(本机不属此情况) */
131
+ }
132
+ const schema = ensureSchema(db);
133
+ // 【2026-10-10 · 补坑库联动A】meta 命名空间一次性迁移(幂等;旧键保留 = 冗余写,可回滚)
134
+ const migrated = migrateMetaNamespace(db);
135
+ const sv = getMeta(db, META_KEYS.schemaVersion);
136
+ if (sv == null) {
137
+ setMeta(db, META_KEYS.schemaVersion, SCHEMA_VERSION);
138
+ } else if (migrated.length && sv !== SCHEMA_VERSION) {
139
+ // 迁移实际发生 ⇒ 升版本号(表结构未变,只是键位扩了命名空间)
140
+ setMeta(db, META_KEYS.schemaVersion, SCHEMA_VERSION);
141
+ }
142
+ return { db, dbPath, schema, migrated };
143
+ }
144
+
145
+ export function getMeta(db, k) {
146
+ const r = db.prepare('SELECT v FROM meta WHERE k = ?').get(k);
147
+ return r ? r.v : null;
148
+ }
149
+
150
+ export function setMeta(db, k, v) {
151
+ db.prepare(
152
+ 'INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v',
153
+ ).run(String(k), String(v));
154
+ }
155
+
156
+ export function getMetaAll(db) {
157
+ const out = {};
158
+ for (const r of db.prepare('SELECT k, v FROM meta ORDER BY k').all()) out[r.k] = r.v;
159
+ return out;
160
+ }
161
+
162
+ export function vecToBlob(vec) {
163
+ const f = vec instanceof Float32Array ? vec : Float32Array.from(vec);
164
+ return Buffer.from(f.buffer, f.byteOffset, f.byteLength);
165
+ }
166
+
167
+ export function blobToVec(blob) {
168
+ // 注意:sqlite 返回的 Buffer 可能是池化分配(byteOffset 不对齐 4 字节)⇒ 必须切片后重建
169
+ const ab = blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength);
170
+ return new Float32Array(ab);
171
+ }
172
+
173
+ /** 批量 upsert(单事务) */
174
+ export function upsertVectors(db, items) {
175
+ if (!items.length) return 0;
176
+ const stmt = db.prepare(`
177
+ INSERT INTO vectors (source, ref_id, model, dim, vec, text_hash, updated_at)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?)
179
+ ON CONFLICT(source, ref_id) DO UPDATE SET
180
+ model = excluded.model,
181
+ dim = excluded.dim,
182
+ vec = excluded.vec,
183
+ text_hash = excluded.text_hash,
184
+ updated_at = excluded.updated_at
185
+ `);
186
+ db.exec('BEGIN');
187
+ try {
188
+ for (const it of items) {
189
+ stmt.run(
190
+ it.source,
191
+ String(it.refId),
192
+ it.model,
193
+ it.dim,
194
+ vecToBlob(it.vec),
195
+ it.textHash,
196
+ it.updatedAt,
197
+ );
198
+ }
199
+ db.exec('COMMIT');
200
+ } catch (e) {
201
+ try {
202
+ db.exec('ROLLBACK');
203
+ } catch {
204
+ /* ignore */
205
+ }
206
+ throw e;
207
+ }
208
+ return items.length;
209
+ }
210
+
211
+ /** 取指定 ref_id 的已有哈希(增量同步的跳过判据) */
212
+ export function getHashes(db, source, refIds) {
213
+ const out = new Map();
214
+ if (!refIds.length) return out;
215
+ const stmt = db.prepare('SELECT text_hash FROM vectors WHERE source = ? AND ref_id = ?');
216
+ for (const id of refIds) {
217
+ const r = stmt.get(source, String(id));
218
+ if (r) out.set(String(id), r.text_hash);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ export function countVectors(db, source) {
224
+ const r = source
225
+ ? db.prepare('SELECT COUNT(*) AS n FROM vectors WHERE source = ?').get(source)
226
+ : db.prepare('SELECT COUNT(*) AS n FROM vectors').get();
227
+ return r.n;
228
+ }
229
+
230
+ export function countByModel(db, source, model) {
231
+ return db
232
+ .prepare('SELECT COUNT(*) AS n FROM vectors WHERE source = ? AND model = ?')
233
+ .get(source, model).n;
234
+ }
235
+
236
+ export function deleteSource(db, source) {
237
+ const r = db.prepare('DELETE FROM vectors WHERE source = ?').run(source);
238
+ return Number(r.changes || 0);
239
+ }
240
+
241
+ /**
242
+ * 载入整个矩阵(召回用;万级候选纯内存暴力检索,方案 §3.3)。
243
+ * @returns {{refIds: string[], refIdsNum: number[], dim: number, data: Float32Array, count: number, textHashes: string[], models: string[]}}
244
+ */
245
+ export function loadMatrix(db, source, model = null) {
246
+ const rows = model
247
+ ? db
248
+ .prepare(
249
+ 'SELECT ref_id, model, dim, vec, text_hash FROM vectors WHERE source = ? AND model = ? ORDER BY CAST(ref_id AS INTEGER)',
250
+ )
251
+ .all(source, model)
252
+ : db
253
+ .prepare(
254
+ 'SELECT ref_id, model, dim, vec, text_hash FROM vectors WHERE source = ? ORDER BY CAST(ref_id AS INTEGER)',
255
+ )
256
+ .all(source);
257
+ if (!rows.length) {
258
+ return {
259
+ refIds: [],
260
+ refIdsNum: [],
261
+ dim: 0,
262
+ data: new Float32Array(0),
263
+ count: 0,
264
+ textHashes: [],
265
+ models: [],
266
+ };
267
+ }
268
+ const dim = rows[0].dim;
269
+ const data = new Float32Array(rows.length * dim);
270
+ rows.forEach((r, i) => {
271
+ if (r.dim !== dim) {
272
+ throw new Error(
273
+ `向量库维度不一致:ref_id=${r.ref_id} dim=${r.dim},首个为 ${dim}(应全量重建)`,
274
+ );
275
+ }
276
+ const v = blobToVec(r.vec);
277
+ if (v.length !== dim) {
278
+ throw new Error(`向量 blob 长度异常:ref_id=${r.ref_id} len=${v.length} 期望 ${dim}`);
279
+ }
280
+ data.set(v, i * dim);
281
+ });
282
+ const models = [...new Set(rows.map((r) => r.model))];
283
+ return {
284
+ refIds: rows.map((r) => String(r.ref_id)),
285
+ refIdsNum: rows.map((r) => Number(r.ref_id)),
286
+ dim,
287
+ data,
288
+ count: rows.length,
289
+ textHashes: rows.map((r) => r.text_hash),
290
+ models,
291
+ };
292
+ }