moflo 4.9.37 → 4.10.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 (76) hide show
  1. package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
  2. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  3. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  4. package/.claude/helpers/statusline.cjs +69 -33
  5. package/.claude/helpers/subagent-bootstrap.json +1 -1
  6. package/.claude/helpers/subagent-start.cjs +1 -1
  7. package/bin/build-embeddings.mjs +6 -20
  8. package/bin/cli.js +5 -0
  9. package/bin/generate-code-map.mjs +4 -24
  10. package/bin/hooks.mjs +3 -12
  11. package/bin/index-all.mjs +3 -13
  12. package/bin/index-guidance.mjs +36 -85
  13. package/bin/index-patterns.mjs +6 -24
  14. package/bin/index-tests.mjs +4 -23
  15. package/bin/lib/db-repair.mjs +358 -62
  16. package/bin/lib/get-backend.mjs +306 -0
  17. package/bin/lib/incremental-write.mjs +27 -7
  18. package/bin/lib/moflo-paths.mjs +64 -4
  19. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  20. package/bin/migrations/knowledge-purge.mjs +7 -8
  21. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  22. package/bin/migrations/purge-doc-entries.mjs +7 -8
  23. package/bin/migrations/strip-context-preambles.mjs +4 -6
  24. package/bin/run-migrations.mjs +1 -10
  25. package/bin/semantic-search.mjs +7 -18
  26. package/bin/session-start-launcher.mjs +144 -108
  27. package/bin/simplify-classify.cjs +38 -17
  28. package/dist/src/cli/commands/daemon.js +38 -11
  29. package/dist/src/cli/commands/doctor-checks-config.js +60 -0
  30. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  31. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  32. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  33. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  34. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  35. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  36. package/dist/src/cli/commands/doctor-fixes.js +87 -0
  37. package/dist/src/cli/commands/doctor-registry.js +24 -1
  38. package/dist/src/cli/commands/doctor.js +1 -1
  39. package/dist/src/cli/commands/embeddings.js +17 -22
  40. package/dist/src/cli/commands/memory.js +13 -23
  41. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  42. package/dist/src/cli/init/moflo-init.js +40 -0
  43. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  44. package/dist/src/cli/memory/bridge-core.js +256 -30
  45. package/dist/src/cli/memory/bridge-embedder.js +84 -3
  46. package/dist/src/cli/memory/bridge-entries.js +70 -6
  47. package/dist/src/cli/memory/controller-registry.js +7 -2
  48. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  49. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  50. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  51. package/dist/src/cli/memory/daemon-backend.js +400 -0
  52. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  53. package/dist/src/cli/memory/database-provider.js +57 -40
  54. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  55. package/dist/src/cli/memory/index.js +0 -1
  56. package/dist/src/cli/memory/memory-bridge.js +40 -8
  57. package/dist/src/cli/memory/memory-initializer.js +271 -211
  58. package/dist/src/cli/memory/rvf-migration.js +25 -11
  59. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  60. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  61. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  62. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  63. package/dist/src/cli/services/daemon-lock.js +58 -1
  64. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  65. package/dist/src/cli/services/embeddings-migration.js +9 -12
  66. package/dist/src/cli/services/ephemeral-namespace-purge.js +21 -16
  67. package/dist/src/cli/services/learning-service.js +12 -20
  68. package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
  69. package/dist/src/cli/services/project-root.js +69 -9
  70. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  71. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  72. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  73. package/dist/src/cli/shared/events/event-store.js +26 -55
  74. package/dist/src/cli/version.js +1 -1
  75. package/package.json +2 -4
  76. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -1,43 +1,54 @@
