@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,325 @@
1
+ // 记忆引擎 v2 · 离线层 —— 文档索引独立库(方案 §3.2 / D1 已拍:出主库)
2
+ //
3
+ // 为什么独立库(B3/B5 的正解,三条硬理由,勿重开):
4
+ // 1. 主库 `memories` **无 path 列、无外部唯一索引** ⇒ 幂等键无处可放;
5
+ // 且 `forPrompt` 的 LIMIT 名额会被 `updated_at=now` 的索引条整批占掉;
6
+ // 2. `memory.db` 是 DELETE 模式 + `busy_timeout=0` ⇒ 生成器写主库 = 与 `memory_write` 抢锁;
7
+ // 3. 索引条描述**本机 docs 的 mtime**,两机 mtime 不同 ⇒ 跨机双写会互相覆盖。
8
+ // 独立库同时继承 §3.1 的好处:不进任何同步仓、可重建、无锁竞争。
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { DOC_INDEX_SCHEMA_VERSION } from './config.mjs';
13
+
14
+ /** 表结构 —— 逐列照方案 §3.2(验收要贴 PRAGMA table_info 逐列比对,勿自行增删列)。 */
15
+ const DDL = `
16
+ CREATE TABLE IF NOT EXISTS doc_index (
17
+ path TEXT PRIMARY KEY,
18
+ mtime INTEGER NOT NULL,
19
+ size INTEGER NOT NULL,
20
+ text_hash TEXT NOT NULL,
21
+ summary TEXT NOT NULL,
22
+ owner_user TEXT NOT NULL DEFAULT 'LiJianXiao',
23
+ visibility TEXT NOT NULL DEFAULT 'private_owner',
24
+ updated_at INTEGER NOT NULL
25
+ );
26
+ CREATE TABLE IF NOT EXISTS meta (
27
+ k TEXT PRIMARY KEY,
28
+ v TEXT NOT NULL
29
+ );
30
+ `;
31
+
32
+ /** meta 键名(卡要求:schemaVersion / hostId / lastRunAt;后两项为运行状态,照 S1 的 lastSyncStats 先例)。 */
33
+ export const META_KEYS = {
34
+ schemaVersion: 'schemaVersion',
35
+ hostId: 'hostId',
36
+ lastRunAt: 'lastRunAt',
37
+ lastRunStats: 'lastRunStats',
38
+ };
39
+
40
+ /** 列序(验收用:与 PRAGMA table_info 逐列比对) */
41
+ export const COLUMNS = [
42
+ { name: 'path', type: 'TEXT', notnull: 0, dflt: null, pk: 1 },
43
+ { name: 'mtime', type: 'INTEGER', notnull: 1, dflt: null, pk: 0 },
44
+ { name: 'size', type: 'INTEGER', notnull: 1, dflt: null, pk: 0 },
45
+ { name: 'text_hash', type: 'TEXT', notnull: 1, dflt: null, pk: 0 },
46
+ { name: 'summary', type: 'TEXT', notnull: 1, dflt: null, pk: 0 },
47
+ { name: 'owner_user', type: 'TEXT', notnull: 1, dflt: "'LiJianXiao'", pk: 0 },
48
+ { name: 'visibility', type: 'TEXT', notnull: 1, dflt: "'private_owner'", pk: 0 },
49
+ { name: 'updated_at', type: 'INTEGER', notnull: 1, dflt: null, pk: 0 },
50
+ ];
51
+
52
+ /** 并发保护参数(方案 §3.2 / §5.2):busy_timeout + 重试 3 次、总退避上限 60 s。 */
53
+ export const RETRY = {
54
+ retries: 3,
55
+ // 单次等锁上限。独立库的写者只有本模块自己(罕见的手工操作才会占锁)⇒ 2s 足够;
56
+ // 取 5s 会让"4 次尝试"的墙钟被等锁拖到 ~87s(2026-09-21 实测),与 §5.2「总退避上限 60s」的口径读起来不像同一件事。
57
+ busyTimeoutMs: 2000,
58
+ // 指数退避:1s / 4s / 16s(累计 21s)
59
+ delaysMs: [1000, 4000, 16000],
60
+ // 总预算(含等锁):重试前判据 —— 已耗时 + 下一次退避 > 预算就直接放弃
61
+ totalBudgetMs: 60000,
62
+ };
63
+
64
+ function tableExists(db, name) {
65
+ return Boolean(
66
+ db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name),
67
+ );
68
+ }
69
+
70
+ export function ensureSchema(db) {
71
+ const created = [];
72
+ if (!tableExists(db, 'doc_index')) created.push('doc_index');
73
+ if (!tableExists(db, 'meta')) created.push('meta');
74
+ db.exec(DDL);
75
+ return {
76
+ created,
77
+ tables: { doc_index: tableExists(db, 'doc_index'), meta: tableExists(db, 'meta') },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 打开(必要时创建)文档索引库。
83
+ * @param {string} dbPath
84
+ * @param {{create?: boolean, hostId?: string}} [opts]
85
+ */
86
+ export function openDocIndexStore(dbPath, opts = {}) {
87
+ const create = opts.create !== false;
88
+ if (create) fs.mkdirSync(path.dirname(dbPath), { recursive: true });
89
+ if (create === false && !fs.existsSync(dbPath)) {
90
+ throw new Error(`文档索引库不存在: ${dbPath}`);
91
+ }
92
+ const db = new DatabaseSync(dbPath);
93
+ db.exec(`PRAGMA busy_timeout = ${Number(opts.busyTimeoutMs) || RETRY.busyTimeoutMs};`);
94
+ try {
95
+ db.exec('PRAGMA journal_mode = WAL;');
96
+ } catch {
97
+ /* 只读介质上 WAL 可能不可用;本机不属此情况 */
98
+ }
99
+ const schema = ensureSchema(db);
100
+ if (getMeta(db, META_KEYS.schemaVersion) == null) {
101
+ setMeta(db, META_KEYS.schemaVersion, DOC_INDEX_SCHEMA_VERSION);
102
+ }
103
+ if (opts.hostId && getMeta(db, META_KEYS.hostId) == null) {
104
+ setMeta(db, META_KEYS.hostId, String(opts.hostId));
105
+ }
106
+ return { db, dbPath, schema };
107
+ }
108
+
109
+ export function getMeta(db, k) {
110
+ const r = db.prepare('SELECT v FROM meta WHERE k = ?').get(k);
111
+ return r ? r.v : null;
112
+ }
113
+
114
+ export function setMeta(db, k, v) {
115
+ db.prepare('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
116
+ .run(String(k), String(v));
117
+ }
118
+
119
+ export function getMetaAll(db) {
120
+ const out = {};
121
+ for (const r of db.prepare('SELECT k, v FROM meta ORDER BY k').all()) out[r.k] = r.v;
122
+ return out;
123
+ }
124
+
125
+ // ---------------------------------------------------------------- 并发保护
126
+
127
+ /** 是否为"库被锁"类错误(可重试);其它错误一律立即上抛(不掩盖真 bug)。 */
128
+ export function isBusyError(e) {
129
+ const msg = String((e && e.message) || e || '').toLowerCase();
130
+ return (
131
+ msg.includes('database is locked') ||
132
+ msg.includes('database table is locked') ||
133
+ msg.includes('sqlite_busy') ||
134
+ msg.includes('busy')
135
+ );
136
+ }
137
+
138
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
139
+
140
+ /**
141
+ * 带重试地执行写操作(方案 §5.2:重试 3 次 / 退避上限 60 s)。
142
+ * 超限不抛错给调用方判死——返回 {ok:false, error},由上层记 failed(卡要求"不报错给主人")。
143
+ * @template T
144
+ * @param {() => T} fn
145
+ * @param {{retries?:number, delaysMs?:number[], sleep?:(ms:number)=>Promise<void>, onRetry?:(i:number, e:Error)=>void}} [opts]
146
+ * @returns {Promise<{ok:boolean, value?:T, error?:string, attempts:number, busyWaits:number}>}
147
+ */
148
+ export async function withBusyRetry(fn, opts = {}) {
149
+ const retries = opts.retries ?? RETRY.retries;
150
+ const delays = opts.delaysMs ?? RETRY.delaysMs;
151
+ const sleep = opts.sleep ?? defaultSleep;
152
+ const started = Date.now();
153
+ let attempts = 0;
154
+ for (let i = 0; ; i += 1) {
155
+ attempts += 1;
156
+ try {
157
+ return { ok: true, value: fn(), attempts, busyWaits: i };
158
+ } catch (e) {
159
+ if (!isBusyError(e) || i >= retries) {
160
+ return { ok: false, error: String((e && e.message) || e), attempts, busyWaits: i };
161
+ }
162
+ const delay = delays[Math.min(i, delays.length - 1)] ?? 1000;
163
+ if (Date.now() - started + delay > (opts.totalBudgetMs ?? RETRY.totalBudgetMs)) {
164
+ return { ok: false, error: `退避预算耗尽(${opts.totalBudgetMs ?? RETRY.totalBudgetMs}ms):${String((e && e.message) || e)}`, attempts, busyWaits: i + 1 };
165
+ }
166
+ if (opts.onRetry) opts.onRetry(i + 1, e);
167
+ await sleep(delay);
168
+ }
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------- 读写
173
+
174
+ /**
175
+ * 批量 UPSERT(单事务)。幂等键 = `path` 主键:重复执行不产生孤条。
176
+ * @param {import('node:sqlite').DatabaseSync} db
177
+ * @param {Array<{path:string, mtime:number, size:number, textHash:string, summary:string,
178
+ * ownerUser?:string, visibility?:string, updatedAt:number}>} rows
179
+ * @returns {{inserted:number, updated:number, unchanged:number}}
180
+ */
181
+ export function upsertDocs(db, rows) {
182
+ const out = { inserted: 0, updated: 0, unchanged: 0 };
183
+ if (!rows.length) return out;
184
+ const existing = new Map();
185
+ const selStmt = db.prepare('SELECT text_hash FROM doc_index WHERE path = ?');
186
+ for (const r of rows) {
187
+ const cur = selStmt.get(r.path);
188
+ existing.set(r.path, cur ? cur.text_hash : null);
189
+ }
190
+ const stmt = db.prepare(`
191
+ INSERT INTO doc_index (path, mtime, size, text_hash, summary, owner_user, visibility, updated_at)
192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
193
+ ON CONFLICT(path) DO UPDATE SET
194
+ mtime = excluded.mtime,
195
+ size = excluded.size,
196
+ text_hash = excluded.text_hash,
197
+ summary = excluded.summary,
198
+ owner_user = excluded.owner_user,
199
+ visibility = excluded.visibility,
200
+ updated_at = excluded.updated_at
201
+ `);
202
+ const now = Date.now();
203
+ db.exec('BEGIN');
204
+ try {
205
+ for (const r of rows) {
206
+ const prev = existing.get(r.path);
207
+ if (prev === r.textHash) {
208
+ // 摘要内容未变 ⇒ 不重写该条(验收项「text_hash 未变的条目未重建」)
209
+ out.unchanged += 1;
210
+ continue;
211
+ }
212
+ if (prev == null) out.inserted += 1;
213
+ else out.updated += 1;
214
+ stmt.run(
215
+ String(r.path),
216
+ Math.trunc(Number(r.mtime) || 0),
217
+ Math.trunc(Number(r.size) || 0),
218
+ String(r.textHash),
219
+ String(r.summary),
220
+ String(r.ownerUser || 'LiJianXiao'),
221
+ String(r.visibility || 'private_owner'),
222
+ Math.trunc(Number(r.updatedAt) || now),
223
+ );
224
+ }
225
+ db.exec('COMMIT');
226
+ } catch (e) {
227
+ try {
228
+ db.exec('ROLLBACK');
229
+ } catch {
230
+ /* ignore */
231
+ }
232
+ throw e;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** 取指定 path 的已有 text_hash(跳过判据)。 */
238
+ export function getHashesForPaths(db, paths) {
239
+ const out = new Map();
240
+ if (!paths.length) return out;
241
+ const stmt = db.prepare('SELECT text_hash FROM doc_index WHERE path = ?');
242
+ for (const p of paths) {
243
+ const r = stmt.get(String(p));
244
+ if (r) out.set(String(p), r.text_hash);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ export function getRow(db, p) {
250
+ const r = db.prepare('SELECT * FROM doc_index WHERE path = ?').get(String(p));
251
+ return r ?? null;
252
+ }
253
+
254
+ /**
255
+ * 该篇在库里**是否有条**(主条 `path` **或** 任一分条 `path#§N`)。
256
+ * ⚠️ 必须两条都查:长文档按 §3.2 切成 2~5 条时**只有分条、没有主条**
257
+ * (实测踩过:只查主条会让长文档每轮都判 `missing-in-db` 反复重建,"第二轮全部跳过"永远不成立)。
258
+ */
259
+ export function hasDocRows(db, p) {
260
+ const r = db
261
+ .prepare("SELECT 1 AS n FROM doc_index WHERE path = ? OR path LIKE ? ESCAPE '\\' LIMIT 1")
262
+ .get(String(p), `${escapeLike(p)}#§%`);
263
+ return Boolean(r);
264
+ }
265
+
266
+ /** 全量列出(抽检/比对用;按 path 排序保证可复现)。 */
267
+ export function listRows(db, { pathPrefix = null } = {}) {
268
+ if (pathPrefix) {
269
+ return db
270
+ .prepare("SELECT * FROM doc_index WHERE path = ? OR path LIKE ? ESCAPE '\\' ORDER BY path")
271
+ .all(pathPrefix, `${escapeLike(pathPrefix)}#§%`);
272
+ }
273
+ return db.prepare('SELECT * FROM doc_index ORDER BY path').all();
274
+ }
275
+
276
+ export function countRows(db) {
277
+ return db.prepare('SELECT COUNT(*) AS n FROM doc_index').get().n;
278
+ }
279
+
280
+ /** LIKE 元字符转义(path 里可能出现 % _ \,不转义会误匹配)。 */
281
+ export function escapeLike(s) {
282
+ return String(s).replace(/[\\%_]/g, (m) => `\\${m}`);
283
+ }
284
+
285
+ /**
286
+ * 删除某篇文档的全部索引条(含 `path#§N` 长文档分条)——回滚口径:
287
+ * `DELETE FROM doc_index WHERE path=?`(方案 §5.4)+ 分条一并清。
288
+ */
289
+ export function deleteDoc(db, p) {
290
+ const r = db
291
+ .prepare("DELETE FROM doc_index WHERE path = ? OR path LIKE ? ESCAPE '\\'")
292
+ .run(String(p), `${escapeLike(p)}#§%`);
293
+ return Number(r.changes || 0);
294
+ }
295
+
296
+ /** 删除本次未再生成的分条(治"文档变短后残留孤条")。 */
297
+ export function deleteStaleRows(db, p, keepKeys) {
298
+ const keep = new Set(keepKeys.map(String));
299
+ const rows = db
300
+ .prepare("SELECT path FROM doc_index WHERE path = ? OR path LIKE ? ESCAPE '\\'")
301
+ .all(String(p), `${escapeLike(p)}#§%`);
302
+ const stale = rows.map((r) => r.path).filter((v) => !keep.has(v));
303
+ if (!stale.length) return 0;
304
+ const stmt = db.prepare('DELETE FROM doc_index WHERE path = ?');
305
+ db.exec('BEGIN');
306
+ try {
307
+ for (const s of stale) stmt.run(s);
308
+ db.exec('COMMIT');
309
+ } catch (e) {
310
+ try {
311
+ db.exec('ROLLBACK');
312
+ } catch {
313
+ /* ignore */
314
+ }
315
+ throw e;
316
+ }
317
+ return stale.length;
318
+ }
319
+
320
+ /** 库文件级快照(改动前备份;方案 §7「回滚点」)。 */
321
+ export function snapshotDb(db, destFile) {
322
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
323
+ db.prepare('VACUUM INTO ?').run(destFile);
324
+ return destFile;
325
+ }