claude-mem-lite 3.57.0 → 3.58.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.57.0",
13
+ "version": "3.58.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.57.0",
3
+ "version": "3.58.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/adopt-cli.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './memdir.mjs';
23
23
  import {
24
24
  writeManaged, removeManaged, isAdopted as claudeMdIsAdopted,
25
+ hasResidue as claudeMdHasResidue,
25
26
  needsRefresh, migrateLegacyMemoryDir, hasLegacyMemdirSentinel,
26
27
  claudeMdPath, detailDocPath,
27
28
  } from './claudemd.mjs';
@@ -311,8 +312,8 @@ function statusAll() {
311
312
 
312
313
  const known = listKnownProjectDirs();
313
314
  let adoptedCount = 0;
314
- for (const dir of known) if (claudeMdIsAdopted(dir, PLUGIN_SLUG)) adoptedCount++;
315
- log(`[adopt --status] known projects (~/.claude.json): ${known.length} scanned, ${adoptedCount} with a CLAUDE.md managed block.`);
315
+ for (const dir of known) if (claudeMdHasResidue(dir, PLUGIN_SLUG)) adoptedCount++;
316
+ log(`[adopt --status] known projects (~/.claude.json): ${known.length} scanned, ${adoptedCount} with a CLAUDE.md managed block or partial residue (detail doc/state).`);
316
317
  if (adoptedCount > 0) log('[adopt --status] run `claude-mem-lite unadopt --all` to remove every CLAUDE.md block.');
317
318
 
318
319
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? 'set' : 'unset';
@@ -347,16 +348,20 @@ function unadoptAll(args) {
347
348
 
348
349
  // 1. New scheme: scrub CLAUDE.md managed blocks across known project paths.
349
350
  const projectDirs = listKnownProjectDirs();
350
- let blocks = 0;
351
+ let blocks = 0, partial = 0;
351
352
  for (const dir of projectDirs) {
352
- if (!claudeMdIsAdopted(dir, PLUGIN_SLUG)) continue;
353
+ // hasResidue, not isAdopted: the sweep must also catch PARTIAL residue
354
+ // (block without detail doc, or an orphaned doc/state sidecar) —
355
+ // isAdopted's block-AND-doc gate skipped those projects forever.
356
+ if (!claudeMdHasResidue(dir, PLUGIN_SLUG)) continue;
353
357
  if (dryRun) {
354
- log(`[unadopt --all --dry-run] ${dir} → would-remove CLAUDE.md block + detail doc`);
358
+ log(`[unadopt --all --dry-run] ${dir} → would-remove plugin residue (CLAUDE.md block and/or detail doc/state)`);
355
359
  blocks++;
356
360
  continue;
357
361
  }
358
362
  const r = removeManaged(dir, PLUGIN_SLUG);
359
363
  if (r.action === 'removed') { log(`[unadopt --all] ${dir} → removed`); blocks++; }
364
+ else { log(`[unadopt --all] ${dir} → cleaned partial residue (detail doc/state, no block)`); partial++; }
360
365
  }
361
366
 
362
367
  // 2. Legacy memory-dir cleanup across every memdir (foreign-content guarded).
@@ -372,7 +377,8 @@ function unadoptAll(args) {
372
377
  }
373
378
 
374
379
  log('');
375
- log(`[unadopt --all] ${dryRun ? 'would remove' : 'removed'} ${blocks} CLAUDE.md block(s) across ${projectDirs.length} known project(s); ${legacy} legacy memory-dir sentinel(s) ${dryRun ? 'pending' : 'cleaned'}.`);
380
+ const partialNote = partial > 0 ? ` (+${partial} partial-residue cleanup(s))` : '';
381
+ log(`[unadopt --all] ${dryRun ? 'would remove' : 'removed'} ${blocks} CLAUDE.md block(s)${partialNote} across ${projectDirs.length} known project(s); ${legacy} legacy memory-dir sentinel(s) ${dryRun ? 'pending' : 'cleaned'}.`);
376
382
  if (projectDirs.length === 0) {
377
383
  log('[unadopt --all] no known projects found in ~/.claude.json — if a project was adopted but never opened in Claude Code, run `claude-mem-lite unadopt` from inside it.');
378
384
  }
@@ -389,7 +395,7 @@ export function cmdUnadopt(args = []) {
389
395
 
390
396
  const cwd = detectCwd();
391
397
  if (dryRun) {
392
- const blockState = claudeMdIsAdopted(cwd, PLUGIN_SLUG) ? 'would-remove CLAUDE.md block + detail doc' : 'no CLAUDE.md block';
398
+ const blockState = claudeMdHasResidue(cwd, PLUGIN_SLUG) ? 'would-remove CLAUDE.md block + detail doc' : 'no CLAUDE.md block';
393
399
  const legacy = hasLegacyMemdirSentinel(cwd, PLUGIN_SLUG) ? 'would-clean legacy memory-dir sentinel' : 'no legacy residue';
394
400
  log(`[unadopt --dry-run] ${cwd}`);
395
401
  log(` ${blockState}`);
package/claudemd.mjs CHANGED
@@ -98,6 +98,20 @@ export function isAdopted(cwd, slug) {
98
98
  return blk.body !== null && existsSync(detailDocPath(cwd, slug));
99
99
  }
100
100
 
101
+ /**
102
+ * Any trace of adoption that unadopt should clean: managed block OR detail doc
103
+ * OR state sidecar. Deliberately weaker than isAdopted (whose AND lets a
104
+ * half-written adopt self-heal on the next SessionStart): the unadopt sweep
105
+ * gated on isAdopted skipped partial residue forever — e.g. a user deleted the
106
+ * detail doc but the CLAUDE.md block remained, and `unadopt --all` never
107
+ * removed it. removeManaged cleans all three pieces, so sweep on any of them.
108
+ */
109
+ export function hasResidue(cwd, slug) {
110
+ return readBlock(cwd, slug).body !== null
111
+ || existsSync(detailDocPath(cwd, slug))
112
+ || existsSync(stateFilePath(cwd, slug));
113
+ }
114
+
101
115
  /**
102
116
  * Whether the installed block/doc has drifted from the shipped content — i.e.
103
117
  * a version bump or a template edit means we should refresh. Returns true when
package/hook-shared.mjs CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
- import { ensureDb, DB_DIR } from './schema.mjs';
9
+ import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
10
10
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
11
11
  // Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
12
12
  // fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
@@ -154,7 +154,9 @@ export function createSessionId() {
154
154
 
155
155
  export function openDb() {
156
156
  try {
157
- return ensureDb();
157
+ // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
158
+ // silently dead (null DB) on a corrupt WAL until the next MCP server start.
159
+ return ensureDbWithWalRecovery();
158
160
  } catch {
159
161
  return null;
160
162
  }
package/hook-update.mjs CHANGED
@@ -635,6 +635,32 @@ function smokeInstalledRelease(targetDir) {
635
635
  const p = join(targetDir, entry);
636
636
  if (existsSync(p)) execSync(`${q(process.execPath)} --check ${q(p)}`, { timeout: 10000, stdio: 'ignore' });
637
637
  }
638
+ // `cli.mjs help` exits without opening the DB, so it cannot see a
639
+ // present-but-unusable better-sqlite3 binding: npm >= 12 blocks
640
+ // install/lifecycle scripts by default, so the staging `npm install`
641
+ // above exits 0 with the native .node never compiled (a Node major bump
642
+ // strands a stale ABI the same way). Direct installs register server.mjs
643
+ // without the launch.mjs probe, so this gate is their only check. Probe
644
+ // in a child process (execSync so the unit-test mock intercepts, and so
645
+ // the running old-version process's require cache can't mask it); on
646
+ // failure rebuild with scripts enabled for just this dep — plain-rebuild
647
+ // fallback for older npm — then re-probe. A still-broken binding throws
648
+ // out of the try, smoke fails, and the caller rolls back to the old
649
+ // (working) install.
650
+ if (existsSync(join(targetDir, 'node_modules', 'better-sqlite3'))) {
651
+ const probeSrc = 'const{createRequire}=require("node:module");const D=createRequire(process.argv[1])("better-sqlite3");new D(":memory:").close();';
652
+ const probeCmd = `${q(process.execPath)} -e ${q(probeSrc)} ${q(join(targetDir, 'package.json'))}`;
653
+ try {
654
+ execSync(probeCmd, { timeout: 20000, stdio: 'ignore' });
655
+ } catch {
656
+ try {
657
+ execSync('npm rebuild better-sqlite3 --dangerously-allow-all-scripts', { cwd: targetDir, timeout: 120000, stdio: 'ignore' });
658
+ } catch {
659
+ execSync('npm rebuild better-sqlite3', { cwd: targetDir, timeout: 120000, stdio: 'ignore' });
660
+ }
661
+ execSync(probeCmd, { timeout: 20000, stdio: 'ignore' });
662
+ }
663
+ }
638
664
  return true;
639
665
  } catch (e) {
640
666
  debugLog('WARN', 'hook-update', `post-install smoke failed (rolling back): ${e.message}`);
package/install.mjs CHANGED
@@ -466,7 +466,7 @@ if (IS_DEV) {
466
466
  ok(`better-sqlite3: ${verify.action}`);
467
467
  } else {
468
468
  fail(`better-sqlite3 binding unusable after rebuild: ${verify.error}`);
469
- log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --build-from-source');
469
+ log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --dangerously-allow-all-scripts');
470
470
  process.exit(1);
471
471
  }
472
472
  }
package/mem-cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // No MCP SDK or heavy deps — only imports schema.mjs and utils.mjs
4
4
 
5
5
  import { homedir } from 'os';
6
- import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
6
+ import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
7
  import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
9
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
@@ -3156,14 +3156,16 @@ export async function run(argv) {
3156
3156
  }
3157
3157
 
3158
3158
  // adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
3159
- // no DB needed. Route them before ensureDb() so an unbootable DB doesn't block.
3159
+ // no DB needed. Route them before the DB open so an unbootable DB doesn't block.
3160
3160
  if (cmd === 'adopt') { cmdAdopt(cmdArgs); return; }
3161
3161
  if (cmd === 'unadopt') { cmdUnadopt(cmdArgs); return; }
3162
3162
  if (cmd === 'memdir-audit') { cmdMemdirAudit(cmdArgs); return; }
3163
3163
 
3164
3164
  let db;
3165
3165
  try {
3166
- db = ensureDb();
3166
+ // Corruption-gated WAL recovery (shared with server/hooks) — a corrupt WAL
3167
+ // previously threw here with no auto-repair until the next MCP start.
3168
+ db = ensureDbWithWalRecovery({ warn: (m) => process.stderr.write(`[mem] ${m}\n`) });
3167
3169
  } catch (e) {
3168
3170
  out(`[mem] Error: Cannot open database: ${e.message}`);
3169
3171
  out(`[mem] DB path: ${DB_PATH}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.57.0",
3
+ "version": "3.58.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -1000,6 +1000,49 @@ export function ensureDb() {
1000
1000
  }
1001
1001
  }
1002
1002
 
1003
+ /**
1004
+ * Whether an open/init error carries a genuine corruption signature. WAL-delete
1005
+ * recovery is ONLY safe for these: on a transient error (SQLITE_BUSY) or the
1006
+ * forward-version guard throw, deleting the WAL would discard committed-but-
1007
+ * uncheckpointed transactions — silent data loss.
1008
+ */
1009
+ export function isDbCorruptionError(err) {
1010
+ return /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i
1011
+ .test(`${err?.code || ''} ${err?.message || ''}`);
1012
+ }
1013
+
1014
+ /**
1015
+ * ensureDb with corruption-gated WAL recovery. Was inlined in server.mjs only,
1016
+ * so hooks (openDb → silent null) and the CLI (raw throw) stayed degraded on a
1017
+ * corrupt WAL until the next MCP server start. One shared implementation now
1018
+ * serves all three openers.
1019
+ *
1020
+ * Non-corruption failure → rethrows the original error, WAL/SHM left intact.
1021
+ * Corruption → deletes -wal/-shm, retries once; a still-failing retry rethrows
1022
+ * with `err.walRecoveryAttempted = true` so callers can word their fatal hint
1023
+ * accurately (recovery already tried vs WAL deliberately preserved).
1024
+ *
1025
+ * @param {{warn?: (msg: string) => void, info?: (msg: string) => void}} [opts]
1026
+ */
1027
+ export function ensureDbWithWalRecovery({ warn, info } = {}) {
1028
+ try {
1029
+ return ensureDb();
1030
+ } catch (firstErr) {
1031
+ if (!isDbCorruptionError(firstErr)) throw firstErr;
1032
+ warn?.(`DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
1033
+ try { rmSync(DB_PATH + '-wal', { force: true }); } catch { /* best-effort */ }
1034
+ try { rmSync(DB_PATH + '-shm', { force: true }); } catch { /* best-effort */ }
1035
+ try {
1036
+ const db = ensureDb();
1037
+ info?.('DB recovered after WAL cleanup');
1038
+ return db;
1039
+ } catch (retryErr) {
1040
+ try { retryErr.walRecoveryAttempted = true; } catch { /* frozen error — fine */ }
1041
+ throw retryErr;
1042
+ }
1043
+ }
1044
+ }
1045
+
1003
1046
  /**
1004
1047
  * Create FTS5 virtual table + sync triggers for a content table.
1005
1048
  * Idempotent: skips if already exists. Exported for test helpers.
@@ -36,11 +36,37 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
36
36
  // intact but the .node binary stale → server FATALs with "Could not locate
37
37
  // the bindings file" on first DB open. Probe + auto-rebuild before launching.
38
38
  try {
39
- const { ensureBetterSqlite3Working } = await import('../lib/binding-probe.mjs');
40
- const verify = await ensureBetterSqlite3Working(ROOT);
39
+ const { ensureBetterSqlite3Working, probeBetterSqlite3Binding } = await import('../lib/binding-probe.mjs');
40
+ // The rebuild inside ensureBetterSqlite3Working mutates node_modules — the
41
+ // same write class as install/repair/update, and this was the ONE rebuild
42
+ // path outside the shared install.lock: a second MCP launch or a concurrent
43
+ // `install.mjs repair` (hook-launcher heal) could clobber the .node
44
+ // mid-compile. Take the lock for the rebuild-capable path; a live peer →
45
+ // wait up to 10s, then degrade to a probe-only pass (healthy binding
46
+ // proceeds; a broken one defers to the peer instead of racing it).
47
+ const { acquireLock } = await import('../lib/proc-lock.mjs');
48
+ const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
49
+ const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
50
+ let release = null;
51
+ for (let i = 0; i < 20 && !(release = acquireLock(lockPath)); i++) {
52
+ await new Promise((r) => setTimeout(r, 500));
53
+ }
54
+ let verify;
55
+ try {
56
+ if (release) {
57
+ verify = await ensureBetterSqlite3Working(ROOT);
58
+ } else {
59
+ const probe = await probeBetterSqlite3Binding(ROOT);
60
+ verify = probe.ok
61
+ ? { ok: true, action: 'verified' }
62
+ : { ok: false, error: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)` };
63
+ }
64
+ } finally {
65
+ if (release) release();
66
+ }
41
67
  if (!verify.ok) {
42
68
  process.stderr.write(`[claude-mem-lite] better-sqlite3 binding unusable: ${verify.error}\n`);
43
- process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm rebuild better-sqlite3 --build-from-source\n`);
69
+ process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm rebuild better-sqlite3 --dangerously-allow-all-scripts\n`);
44
70
  process.exit(1);
45
71
  }
46
72
  if (verify.action === 'rebuilt') {
package/scripts/setup.sh CHANGED
@@ -80,20 +80,22 @@ mkdir -p "$DATA_DIR/runtime" 2>/dev/null || true
80
80
 
81
81
  mark_deps_broken() {
82
82
  local reason="$1"
83
+ local repair="${2:-npm install --omit=dev}"
83
84
  # Embed reason + repair command so hook.mjs renders a complete error without
84
85
  # having to re-derive them. Delegate JSON serialization to node so embedded
85
86
  # quotes / shell metachars in $ROOT or $reason can't produce an invalid file
86
87
  # (bash `printf '"..%s.."'` cannot escape arbitrary strings safely; v2.79.1 fix).
87
88
  # shellcheck disable=SC2016 # node script single-quoted on purpose; vars passed via env (MARK_*), not shell expansion
88
- MARK_REASON="$reason" MARK_ROOT="$ROOT" MARK_FLAG="$DEPS_FLAG" node -e '
89
+ MARK_REASON="$reason" MARK_ROOT="$ROOT" MARK_FLAG="$DEPS_FLAG" MARK_REPAIR="$repair" node -e '
89
90
  const fs = require("fs");
90
91
  const reason = process.env.MARK_REASON || "unknown";
91
92
  const root = process.env.MARK_ROOT || "";
93
+ const repair = process.env.MARK_REPAIR || "npm install --omit=dev";
92
94
  fs.writeFileSync(process.env.MARK_FLAG, JSON.stringify({
93
95
  ts: new Date().toISOString(),
94
96
  reason,
95
97
  root,
96
- repair: `cd ${JSON.stringify(root)} && npm install --omit=dev`,
98
+ repair: `cd ${JSON.stringify(root)} && ${repair}`,
97
99
  }) + "\n");
98
100
  ' 2>/dev/null || true
99
101
  }
@@ -107,7 +109,6 @@ if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
107
109
  if [[ -d "$DATA_DIR/node_modules/better-sqlite3" ]]; then
108
110
  if ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
109
111
  log_ok "Dependencies linked from $DATA_DIR"
110
- mark_deps_ok
111
112
  fi
112
113
  fi
113
114
  # Slow path: npm install (first-time only, ~10-20s for native addon)
@@ -115,15 +116,96 @@ if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
115
116
  log_info "Installing dependencies (first-time setup)..."
116
117
  if (cd "$ROOT" && npm install --omit=dev --no-audit --no-fund 2>&1) >&2; then
117
118
  log_ok "Dependencies installed"
118
- mark_deps_ok
119
119
  else
120
120
  log_warn "Dependency install failed — hooks may have limited functionality (flag: $DEPS_FLAG)"
121
121
  mark_deps_broken "npm install --omit=dev failed in plugin cache root"
122
122
  fi
123
123
  fi
124
- else
125
- # Deps already present — make sure we don't keep stale broken flag around
126
- mark_deps_ok
124
+ fi
125
+
126
+ # 6b. Binding probe: node_modules/better-sqlite3 PRESENT is not node binding
127
+ # WORKING. npm >= 12 blocks install/lifecycle scripts by default, so the
128
+ # `npm install` above exits 0 with the native .node binding never compiled
129
+ # (and a Node major upgrade strands a stale-ABI binding the same way) —
130
+ # pre-v3.58 this branch called mark_deps_ok on directory presence alone,
131
+ # leaving every hook dead with a false-green flag until the MCP server's
132
+ # own probe ran. Probe via the shared fix point lib/binding-probe.mjs
133
+ # (opens a :memory: DB; auto-rebuilds with --dangerously-allow-all-scripts,
134
+ # plain-rebuild fallback for older npm). The ABI-keyed marker lives inside
135
+ # node_modules/ — WITH the tree it certifies — so healthy sessions cost one
136
+ # stat, a new plugin-cache version dir (fresh node_modules) re-probes, and
137
+ # a Node upgrade (new ABI) re-probes. While broken, every SessionStart
138
+ # retries the rebuild until it heals.
139
+ if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
140
+ NODE_ABI="$(node -p 'process.versions.modules' 2>/dev/null || echo 0)"
141
+ BINDING_MARKER="$ROOT/node_modules/.mem-binding-ok-$NODE_ABI"
142
+ if [[ -f "$BINDING_MARKER" ]]; then
143
+ mark_deps_ok
144
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; ROOT passed via env, not shell expansion
145
+ elif PROBE_ROOT="$ROOT" node --input-type=module -e '
146
+ // NOTE: this whole script sits in a single-quoted bash string — no
147
+ // apostrophes anywhere in it.
148
+ const { pathToFileURL } = await import("node:url");
149
+ const { join } = await import("node:path");
150
+ const root = process.env.PROBE_ROOT;
151
+ const libUrl = (f) => pathToFileURL(join(root, "lib", f)).href;
152
+ let helpers = null;
153
+ try {
154
+ const [probeMod, lockMod, dirMod] = await Promise.all(
155
+ ["binding-probe.mjs", "proc-lock.mjs", "resolve-data-dir.mjs"].map((f) => import(libUrl(f))));
156
+ helpers = { ...probeMod, ...lockMod, ...dirMod };
157
+ } catch {
158
+ // Probe helpers missing (half-installed tree) — fall back to a bare
159
+ // probe with no rebuild: a WORKING binding must still clear the flag,
160
+ // and a helperless broken tree is repaired by the hook-launcher path.
161
+ const { createRequire } = await import("node:module");
162
+ try {
163
+ const D = createRequire(join(root, "package.json"))("better-sqlite3");
164
+ new D(":memory:").close();
165
+ process.exit(0);
166
+ } catch (e) {
167
+ process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
168
+ process.exit(1);
169
+ }
170
+ }
171
+ // Probe first — read-only, no lock needed. Healthy binding exits here.
172
+ const first = await helpers.probeBetterSqlite3Binding(root);
173
+ if (first.ok) process.exit(0);
174
+ // Broken: rebuild ONLY under the shared install.lock (a second MCP launch
175
+ // or install.mjs repair rebuilding the same node_modules concurrently can
176
+ // tear the .node), and with the exec bounded to 20s — this script runs
177
+ // under the SessionStart hook cap (hooks.json timeout 30), and letting the
178
+ // hook SIGKILL a mid-flight node-gyp leaves a partial .node with no flag
179
+ // written. On lock-miss or timeout: mark broken and defer the heal to the
180
+ // MCP launch path (no hook cap, same lock).
181
+ const lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), "runtime", "install.lock");
182
+ const release = helpers.acquireLock(lockPath);
183
+ if (!release) {
184
+ const firstLine = String(first.error).split("\n")[0];
185
+ process.stderr.write(`[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`);
186
+ process.exit(1);
187
+ }
188
+ let r;
189
+ try {
190
+ const { execSync } = await import("node:child_process");
191
+ r = await helpers.ensureBetterSqlite3Working(root, {
192
+ exec: (cmd, opts) => execSync(cmd, { ...opts, timeout: 20000 }),
193
+ });
194
+ } finally {
195
+ // process.exit skips finally blocks — exits live BELOW this so the
196
+ // lock is always released.
197
+ release();
198
+ }
199
+ if (!r.ok) { process.stderr.write(`[claude-mem-lite] binding probe: ${r.error}\n`); process.exit(1); }
200
+ if (r.action === "rebuilt") process.stderr.write("[claude-mem-lite] rebuilt better-sqlite3 binding for current Node ABI\n");
201
+ '; then
202
+ rm -f "$ROOT/node_modules/.mem-binding-ok-"* 2>/dev/null || true
203
+ touch "$BINDING_MARKER" 2>/dev/null || true
204
+ mark_deps_ok
205
+ else
206
+ log_warn "better-sqlite3 native binding unusable — hooks degraded until repaired (flag: $DEPS_FLAG)"
207
+ mark_deps_broken "better-sqlite3 binding probe/rebuild failed (npm >= 12 blocks compile scripts by default)" "npm rebuild better-sqlite3 --dangerously-allow-all-scripts"
208
+ fi
127
209
  fi
128
210
 
129
211
  # 7. MCP cleanup: one-shot purge of stale global MCP registrations.
package/server.mjs CHANGED
@@ -7,7 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
- import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
10
+ import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
11
11
  import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
@@ -66,36 +66,25 @@ const { version: PKG_VERSION } = require('./package.json');
66
66
 
67
67
  // ─── Database ───────────────────────────────────────────────────────────────
68
68
 
69
- import { rmSync, existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, chmodSync } from 'fs';
69
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, chmodSync } from 'fs';
70
70
 
71
71
  let db;
72
72
  try {
73
- db = ensureDb();
74
- } catch (firstErr) {
75
- // WAL-delete recovery is ONLY safe for genuine corruption. On a transient
76
- // error (SQLITE_BUSY) or the forward-version guard throw, deleting the WAL
77
- // would discard committed-but-uncheckpointed transactions — silent data loss.
78
- // Restrict the rm to corruption signatures; otherwise fail fast, WAL intact.
79
- const sig = `${firstErr.code || ''} ${firstErr.message || ''}`;
80
- const isCorruption = /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(sig);
81
- if (!isCorruption) {
82
- console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${firstErr.message}`);
83
- console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
84
- process.exit(1);
85
- }
86
- // Recovery: remove WAL/SHM files (corrupt WAL is the most common cause) and retry
87
- debugLog('WARN', 'server', `DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
88
- try { rmSync(DB_PATH + '-wal', { force: true }); } catch {}
89
- try { rmSync(DB_PATH + '-shm', { force: true }); } catch {}
90
- try {
91
- db = ensureDb();
92
- debugLog('INFO', 'server', 'DB recovered after WAL cleanup');
93
- } catch (retryErr) {
94
- // Fatal: log and exit with descriptive message (Claude Code shows stderr)
95
- console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${retryErr.message}`);
73
+ // Corruption-gated WAL recovery lives in schema.mjs (shared with hooks/CLI
74
+ // since the D#8-adjacent P3 fix); the server keeps only its exit semantics.
75
+ db = ensureDbWithWalRecovery({
76
+ warn: (m) => debugLog('WARN', 'server', m),
77
+ info: (m) => debugLog('INFO', 'server', m),
78
+ });
79
+ } catch (err) {
80
+ // Fatal: log and exit with descriptive message (Claude Code shows stderr)
81
+ console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${err.message}`);
82
+ if (err.walRecoveryAttempted) {
96
83
  console.error(`[claude-mem-lite] Try: rm "${DB_PATH}-wal" "${DB_PATH}-shm" or reinstall with: node install.mjs install`);
97
- process.exit(1);
84
+ } else {
85
+ console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
98
86
  }
87
+ process.exit(1);
99
88
  }
100
89
  // Server process uses longer busy_timeout for concurrent MCP requests
101
90
  db.pragma('busy_timeout = 5000');