1
1
  /**
2
- * Memory-DB integrity check + auto-REINDEX (story #743).
2
+ * Memory-DB integrity check + tiered repair (#743, #1090-followup).
3
3
  *
4
- * The `.moflo/moflo.db` SQLite file routinely accumulates index corruption of
5
- * the form `row N missing from index sqlite_autoindex_memory_entries_1` —
6
- * the row data is intact, only the unique-key index has drifted. The most
7
- * common trigger is sql.js's whole-file dump-on-flush behaviour racing with
8
- * concurrent writes (see `feedback_sqljs_writeback_clobber.md` and #714).
4
+ * The `.moflo/moflo.db` SQLite file picks up corruption in two distinct modes:
5
+ *
6
+ * 1. **Index drift** `row N missing from sqlite_autoindex_memory_entries_1`.
7
+ * Row data is intact; only the unique-key b-tree is wrong. Trigger: sql.js's
8
+ * whole-file dump-on-flush racing with concurrent writes (#714, #743
9
+ * fixed for new installs by Phase 5 / #1084 which removed sql.js entirely).
10
+ * **REINDEX** rebuilds the index from canonical row data.
11
+ *
12
+ * 2. **Table b-tree corruption** — `Tree N page M cell K: Rowid X out of
13
+ * order`, where Tree N is a TABLE root page (not just an index). Row data
14
+ * is partly intact, but page ordering is broken. Triggers we've seen:
15
+ * - sql.js → node:sqlite migration: an old 4.9.x sql.js daemon flushes its
16
+ * full-file dump OVER a WAL frame that the new 4.10 backend has already
17
+ * written, leaving WAL referencing pages that no longer exist in main.
18
+ * - Concurrent multi-process writes when the daemon was disabled (#981).
19
+ * **REINDEX cannot fix this** — the table itself is broken. Recovery path:
20
+ * a) `VACUUM INTO` a fresh file (single-shot rebuild; fails fast if
21
+ * iteration hits an unreadable page),
22
+ * b) row-level salvage — chunked `SELECT rowid > ?` per table, catching
23
+ * per-chunk errors and skipping past corrupt page ranges,
24
+ * c) atomic swap with .corrupt.<TS> backup retained for forensics.
25
+ *
26
+ * 3. **Unrecoverable** — header damage, encrypted-by-malware, etc. We can't
27
+ * fix this; surface a clear failure and let the user decide between manual
28
+ * `flo memory rebuild-index` (destructive) and offline recovery tools.
9
29
  *
10
30
  * Symptoms when uncorrected:
11
31
  * - `index-guidance.mjs` and `index-patterns.mjs` fail mid-write with
12
32
  * `database disk image is malformed`, leaving partial state.
13
33
  * - The ephemeral-namespace purge (#729) fails silently, so hive-mind /
14
34
  * tasklist / epic-state / test-bridge-fix rows accumulate.
15
- * - Vector counts in the statusline stay inflated (observed: 4415 with
16
- * 1025 unpurged ephemeral rows).
35
+ * - Vector counts in the statusline stay inflated.
36
+ * - Healer's deep checks throw with "database disk image is malformed",
37
+ * surfacing as the synthetic 'Check' failure (doctor.ts:214).
17
38
  *
18
- * Fix shape: REINDEX rebuilds indexes from the canonical row data much less
19
- * destructive than a full rebuild and works for the typical drift mode. If
20
- * REINDEX itself fails to restore integrity we leave the file alone and
21
- * report; manual `flo memory rebuild-index` is the fallback.
22
- *
23
- * MUST run BEFORE any long-lived sql.js consumer (MCP server, daemon) opens
24
- * the DB and BEFORE the embeddings migration / soft-delete purge / ephemeral
25
- * purge — those all swallow corruption errors and silently no-op.
39
+ * MUST run BEFORE any long-lived consumer (MCP server, daemon) opens the DB
40
+ * and BEFORE the embeddings migration / soft-delete purge / ephemeral purge
41
+ * those all swallow corruption errors and silently no-op.
26
42
  */
27
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
43
+ import { existsSync, renameSync, unlinkSync } from 'node:fs';
28
44
  import { memoryDbPath } from './moflo-paths.mjs';
29
-
30
- let _initSqlJs = null;
31
-
32
- async function loadSqlJs() {
33
- if (_initSqlJs) return _initSqlJs;
34
- // sql.js is a hard dependency of moflo (see top-level package.json);
35
- // resolving it from the consumer's node_modules works because the launcher
36
- // runs from the consumer cwd.
37
- const mod = await import('sql.js');
38
- _initSqlJs = mod.default || mod;
39
- return _initSqlJs;
40
- }
45
+ import { openBackend } from './get-backend.mjs';
46
+ import './suppress-sqlite-warning.mjs';
47
+ // Resolve node:sqlite once at module load — get-backend.mjs has already
48
+ // loaded it by this point, so the dynamic import is a cache hit. Avoids
49
+ // three independent `await import('node:sqlite')` calls inside the repair
50
+ // functions (style cleanup; was producing no functional difference).
51
+ const { DatabaseSync } = await import('node:sqlite');
41
52
 
42
53
  function isOk(execResult) {
43
54
  const rows = execResult?.[0]?.values ?? [];
@@ -49,52 +60,337 @@ function corruptionCount(execResult) {
49
60
  }
50
61
 
51
62
  /**
52
- * Probe the memory DB for index corruption and run REINDEX in place if
53
- * found. Returns `{ repaired, errors, persistent }`:
54
- * - `repaired: true` and `errors > 0` when REINDEX restored integrity.
55
- * - `repaired: false, errors: 0` when the DB is healthy or absent.
56
- * - `repaired: false, errors > 0, persistent: true` when corruption survives
57
- * REINDEX (caller should surface to the user manual rebuild needed).
63
+ * Open `.moflo/moflo.db` raw via node:sqlite in readonly mode and run
64
+ * `PRAGMA integrity_check`. Bypasses {@link openBackend} because that path
65
+ * sets `journal_mode=WAL`, `busy_timeout`, and `synchronous=NORMAL` on every
66
+ * non-readonly open those PRAGMAs can themselves throw against a corrupt
67
+ * file, and the pre-#1090 code path caught those throws and reported the DB
68
+ * as healthy. Readonly + no PRAGMAs = the probe always reaches the
69
+ * `integrity_check` call regardless of file health.
58
70
  *
59
- * Never throws; any internal failure becomes `{ repaired: false, errors: 0 }`
60
- * so a probe failure cannot block session start.
71
+ * Exported so the TS doctor check (`checkMemoryDbIntegrity` in
72
+ * `src/cli/commands/doctor-checks-config.ts`) can call into the same
73
+ * implementation instead of re-deriving the readonly-no-PRAGMAs probe.
74
+ *
75
+ * @param {string} dbPath
76
+ * @returns {Promise<{ ok: boolean, errors: number, openFailed?: boolean }>}
61
77
  */
62
- export async function repairMemoryDbIfCorrupt(projectRoot) {
63
- const dbPath = memoryDbPath(projectRoot);
64
- if (!existsSync(dbPath)) return { repaired: false, errors: 0 };
65
-
66
- let initSql;
78
+ export async function probeIntegrityRaw(dbPath) {
79
+ let db;
80
+ try {
81
+ db = new DatabaseSync(dbPath, { readOnly: true });
82
+ } catch {
83
+ return { ok: false, errors: 0, openFailed: true };
84
+ }
67
85
  try {
68
- initSql = await loadSqlJs();
86
+ const rows = db.prepare('PRAGMA integrity_check').all();
87
+ if (rows.length === 1 && String(rows[0]?.integrity_check ?? '').toLowerCase() === 'ok') {
88
+ return { ok: true, errors: 0 };
89
+ }
90
+ return { ok: false, errors: rows.length };
69
91
  } catch {
70
- return { repaired: false, errors: 0 };
92
+ return { ok: false, errors: 0, openFailed: true };
93
+ } finally {
94
+ try { db.close(); } catch { /* already-dead handle */ }
71
95
  }
96
+ }
72
97
 
73
- let db = null;
98
+ /**
99
+ * Tier-2 recovery: `VACUUM INTO` a fresh file. Single SQLite call that
100
+ * iterates every row of every table and writes them to a brand-new database
101
+ * with rebuilt indexes. Fails fast if iteration hits an unreadable page —
102
+ * caller falls back to row-level salvage.
103
+ *
104
+ * @param {string} srcPath
105
+ * @param {string} dstPath
106
+ * @returns {Promise<{ ok: boolean, error?: string }>}
107
+ */
108
+ async function tryVacuumInto(srcPath, dstPath) {
109
+ try { if (existsSync(dstPath)) unlinkSync(dstPath); } catch { /* best effort */ }
110
+ let db;
111
+ try {
112
+ // Open writable (not readonly) — VACUUM needs to checkpoint WAL first.
113
+ // Skip our standard WAL pragmas (they can throw on corrupt files); SQLite
114
+ // applies its defaults which are sufficient for VACUUM INTO.
115
+ db = new DatabaseSync(srcPath);
116
+ } catch (err) {
117
+ return { ok: false, error: err?.message ?? 'open failed' };
118
+ }
119
+ try {
120
+ try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* corrupt WAL ok */ }
121
+ db.exec(`VACUUM INTO '${dstPath.replace(/'/g, "''")}'`);
122
+ return { ok: true };
123
+ } catch (err) {
124
+ return { ok: false, error: err?.message ?? 'vacuum failed' };
125
+ } finally {
126
+ try { db.close(); } catch { /* */ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Tier-3 recovery: row-level salvage. Iterate each non-empty table in
132
+ * `rowid > ?` chunks; on any chunk-read failure, skip past that chunk's
133
+ * rowid range and continue. Per-table loss stats returned so the caller can
134
+ * surface what was preserved vs lost.
135
+ *
136
+ * Schema is copied verbatim from `sqlite_master.sql` so triggers/indexes/views
137
+ * are preserved alongside tables. `INSERT OR IGNORE` handles unique-key
138
+ * collisions from any duplicate-rowid corruption mode.
139
+ *
140
+ * @param {string} srcPath
141
+ * @param {string} dstPath
142
+ * @returns {Promise<{
143
+ * ok: boolean,
144
+ * error?: string,
145
+ * lossStats?: Record<string, { read: number, written: number, errors: number }>,
146
+ * }>}
147
+ */
148
+ async function trySalvageRowByRow(srcPath, dstPath) {
149
+ try { if (existsSync(dstPath)) unlinkSync(dstPath); } catch { /* */ }
150
+
151
+ let src;
152
+ try {
153
+ src = new DatabaseSync(srcPath, { readOnly: true });
154
+ } catch (err) {
155
+ return { ok: false, error: err?.message ?? 'src open failed' };
156
+ }
157
+
158
+ // Open dst defensively. If this throws (e.g. permissions, dst path in a
159
+ // dir we can't create, or a concurrent lock on dstPath), keep the
160
+ // "never throws" contract by returning the failure shape — otherwise the
161
+ // open exception would escape past `repairMemoryDbIfCorrupt` and block
162
+ // session start, which is the failure mode this whole module exists to
163
+ // prevent.
164
+ let dst;
74
165
  try {
75
- const SQL = await initSql();
76
- const data = readFileSync(dbPath);
77
- db = new SQL.Database(data);
166
+ dst = new DatabaseSync(dstPath);
167
+ } catch (err) {
168
+ try { src.close(); } catch { /* */ }
169
+ return { ok: false, error: err?.message ?? 'dst open failed' };
170
+ }
78
171
 
79
- const before = db.exec('PRAGMA integrity_check');
80
- if (isOk(before)) {
81
- return { repaired: false, errors: 0 };
172
+ const lossStats = {};
173
+ const CHUNK = 500;
174
+
175
+ try {
176
+ // Copy schema. Order matters: tables first (else indexes/triggers/views
177
+ // reference nonexistent tables), then everything else. sqlite_* objects
178
+ // (sqlite_sequence, sqlite_autoindex_*) are created implicitly by SQLite.
179
+ const schemaRows = src
180
+ .prepare(
181
+ "SELECT type, name, tbl_name, sql FROM sqlite_master " +
182
+ "WHERE sql IS NOT NULL ORDER BY CASE type " +
183
+ "WHEN 'table' THEN 1 WHEN 'index' THEN 2 WHEN 'view' THEN 3 ELSE 4 END",
184
+ )
185
+ .all();
186
+ for (const s of schemaRows) {
187
+ if (String(s.name).startsWith('sqlite_')) continue;
188
+ try { dst.exec(s.sql + ';'); } catch { /* malformed schema row — skip */ }
82
189
  }
83
190
 
84
- const errors = corruptionCount(before);
85
- db.run('REINDEX');
191
+ // Salvage rows table-by-table.
192
+ const tables = src
193
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
194
+ .all();
86
195
 
87
- const after = db.exec('PRAGMA integrity_check');
88
- if (!isOk(after)) {
89
- return { repaired: false, errors, persistent: true };
196
+ for (const t of tables) {
197
+ const name = String(t.name);
198
+ lossStats[name] = { read: 0, written: 0, errors: 0 };
199
+
200
+ const cols = src.prepare(`PRAGMA table_info('${name.replace(/'/g, "''")}')`).all();
201
+ if (cols.length === 0) continue;
202
+ const colList = cols.map((c) => '"' + String(c.name).replace(/"/g, '""') + '"').join(',');
203
+ const placeholders = cols.map(() => '?').join(',');
204
+ const insert = dst.prepare(
205
+ `INSERT OR IGNORE INTO "${name.replace(/"/g, '""')}" (${colList}) VALUES (${placeholders})`,
206
+ );
207
+
208
+ let lastRowid = 0;
209
+ let safetyCap = 0;
210
+ const MAX_ITERATIONS = 100_000;
211
+
212
+ while (safetyCap++ < MAX_ITERATIONS) {
213
+ let rows;
214
+ try {
215
+ rows = src
216
+ .prepare(
217
+ `SELECT rowid as __rid, * FROM "${name.replace(/"/g, '""')}" ` +
218
+ `WHERE rowid > ? ORDER BY rowid LIMIT ${CHUNK}`,
219
+ )
220
+ .all(lastRowid);
221
+ } catch {
222
+ lossStats[name].errors++;
223
+ lastRowid += CHUNK;
224
+ continue;
225
+ }
226
+ if (!rows || rows.length === 0) break;
227
+ lossStats[name].read += rows.length;
228
+ for (const r of rows) {
229
+ try {
230
+ insert.run(...cols.map((c) => r[c.name]));
231
+ lossStats[name].written++;
232
+ } catch {
233
+ lossStats[name].errors++;
234
+ }
235
+ lastRowid = Number(r.__rid);
236
+ }
237
+ if (rows.length < CHUNK) break;
238
+ }
90
239
  }
91
240
 
92
- const out = Buffer.from(db.export());
93
- writeFileSync(dbPath, out);
94
- return { repaired: true, errors };
95
- } catch {
96
- return { repaired: false, errors: 0 };
241
+ // Verify the recovered file. If integrity_check still fails, the
242
+ // salvage didn't actually produce a clean file — surface as failure
243
+ // (caller will keep the corrupted original in place).
244
+ const checkRows = dst.prepare('PRAGMA integrity_check').all();
245
+ const recoveredOk =
246
+ checkRows.length === 1 &&
247
+ String(checkRows[0]?.integrity_check ?? '').toLowerCase() === 'ok';
248
+ if (!recoveredOk) {
249
+ return { ok: false, error: 'recovered file failed integrity_check', lossStats };
250
+ }
251
+ return { ok: true, lossStats };
252
+ } catch (err) {
253
+ return { ok: false, error: err?.message ?? 'salvage failed' };
97
254
  } finally {
98
- if (db) try { db.close(); } catch { /* non-fatal */ }
255
+ try { src.close(); } catch { /* */ }
256
+ try { dst.close(); } catch { /* */ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Atomically swap a freshly recovered DB into the canonical path, keeping the
262
+ * corrupted original (+ its WAL/SHM sidecars if present) under `.corrupt.<TS>`
263
+ * suffixes for forensics. Caller must guarantee no live writer holds the
264
+ * canonical file open before invoking this — see `stopWritersBeforeRepair`
265
+ * for the daemon-coordinated entry point.
266
+ *
267
+ * @param {string} canonicalPath
268
+ * @param {string} recoveredPath
269
+ * @returns {{ ok: boolean, error?: string, corruptSuffix: string }}
270
+ */
271
+ function atomicSwap(canonicalPath, recoveredPath) {
272
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
273
+ const corruptSuffix = `.corrupt.${ts}`;
274
+ try {
275
+ if (existsSync(canonicalPath)) {
276
+ renameSync(canonicalPath, canonicalPath + corruptSuffix);
277
+ }
278
+ const walPath = canonicalPath + '-wal';
279
+ const shmPath = canonicalPath + '-shm';
280
+ if (existsSync(walPath)) {
281
+ try { renameSync(walPath, walPath + corruptSuffix); } catch { /* not always present */ }
282
+ }
283
+ if (existsSync(shmPath)) {
284
+ try { renameSync(shmPath, shmPath + corruptSuffix); } catch { /* not always present */ }
285
+ }
286
+ renameSync(recoveredPath, canonicalPath);
287
+ return { ok: true, corruptSuffix };
288
+ } catch (err) {
289
+ return { ok: false, error: err?.message ?? 'swap failed', corruptSuffix };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Probe the memory DB for corruption and run a tiered repair if found:
295
+ *
296
+ * - Tier 1: `REINDEX` in place (index-only corruption — #743).
297
+ * - Tier 2: `VACUUM INTO` fresh file + atomic swap (table b-tree corruption).
298
+ * - Tier 3: row-level salvage + atomic swap (deep corruption with partial
299
+ * row loss).
300
+ *
301
+ * Returns a structured result:
302
+ * - `{ repaired: false, errors: 0 }` — healthy or absent.
303
+ * - `{ repaired: true, errors: N, tier: 'reindex' }` — Tier 1 worked.
304
+ * - `{ repaired: true, errors: N, tier: 'vacuum', corruptBackup }` — Tier 2.
305
+ * - `{ repaired: true, errors: N, tier: 'salvage', corruptBackup, lossStats }`
306
+ * — Tier 3 (partial row loss possible; see `lossStats`).
307
+ * - `{ repaired: false, errors: N, persistent: true }` — nothing worked;
308
+ * manual recovery needed.
309
+ *
310
+ * Never throws; any internal failure becomes `{ repaired: false, errors: 0 }`
311
+ * so a probe failure cannot block session start.
312
+ *
313
+ * @param {string} projectRoot
314
+ * @returns {Promise<{
315
+ * repaired: boolean,
316
+ * errors: number,
317
+ * tier?: 'reindex' | 'vacuum' | 'salvage',
318
+ * persistent?: boolean,
319
+ * corruptBackup?: string,
320
+ * lossStats?: Record<string, { read: number, written: number, errors: number }>,
321
+ * }>}
322
+ */
323
+ export async function repairMemoryDbIfCorrupt(projectRoot) {
324
+ const dbPath = memoryDbPath(projectRoot);
325
+ if (!existsSync(dbPath)) return { repaired: false, errors: 0 };
326
+
327
+ // Step 1 — defensive readonly probe (cannot throw on WAL-setup errors
328
+ // against corrupt files). If the open itself fails, fall through to the
329
+ // openBackend path which has retry semantics for transient lock issues;
330
+ // truly unopenable files surface as persistent below.
331
+ const probe = await probeIntegrityRaw(dbPath);
332
+ if (probe.ok) return { repaired: false, errors: 0 };
333
+
334
+ const errors = probe.errors;
335
+
336
+ // Step 2 — Tier 1: REINDEX via the existing backend path. Fast for the
337
+ // common index-drift mode and preserves the file in place.
338
+ if (!probe.openFailed) {
339
+ try {
340
+ const db = await openBackend(projectRoot, { create: false });
341
+ try {
342
+ db.run('REINDEX');
343
+ const after = db.exec('PRAGMA integrity_check');
344
+ if (isOk(after)) {
345
+ db.save();
346
+ return { repaired: true, errors, tier: 'reindex' };
347
+ }
348
+ } finally {
349
+ try { db.close(); } catch { /* */ }
350
+ }
351
+ } catch {
352
+ // REINDEX path failed (often because openBackend's WAL pragmas throw
353
+ // on a corrupt file). Fall through to deeper recovery.
354
+ }
355
+ }
356
+
357
+ // Step 3 — Tier 2: VACUUM INTO a fresh file.
358
+ const recoveredPath = dbPath + '.recovered';
359
+ const vacuum = await tryVacuumInto(dbPath, recoveredPath);
360
+ if (vacuum.ok) {
361
+ const recoveredProbe = await probeIntegrityRaw(recoveredPath);
362
+ if (recoveredProbe.ok) {
363
+ const swap = atomicSwap(dbPath, recoveredPath);
364
+ if (swap.ok) {
365
+ return {
366
+ repaired: true,
367
+ errors: errors || corruptionCount(recoveredProbe),
368
+ tier: 'vacuum',
369
+ corruptBackup: dbPath + swap.corruptSuffix,
370
+ };
371
+ }
372
+ }
373
+ try { unlinkSync(recoveredPath); } catch { /* */ }
99
374
  }
375
+
376
+ // Step 4 — Tier 3: row-level salvage.
377
+ const salvage = await trySalvageRowByRow(dbPath, recoveredPath);
378
+ if (salvage.ok) {
379
+ const swap = atomicSwap(dbPath, recoveredPath);
380
+ if (swap.ok) {
381
+ return {
382
+ repaired: true,
383
+ errors,
384
+ tier: 'salvage',
385
+ corruptBackup: dbPath + swap.corruptSuffix,
386
+ lossStats: salvage.lossStats,
387
+ };
388
+ }
389
+ try { unlinkSync(recoveredPath); } catch { /* */ }
390
+ } else {
391
+ try { if (existsSync(recoveredPath)) unlinkSync(recoveredPath); } catch { /* */ }
392
+ }
393
+
394
+ // Step 5 — give up.
395
+ return { repaired: false, errors, persistent: true };
100
396
  }