claude-mem-lite 3.59.1 → 3.60.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.59.1",
13
+ "version": "3.60.1",
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.59.1",
3
+ "version": "3.60.1",
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/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'restore', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
3
- const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'repair', 'release']);
3
+ const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'repair', 'rebuild-binding', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
6
6
 
package/install.mjs CHANGED
@@ -40,7 +40,8 @@ const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
40
40
  import { RESOURCE_METADATA } from './install-metadata.mjs';
41
41
  import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
42
42
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
43
- import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
43
+ import { probeBetterSqlite3Binding, probeBindingInFreshProcess, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
44
+ import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
44
45
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
45
46
  import { acquireLock } from './lib/proc-lock.mjs';
46
47
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
@@ -1395,14 +1396,17 @@ async function doctor() {
1395
1396
  issues++;
1396
1397
  }
1397
1398
 
1398
- // Dependencies
1399
- try {
1400
- const Database = (await import('better-sqlite3')).default;
1401
- const probe = new Database(':memory:');
1402
- probe.close();
1399
+ // Dependencies. Out of process: an in-process open of a STALE .node caches a
1400
+ // dead module handle for the rest of doctor and can SIGSEGV on teardown —
1401
+ // truncating the report of the very run the user started because things are
1402
+ // broken. This is also what makes the native-binding check further down
1403
+ // (which reads the same tree) honest rather than answering from a poisoned
1404
+ // process.
1405
+ const depProbe = probeBindingInFreshProcess(bindingHostDir());
1406
+ if (depProbe.ok) {
1403
1407
  ok('better-sqlite3: verified (import + open OK)');
1404
- } catch (e) {
1405
- fail(`better-sqlite3: import/init failed (${e.message})`);
1408
+ } else {
1409
+ fail(`better-sqlite3: import/init failed (${String(depProbe.error).split('\n')[0]})`);
1406
1410
  issues++;
1407
1411
  }
1408
1412
 
@@ -1447,6 +1451,25 @@ async function doctor() {
1447
1451
  ok('Hook self-heal: no recent silent hook breakage');
1448
1452
  }
1449
1453
 
1454
+ // Native DB binding. Two signals, because they answer different questions:
1455
+ // the marker says "hooks have been failing" (possibly for days, since the hint
1456
+ // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
1457
+ // right now". A Node upgrade breaks every DB-touching path at once, so this is
1458
+ // the single highest-value line in doctor when it fires.
1459
+ const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1460
+ // Reuses the dependency probe above — same tree, same question, and doctor
1461
+ // should not pay for two child spawns to ask it twice.
1462
+ const bindingProbe = depProbe;
1463
+ if (!bindingProbe.ok) {
1464
+ fail(`Native DB binding: unusable (${String(bindingProbe.error).split('\n')[0]}) — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\``);
1465
+ issues++;
1466
+ } else if (breakage) {
1467
+ const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
1468
+ dwarn(`Native DB binding: healthy now, but a fire failed ~${ageH}h ago (${breakage.reason || 'unknown'}) — stale marker clears on the next successful rebuild-binding`);
1469
+ } else {
1470
+ ok(`Native DB binding: loadable on Node ${process.version}`);
1471
+ }
1472
+
1450
1473
  // Plugin/hook lifecycle state
1451
1474
  const settings = readSettings();
1452
1475
  const hasHooks = hasMemHooksConfigured(settings);
@@ -2129,6 +2152,51 @@ function regenerateLockfile() {
2129
2152
 
2130
2153
  // ─── Main ───────────────────────────────────────────────────────────────────
2131
2154
 
2155
+ // An install can own MORE THAN ONE better-sqlite3 tree (dev repo, ~/.claude-mem-lite,
2156
+ // the plugin cache), each with its own .node — and only the one the RUNNING code
2157
+ // resolves matters, i.e. the one next to this file. Rebuilding the wrong tree
2158
+ // reports success while every hook keeps failing. Fall back to INSTALL_DIR when
2159
+ // this file sits in a source-only layout with no deps of its own.
2160
+ function bindingHostDir() {
2161
+ return existsSync(join(PROJECT_DIR, 'node_modules', 'better-sqlite3')) ? PROJECT_DIR : INSTALL_DIR;
2162
+ }
2163
+
2164
+ // Local, network-free repair for an unusable native DB binding — the Node-upgrade
2165
+ // fault (ABI 127 → 137) that `repair` is the wrong size for: repair re-downloads
2166
+ // and signature-verifies a whole GitHub release and fails closed offline, while
2167
+ // this recompiles one module in place. Named in the hook hint, run unattended by
2168
+ // scripts/hook-launcher.mjs at session-start, and usable by hand.
2169
+ //
2170
+ // Takes the same install.lock as the install write phase and launch.mjs's rebuild:
2171
+ // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
2172
+ // and exit 0 (it is doing this very work), never race it.
2173
+ async function rebuildBinding() {
2174
+ const host = bindingHostDir();
2175
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2176
+ if (!release) {
2177
+ // NOT exit 0: skipping is not healing. Callers key their state on the exit
2178
+ // code — a false success would let the launcher drop its cooldown and the
2179
+ // CLI re-exec into the same broken binding.
2180
+ console.error('[install] Another install/repair is in progress — it owns the rebuild; skipping.');
2181
+ process.exitCode = 1;
2182
+ return;
2183
+ }
2184
+ try {
2185
+ const verify = await ensureBetterSqlite3Working(host);
2186
+ if (verify.ok) {
2187
+ ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} (${host})`);
2188
+ // The fault is gone → drop the marker so session-start stops retrying.
2189
+ clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2190
+ } else {
2191
+ fail(`better-sqlite3 binding still unusable: ${verify.error}`);
2192
+ log(`Try manually: cd ${host} && ${NATIVE_BINDING_REBUILD_CMD}`);
2193
+ process.exitCode = 1;
2194
+ }
2195
+ } finally {
2196
+ release();
2197
+ }
2198
+ }
2199
+
2132
2200
  // Cross-process gate around the install write phase. repair() is intentionally
2133
2201
  // NOT locked here: it spawns `install.mjs install` as a child, which takes this
2134
2202
  // lock — locking the parent too would deadlock. A live peer (another session's
@@ -2173,6 +2241,9 @@ export async function main(argv = process.argv.slice(2)) {
2173
2241
  case 'repair':
2174
2242
  await repair();
2175
2243
  break;
2244
+ case 'rebuild-binding':
2245
+ await rebuildBinding();
2246
+ break;
2176
2247
  case 'release':
2177
2248
  syncVersions();
2178
2249
  if (!flags.has('--no-lock')) regenerateLockfile();
@@ -2203,6 +2274,7 @@ Usage:
2203
2274
  node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
2204
2275
  node install.mjs self-update Check for and install updates
2205
2276
  node install.mjs repair Recover a broken install: download latest tarball, re-run install
2277
+ node install.mjs rebuild-binding Recompile better-sqlite3 for the running Node (fixes "NODE_MODULE_VERSION" after a Node upgrade)
2206
2278
  node install.mjs release Sync versions (plugin/marketplace/CLAUDE.md) + regen lockfile via npm@10.9.2 (use --no-lock to skip lock regen)
2207
2279
 
2208
2280
  npx claude-mem-lite Install via npx (one-liner)
@@ -6,10 +6,51 @@
6
6
  // Node v24), and the presence of node_modules/better-sqlite3/ on disk is not
7
7
  // sufficient — the binding can be present-but-stale after a Node upgrade.
8
8
 
9
- import { execSync } from 'node:child_process';
9
+ import { execSync, spawnSync } from 'node:child_process';
10
10
  import { createRequire } from 'node:module';
11
11
  import { join } from 'node:path';
12
12
 
13
+ // npm >= 12 blocks lifecycle scripts by default, so a plain `npm rebuild` exits 0
14
+ // WITHOUT compiling — see the rebuild() comment below. Single home for the one
15
+ // command that actually works, so hints, docs and heal paths cannot drift apart.
16
+ export const NATIVE_BINDING_REBUILD_CMD = 'npm rebuild better-sqlite3 --dangerously-allow-all-scripts';
17
+
18
+ // Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
19
+ export const BINDING_HEAL_GUARD_ENV = 'CLAUDE_MEM_BINDING_HEALED';
20
+
21
+ // The native-binding fault family, in the four shapes it actually reaches callers:
22
+ // • ERR_DLOPEN_FAILED — Node's code for a failed dlopen (ABI mismatch)
23
+ // • NODE_MODULE_VERSION N vs M — the ABI text itself (some throws carry no code)
24
+ // • Could not locate the bindings file — build/Release missing or never compiled
25
+ // • Module did not self-register — the .node was REPLACED under a process that
26
+ // already dlopen'd the old one; only a fresh process recovers (hence the
27
+ // re-exec in healAndReexec, not an in-process retry)
28
+ // Deliberately NARROW: a rebuild cannot fix DB corruption or a missing data dir,
29
+ // and misclassifying those would burn a 30s npm run on every fire.
30
+ const NATIVE_BINDING_PATTERNS = [
31
+ /NODE_MODULE_VERSION/,
32
+ /Could not locate the bindings file/i,
33
+ /did not self-register/i,
34
+ /invalid ELF header/i,
35
+ ];
36
+
37
+ /**
38
+ * True when `err` means "the better-sqlite3 native binding is unusable and a
39
+ * rebuild is the right repair".
40
+ *
41
+ * @param {unknown} err
42
+ * @returns {boolean}
43
+ */
44
+ export function isNativeBindingError(err) {
45
+ if (!err) return false;
46
+ if (err.code === 'ERR_DLOPEN_FAILED') return true;
47
+ // `err ?? ''` covers a thrown STRING: recordHookError accepts any thrown value
48
+ // and already normalizes that shape for its log, so the classifier must not
49
+ // silently read undefined and miss it.
50
+ const msg = String(err.message ?? err ?? '');
51
+ return NATIVE_BINDING_PATTERNS.some((re) => re.test(msg));
52
+ }
53
+
13
54
  /**
14
55
  * Probe better-sqlite3's native binding by importing it from `installDir`'s
15
56
  * node_modules and opening an in-memory DB. Returns {ok, error?}.
@@ -29,21 +70,95 @@ export async function probeBetterSqlite3Binding(installDir) {
29
70
  }
30
71
  }
31
72
 
73
+ /**
74
+ * Probe the binding from a FRESH child process.
75
+ *
76
+ * Why this exists: better-sqlite3 dlopen's its .node lazily and Node caches the
77
+ * module handle process-wide, so a process that has already touched a STALE
78
+ * binary can never load its replacement — the retry dies with "Module did not
79
+ * self-register" (and, under scripts/setup.sh's probe, a segfault on the way
80
+ * out). Verifying a freshly rebuilt binding therefore has to happen somewhere
81
+ * that never saw the old one. Same constraint healAndReexec re-execs for.
82
+ *
83
+ * Synchronous (spawnSync) to match the surrounding execSync rebuild — this runs
84
+ * in installers and hook-adjacent scripts, never on a request path.
85
+ *
86
+ * @param {string} installDir Directory containing node_modules/better-sqlite3
87
+ * @param {{timeoutMs?: number}} [opts]
88
+ * @returns {{ok: true} | {ok: false, error: string}}
89
+ */
90
+ export function probeBindingInFreshProcess(installDir, { timeoutMs = 30_000 } = {}) {
91
+ // The child catches and prints the MESSAGE on its own stdout. Two reasons it
92
+ // is not just an uncaught throw read off stderr: an uncaught exception's first
93
+ // stderr line is the stack HEADER (`node:internal/modules/cjs/loader:1520`),
94
+ // not the diagnostic — and this string is the highest-value line `doctor`
95
+ // prints — and any node warning emitted before the throw would land on stderr
96
+ // first and hijack it. The child's stdout is captured by spawnSync and never
97
+ // inherited, so writing there cannot reach a hook's JSON envelope.
98
+ // installDir is interpolated as a JSON string literal, so a path containing
99
+ // quotes/backslashes cannot break out of the script.
100
+ const script =
101
+ 'try {'
102
+ + 'const { createRequire } = require("node:module");'
103
+ + `const D = createRequire(${JSON.stringify(join(installDir, 'package.json'))})("better-sqlite3");`
104
+ + 'new D(":memory:").close();'
105
+ + '} catch (e) { process.stdout.write(String((e && e.message) || e)); process.exit(1); }';
106
+ const r = spawnSync(process.execPath, ['-e', script], { stdio: 'pipe', timeout: timeoutMs });
107
+ // BEFORE the status check: spawnSync's `timeout` is SIGTERM-then-wait, not a
108
+ // deadline, so a child that survives the signal can still exit 0 while
109
+ // r.error is ETIMEDOUT. Reading status alone would call that healthy.
110
+ if (r.error) return { ok: false, error: r.error.message };
111
+ if (r.status === 0) return { ok: true };
112
+ // Fallbacks cover the paths where the catch never ran: a native crash (the
113
+ // SIGSEGV above) or a failure to spawn at all — both leave stdout empty.
114
+ const printed = String(r.stdout || '').trim();
115
+ const stderrLine = String(r.stderr || '').split('\n').map((l) => l.trim()).find(Boolean);
116
+ return {
117
+ ok: false,
118
+ error: printed
119
+ || stderrLine
120
+ || `binding probe exited ${r.status ?? `on signal ${r.signal}`}`,
121
+ };
122
+ }
123
+
32
124
  /**
33
125
  * Verify better-sqlite3 binding works in `installDir`; if not, run
34
126
  * `npm rebuild better-sqlite3` and re-probe. Returns
35
127
  * { ok: true, action: 'verified' | 'rebuilt' } on success or
36
- * { ok: false, error } if rebuild can't fix it. The `probe` and `rebuild`
37
- * deps are injectable so this can be unit-tested without a real npm
128
+ * { ok: false, error } if rebuild can't fix it. The `probe`, `verify` and
129
+ * `rebuild` deps are injectable so this can be unit-tested without a real npm
38
130
  * subprocess.
39
131
  *
40
132
  * @param {string} installDir Directory containing node_modules/better-sqlite3
41
- * @param {{probe?: () => Promise<{ok: boolean, error?: string}>, rebuild?: () => Promise<void>, exec?: (cmd: string, opts: object) => void}} [deps]
133
+ * @param {{probe?: () => Promise<{ok: boolean, error?: string}>, verify?: () => Promise<{ok: boolean, error?: string}> | {ok: boolean, error?: string}, rebuild?: () => Promise<void>, exec?: (cmd: string, opts: object) => void}} [deps]
42
134
  * @returns {Promise<{ok: true, action: 'verified' | 'rebuilt'} | {ok: false, error: string}>}
43
135
  */
44
136
  export async function ensureBetterSqlite3Working(installDir, deps = {}) {
45
- const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
46
- const exec = deps.exec || execSync;
137
+ // BOTH probes run out of process by default, because a probe must never
138
+ // poison the process that has to act on its answer. Loading a stale .node
139
+ // caches a dead module handle process-wide (and can SIGSEGV on teardown), so
140
+ // an in-process probe→rebuild→re-probe cycle always ends in "Module did not
141
+ // self-register" — reporting a SUCCESSFUL rebuild as a failure. Measured
142
+ // 2026-08-13 on a real ABI 127-under-137 tree: this function returned
143
+ // {ok:false} while the rebuilt .node loaded fine in a fresh process. Every
144
+ // caller keyed state on that lie — setup.sh wrote .deps-broken over a healthy
145
+ // install, install.mjs::rebuildBinding skipped clearNativeBindingBreakage so
146
+ // the launcher re-spawned npm every 6h forever, and `rebuild-binding` exited 1.
147
+ //
148
+ // Isolating only the SECOND probe is not enough: scripts/launch.mjs imports
149
+ // the MCP server into this very process right after a successful rebuild, so a
150
+ // first probe that dlopened the stale binary would hand the server the dead
151
+ // handle. Cost is one ~40ms spawn on a path that already runs npm.
152
+ //
153
+ // `deps.probe` alone still drives BOTH probes: injected-stub tests keep their
154
+ // existing semantics.
155
+ const probe = deps.probe || (() => probeBindingInFreshProcess(installDir));
156
+ const verify = deps.verify || deps.probe || (() => probeBindingInFreshProcess(installDir));
157
+ // Bounded by default: a node-gyp fallback that stalls (no compiler, a hung
158
+ // registry fetch) must not hang the caller forever — the CLI blocks a user at
159
+ // the terminal, and scripts/setup.sh passes an even tighter 20s cap because it
160
+ // runs under a hook timeout. Callers needing a different budget inject `exec`.
161
+ const exec = deps.exec || ((cmd, opts) => execSync(cmd, { timeout: 240_000, ...opts }));
47
162
  const rebuild = deps.rebuild || (async () => {
48
163
  // npm >= 12 blocks install/lifecycle scripts by default (the `allow-scripts`
49
164
  // allowlist ships empty). better-sqlite3's install step
@@ -57,7 +172,7 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
57
172
  // npm has no such gate and treats the unknown flag as an ignored config; if
58
173
  // it instead errors on the flag, fall back to the plain rebuild.
59
174
  try {
60
- exec('npm rebuild better-sqlite3 --dangerously-allow-all-scripts', { cwd: installDir, stdio: 'pipe' });
175
+ exec(NATIVE_BINDING_REBUILD_CMD, { cwd: installDir, stdio: 'pipe' });
61
176
  } catch {
62
177
  exec('npm rebuild better-sqlite3', { cwd: installDir, stdio: 'pipe' });
63
178
  }
@@ -72,8 +187,51 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
72
187
  return { ok: false, error: `rebuild failed: ${e.message}` };
73
188
  }
74
189
 
75
- const second = await probe();
190
+ const second = await verify();
76
191
  if (second.ok) return { ok: true, action: 'rebuilt' };
77
192
 
78
193
  return { ok: false, error: second.error || first.error };
79
194
  }
195
+
196
+ /**
197
+ * Foreground heal for a user-invoked process (the CLI): rebuild the binding,
198
+ * then RE-EXEC this process with its original argv and return the child's exit
199
+ * code. The re-exec is not a convenience — better-sqlite3 dlopen's its .node
200
+ * lazily and caches the handle, so a process that has already hit the stale
201
+ * binary cannot use the fresh one: retrying in-process fails with "Module did
202
+ * not self-register" (observed 2026-08-13 while healing this exact fault).
203
+ *
204
+ * Refuses to act when the guard env is already set, so a heal that does not
205
+ * actually fix the binding cannot spawn an unbounded chain of children.
206
+ *
207
+ * @param {{installDir?: string, argv?: string[], env?: Record<string,string|undefined>, ensure?: () => Promise<{ok: boolean, action?: string, error?: string}>, reexec?: (argv: string[], env: Record<string,string|undefined>) => number, log?: (msg: string) => void}} opts
208
+ * @returns {Promise<{healed: true, exitCode: number} | {healed: false, reason: string, error?: string}>}
209
+ */
210
+ export async function healAndReexec(opts) {
211
+ const {
212
+ installDir,
213
+ argv = process.argv,
214
+ env = process.env,
215
+ log = () => {},
216
+ } = opts;
217
+ const ensure = opts.ensure || (() => ensureBetterSqlite3Working(installDir));
218
+ const reexec = opts.reexec || ((childArgv, childEnv) => {
219
+ const r = spawnSync(childArgv[0], childArgv.slice(1), { stdio: 'inherit', env: childEnv });
220
+ return typeof r.status === 'number' ? r.status : 1;
221
+ });
222
+
223
+ if (env[BINDING_HEAL_GUARD_ENV]) return { healed: false, reason: 'already-attempted' };
224
+
225
+ log(`native DB binding unusable — rebuilding for this Node (${process.version})…`);
226
+ let verify;
227
+ try {
228
+ verify = await ensure();
229
+ } catch (e) {
230
+ return { healed: false, reason: 'rebuild-failed', error: e.message };
231
+ }
232
+ if (!verify.ok) return { healed: false, reason: 'rebuild-failed', error: verify.error };
233
+
234
+ log('binding rebuilt — retrying');
235
+ const exitCode = reexec(argv, { ...env, [BINDING_HEAL_GUARD_ENV]: '1' });
236
+ return { healed: true, exitCode };
237
+ }
@@ -20,6 +20,11 @@
20
20
 
21
21
  import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
22
22
  import { join } from 'path';
23
+ // Both are pure-`node:`-static modules (better-sqlite3 is only createRequire'd
24
+ // lazily inside binding-probe's functions), so this keeps the "usable from the
25
+ // lightweight standalone scripts" property stated above.
26
+ import { isNativeBindingError } from './binding-probe.mjs';
27
+ import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
23
28
 
24
29
  const DAY_MS = 86400000;
25
30
  const RETENTION_MS = 14 * DAY_MS;
@@ -82,6 +87,18 @@ export function recordHookError(scope, err, runtimeDir, ctx) {
82
87
  appendFileSync(join(dir, `${today()}.jsonl`), line, { mode: 0o600 });
83
88
  // Amortized retention sweep: 14-day window kept clean without a cron.
84
89
  pruneOldShards(dir);
90
+
91
+ // Every hook script funnels its failures through here — including the
92
+ // STANDALONE ones (scripts/pre-tool-recall.js, scripts/pre-skill-bridge.js)
93
+ // that never import hook.mjs and so never reach its dispatch catch. That gap
94
+ // is why the 2026-08-13 outage stayed invisible: 78 of that day's 79 entries
95
+ // were `pre-recall:db-open`, i.e. the ONE path whose errors nothing but this
96
+ // log could see. Flagging the native-binding family here — rather than at
97
+ // each call site — is what makes the session-start heal fire no matter which
98
+ // script hits the stale binding first.
99
+ if (isNativeBindingError(err)) {
100
+ recordNativeBindingBreakage(runtimeDir, { reason: String(err?.message ?? err ?? ''), event: String(scope || '') });
101
+ }
85
102
  } catch { /* recorder must never throw */ }
86
103
  }
87
104
 
@@ -16,20 +16,33 @@
16
16
  // run `npm rebuild` itself (2–5s timeout + concurrent-fire races).
17
17
  //
18
18
  // Pure node: imports + injectable now/runtimeDir so it unit-tests without the
19
- // hook dependency graph (no schema.mjs / better-sqlite3 import).
19
+ // hook dependency graph (no schema.mjs / better-sqlite3 import). The one non-node:
20
+ // import — lib/binding-probe.mjs for the shared fault classifier — keeps that
21
+ // property: it only `createRequire`s better-sqlite3 lazily inside its functions,
22
+ // so importing it never dlopen's the very binding this module reports on.
20
23
 
21
24
  import { join } from 'node:path';
22
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
25
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
23
26
  import { fileURLToPath } from 'node:url';
27
+ import { isNativeBindingError } from './binding-probe.mjs';
24
28
 
25
29
  export const NATIVE_BINDING_HINT_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
26
30
  const MARKER_NAME = 'native-binding-hint-last';
27
31
 
28
- // Resolvable invocation of the bundled CLI's repair path. Absolute via
32
+ // Breakage marker read by scripts/hook-launcher.mjs at session-start to trigger
33
+ // the unattended rebuild. The hint alone was NOT enough: it goes to hook stderr,
34
+ // is rate-limited to once per 6h, and in the field (2026-08-13) a Node 22 → 24
35
+ // upgrade left the binding stale for 4 days across 79 failed fires because the
36
+ // only healer was the MCP-server launch path, which those sessions never ran.
37
+ export const NATIVE_BINDING_BROKEN_MARKER = 'native-binding-broken';
38
+
39
+ // Resolvable invocation of the bundled CLI's LOCAL binding repair. Absolute via
29
40
  // import.meta.url (cli.mjs is one dir up from lib/) so it works on a plugin-only
30
- // install, where bare `claude-mem-lite` is not on PATH. cli.mjs routes `repair`
31
- // install.mjs. (review #3)
32
- const CLI_REPAIR = `node ${fileURLToPath(new URL('../cli.mjs', import.meta.url))} repair`;
41
+ // install, where bare `claude-mem-lite` is not on PATH. `rebuild-binding`, not
42
+ // `repair`: repair re-downloads and Ed25519-verifies a whole GitHub release and
43
+ // fails closed offline the wrong (often impossible) tool for recompiling one
44
+ // native module against the running Node. (review #3)
45
+ const CLI_REBUILD_BINDING = `node ${fileURLToPath(new URL('../cli.mjs', import.meta.url))} rebuild-binding`;
33
46
 
34
47
  // Stable-ish identity of a fault so DISTINCT failures get DISTINCT cooldown
35
48
  // windows: the same fault → same key (suppressed within the window), a different
@@ -93,13 +106,56 @@ export function nativeBindingHintDue(runtimeDir, now = Date.now(), cooldownMs =
93
106
  */
94
107
  export function formatHookError(err, event, { now = Date.now(), runtimeDir } = {}) {
95
108
  const ts = new Date(now).toISOString();
96
- if (err && err.code === 'ERR_DLOPEN_FAILED') {
109
+ if (isNativeBindingError(err)) {
110
+ // Record BEFORE the cooldown check: the hint is cosmetic and rate-limited,
111
+ // the marker is the heal trigger. Gating the marker on the hint would mean a
112
+ // silenced hint also silences the repair — exactly the 4-day outage shape.
113
+ if (runtimeDir) recordNativeBindingBreakage(runtimeDir, { reason: err.message, event, now });
97
114
  // Key the cooldown on the fault identity so a DISTINCT native failure within
98
115
  // the window still surfaces (a second ABI mismatch after a partial rebuild, a
99
116
  // corrupt .node) instead of being silenced by a prior, different DLOPEN. (#8/#15)
100
117
  if (runtimeDir && !nativeBindingHintDue(runtimeDir, now, NATIVE_BINDING_HINT_COOLDOWN_MS, errKey(err.message))) return null;
101
118
  return `[claude-mem-lite] [${ts}] [WARN] ${event}: native DB binding can't load ` +
102
- `(likely a Node version change) — auto-heals on next MCP server start, or run: ${CLI_REPAIR}`;
119
+ `(likely a Node version change) — auto-heals at the next session start, or run now: ${CLI_REBUILD_BINDING}`;
103
120
  }
104
121
  return `[claude-mem-lite] [${ts}] [ERROR] ${event}: ${err && err.message}`;
105
122
  }
123
+
124
+ /**
125
+ * Record the "native binding is unusable" state for the launcher's session-start
126
+ * heal. Overwrites: the newest fault is the one worth repairing. Best-effort —
127
+ * a hook must never fail because a marker could not be written.
128
+ *
129
+ * @param {string} runtimeDir
130
+ * @param {{reason?: string, event?: string, now?: number}} [opts]
131
+ */
132
+ export function recordNativeBindingBreakage(runtimeDir, { reason = '', event = '', now = Date.now() } = {}) {
133
+ try {
134
+ mkdirSync(runtimeDir, { recursive: true });
135
+ const marker = join(runtimeDir, NATIVE_BINDING_BROKEN_MARKER);
136
+ const tmp = `${marker}.tmp-${process.pid}`;
137
+ // First line only: the ABI error is multi-line and the marker is read by the
138
+ // launcher (pure node:, no parser beyond JSON.parse) and by `doctor`.
139
+ writeFileSync(tmp, JSON.stringify({ reason: String(reason).split('\n')[0], event, ts: now }));
140
+ renameSync(tmp, marker);
141
+ } catch { /* best-effort */ }
142
+ }
143
+
144
+ /**
145
+ * @param {string} runtimeDir
146
+ * @returns {{reason?: string, event?: string, ts?: number} | null} null when
147
+ * absent, unreadable or torn — a garbage marker must never throw into a hook.
148
+ */
149
+ export function readNativeBindingBreakage(runtimeDir) {
150
+ try {
151
+ const parsed = JSON.parse(readFileSync(join(runtimeDir, NATIVE_BINDING_BROKEN_MARKER), 'utf8'));
152
+ return parsed && typeof parsed === 'object' ? parsed : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /** Idempotent. @param {string} runtimeDir */
159
+ export function clearNativeBindingBreakage(runtimeDir) {
160
+ try { unlinkSync(join(runtimeDir, NATIVE_BINDING_BROKEN_MARKER)); } catch { /* already gone */ }
161
+ }
package/mem-cli.mjs CHANGED
@@ -35,11 +35,14 @@ import { auditMemdir, memdirPath } from './memdir.mjs';
35
35
  import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
36
36
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
37
37
  import { join, sep, dirname } from 'path';
38
+ import { spawnSync } from 'child_process';
38
39
  import { readFileSync, existsSync, readdirSync } from 'fs';
39
40
 
40
41
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
41
42
  // router + remaining-command bodies during the incremental split. Future work:
42
43
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
44
+ import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
45
+ import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
43
46
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
44
47
  import { saveObservation } from './lib/save-observation.mjs';
45
48
  import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
@@ -3204,6 +3207,37 @@ export async function run(argv) {
3204
3207
  // previously threw here with no auto-repair until the next MCP start.
3205
3208
  db = ensureDbWithWalRecovery({ warn: (m) => process.stderr.write(`[mem] ${m}\n`) });
3206
3209
  } catch (e) {
3210
+ // A Node upgrade leaves better_sqlite3.node compiled for the old ABI, and
3211
+ // every DB-touching path fails at once. Pre-v3.60 this printed the raw
3212
+ // multi-line NODE_MODULE_VERSION error with no repair named, and the only
3213
+ // healer was an MCP server start the user might never perform — the shape of
3214
+ // the 4-day outage on 2026-08-13. Heal in place, then RE-EXEC: this process
3215
+ // has already dlopen'd the stale binary, so it cannot use the new one.
3216
+ if (isNativeBindingError(e)) {
3217
+ const healed = await healAndReexec({
3218
+ // Delegate the actual rebuild to `cli.mjs rebuild-binding` so there is
3219
+ // ONE healer: it takes install.lock, resolves which node_modules tree
3220
+ // the running code uses, and clears the hooks' breakage marker.
3221
+ ensure: async () => {
3222
+ // Child stdout is DISCARDED, not inherited: install.mjs logs progress
3223
+ // to stdout, and this CLI's stdout is a data channel (`search --json`
3224
+ // is piped into jq). Progress still reaches the user via stderr.
3225
+ const r = spawnSync(process.execPath, [CLI_PATH, 'rebuild-binding'], {
3226
+ stdio: ['ignore', 'ignore', 'inherit'],
3227
+ timeout: 300_000,
3228
+ });
3229
+ return r.status === 0
3230
+ ? { ok: true, action: 'rebuilt' }
3231
+ : { ok: false, error: `rebuild-binding exited ${r.status ?? 'on signal'}` };
3232
+ },
3233
+ log: (m) => process.stderr.write(`[mem] ${m}\n`),
3234
+ });
3235
+ if (healed.healed) { process.exitCode = healed.exitCode; return; }
3236
+ out(`[mem] Error: native DB binding unusable on Node ${process.version}${healed.error ? ` — ${healed.error}` : ''}`);
3237
+ out(`[mem] Fix: ${CLI_INVOKE} rebuild-binding`);
3238
+ process.exitCode = 1;
3239
+ return;
3240
+ }
3207
3241
  out(`[mem] Error: Cannot open database: ${e.message}`);
3208
3242
  out(`[mem] DB path: ${DB_PATH}`);
3209
3243
  process.exitCode = 1;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.59.1",
3
+ "version": "3.60.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.59.1",
9
+ "version": "3.60.1",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
@@ -1570,22 +1570,6 @@
1570
1570
  }
1571
1571
  }
1572
1572
  },
1573
- "node_modules/ajv/node_modules/fast-uri": {
1574
- "version": "3.1.4",
1575
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
1576
- "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
1577
- "funding": [
1578
- {
1579
- "type": "github",
1580
- "url": "https://github.com/sponsors/fastify"
1581
- },
1582
- {
1583
- "type": "opencollective",
1584
- "url": "https://opencollective.com/fastify"
1585
- }
1586
- ],
1587
- "license": "BSD-3-Clause"
1588
- },
1589
1573
  "node_modules/assertion-error": {
1590
1574
  "version": "2.0.1",
1591
1575
  "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2401,6 +2385,22 @@
2401
2385
  "dev": true,
2402
2386
  "license": "MIT"
2403
2387
  },
2388
+ "node_modules/fast-uri": {
2389
+ "version": "3.1.5",
2390
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
2391
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
2392
+ "funding": [
2393
+ {
2394
+ "type": "github",
2395
+ "url": "https://github.com/sponsors/fastify"
2396
+ },
2397
+ {
2398
+ "type": "opencollective",
2399
+ "url": "https://opencollective.com/fastify"
2400
+ }
2401
+ ],
2402
+ "license": "BSD-3-Clause"
2403
+ },
2404
2404
  "node_modules/fd-package-json": {
2405
2405
  "version": "2.0.0",
2406
2406
  "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
@@ -2687,9 +2687,9 @@
2687
2687
  }
2688
2688
  },
2689
2689
  "node_modules/hono": {
2690
- "version": "4.12.31",
2691
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
2692
- "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
2690
+ "version": "4.13.2",
2691
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
2692
+ "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==",
2693
2693
  "license": "MIT",
2694
2694
  "engines": {
2695
2695
  "node": ">=16.9.0"
@@ -2791,9 +2791,9 @@
2791
2791
  "license": "ISC"
2792
2792
  },
2793
2793
  "node_modules/ip-address": {
2794
- "version": "10.2.0",
2795
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
2796
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
2794
+ "version": "10.5.0",
2795
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
2796
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
2797
2797
  "license": "MIT",
2798
2798
  "engines": {
2799
2799
  "node": ">= 12"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.59.1",
3
+ "version": "3.60.1",
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",
@@ -158,6 +158,7 @@
158
158
  "scripts/pre-agent-inject.js",
159
159
  "scripts/prompt-search-utils.mjs",
160
160
  "scripts/hook-launcher.mjs",
161
+ "scripts/binding-probe-cli.mjs",
161
162
  ".mcp.json",
162
163
  ".claude-plugin/plugin.json",
163
164
  ".claude-plugin/marketplace.json",
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // scripts/binding-probe-cli.mjs — SessionStart native-binding probe + bounded heal.
3
+ //
4
+ // Contract with scripts/setup.sh: exit 0 = binding usable NOW, non-zero = not.
5
+ // Everything human-readable goes to stderr — SessionStart stdout is a JSON
6
+ // envelope Claude Code parses, so this must never write there.
7
+ //
8
+ // Extracted (v3.60.1) from an inline `node --input-type=module -e '…'` string
9
+ // inside setup.sh. Two load-bearing reasons, both observed rather than assumed:
10
+ // • That form CRASHED. The -e process SIGSEGV'd during exit after a
11
+ // verified-good rebuild (Node v24.18, ~50% of runs, no fatal-error report
12
+ // produced), so a SUCCESSFUL heal exited 139 and setup.sh wrote
13
+ // .deps-broken over a healthy install. install.mjs::rebuildBinding doing
14
+ // the same work as a normal module never reproduced it.
15
+ // • It could not contain an apostrophe. The script was interpolated into a
16
+ // single-quoted bash string, so a single `don't` in a comment truncated the
17
+ // shell command — a footgun that fired during development of this fix.
18
+ //
19
+ // Rebuilds are bounded to 20s because setup.sh runs under the SessionStart hook
20
+ // cap (hooks/hooks.json timeout 30): letting the hook SIGKILL a mid-flight
21
+ // node-gyp would leave a partial .node with no flag written. On lock-miss or
22
+ // timeout, mark broken and defer to the MCP launch path (no cap, same lock).
23
+
24
+ import { execSync, spawnSync } from 'node:child_process';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ const ROOT = process.env.PROBE_ROOT || join(dirname(fileURLToPath(import.meta.url)), '..');
29
+
30
+ // Fallback for a half-installed tree where lib/ helpers are absent: a bare
31
+ // probe with no rebuild. A WORKING binding must still be able to clear the
32
+ // broken flag, and a helperless broken tree is repaired by the hook-launcher
33
+ // path instead. Out of process like every other probe here — loading a stale
34
+ // .node caches a dead module handle for the rest of THIS process.
35
+ function bareProbe(root) {
36
+ const script =
37
+ 'try {'
38
+ + 'const { createRequire } = require("node:module");'
39
+ + `const D = createRequire(${JSON.stringify(join(root, 'package.json'))})("better-sqlite3");`
40
+ + 'new D(":memory:").close();'
41
+ + '} catch (e) { process.stdout.write(String((e && e.message) || e)); process.exit(1); }';
42
+ const r = spawnSync(process.execPath, ['-e', script], { stdio: 'pipe', timeout: 8000 });
43
+ if (!r.error && r.status === 0) return true;
44
+ // Say WHY. The inline predecessor printed the cause here; dropping it left the
45
+ // user with setup.sh's generic "binding unusable" and nothing to act on.
46
+ const why = String(r.stdout || '').trim().split('\n')[0]
47
+ || (r.error && r.error.message)
48
+ || `probe exited ${r.status ?? `on signal ${r.signal}`}`;
49
+ process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
50
+ return false;
51
+ }
52
+
53
+ let helpers = null;
54
+ try {
55
+ const [probeMod, lockMod, dirMod] = await Promise.all(
56
+ ['binding-probe.mjs', 'proc-lock.mjs', 'resolve-data-dir.mjs']
57
+ .map((f) => import(pathToFileURL(join(ROOT, 'lib', f)).href)),
58
+ );
59
+ helpers = { ...probeMod, ...lockMod, ...dirMod };
60
+ } catch {
61
+ process.exit(bareProbe(ROOT) ? 0 : 1);
62
+ }
63
+
64
+ // Probe first — read-only, no lock needed. A healthy binding exits here.
65
+ // Bounded like every other step: the whole script runs under the SessionStart
66
+ // hook cap (hooks/hooks.json timeout 30) and an unbounded probe would spend the
67
+ // entire budget before the rebuild it exists to trigger.
68
+ const first = helpers.probeBindingInFreshProcess(ROOT, { timeoutMs: 8000 });
69
+ if (first.ok) process.exit(0);
70
+
71
+ // Broken: rebuild ONLY under the shared install.lock. A second MCP launch or an
72
+ // install.mjs repair rebuilding the same node_modules concurrently can tear the
73
+ // .node mid-compile.
74
+ let lockPath;
75
+ try {
76
+ lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
77
+ } catch (e) {
78
+ // resolveDataDir THROWS on a non-absolute CLAUDE_MEM_DIR. Unhandled, that
79
+ // prints an 8-line rejection stack onto SessionStart stderr; one line is enough.
80
+ process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
81
+ process.exit(1);
82
+ }
83
+ const release = helpers.acquireLock(lockPath);
84
+ if (!release) {
85
+ const firstLine = String(first.error).split('\n')[0];
86
+ process.stderr.write(
87
+ `[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`,
88
+ );
89
+ process.exit(1);
90
+ }
91
+
92
+ let result;
93
+ try {
94
+ result = await helpers.ensureBetterSqlite3Working(ROOT, {
95
+ // Reuse the probe we already paid for. Without this, ensure() spawns its own
96
+ // default first probe — a second identical child on the critical path, under
97
+ // a hook cap, to re-learn what `first` already says.
98
+ probe: () => first,
99
+ exec: (cmd, opts) => execSync(cmd, { ...opts, timeout: 20000 }),
100
+ verify: () => helpers.probeBindingInFreshProcess(ROOT, { timeoutMs: 8000 }),
101
+ });
102
+ } finally {
103
+ // process.exit skips finally blocks — every exit below this point, so the
104
+ // lock is always released.
105
+ release();
106
+ }
107
+
108
+ if (!result.ok) {
109
+ process.stderr.write(`[claude-mem-lite] binding probe: ${result.error}\n`);
110
+ process.exit(1);
111
+ }
112
+ if (result.action === 'rebuilt') {
113
+ process.stderr.write('[claude-mem-lite] rebuilt better-sqlite3 binding for current Node ABI\n');
114
+ }
115
+ process.exit(0);
@@ -25,7 +25,7 @@
25
25
  // install.
26
26
 
27
27
  import { existsSync, mkdirSync, writeFileSync, statSync, unlinkSync, readFileSync } from 'node:fs';
28
- import { spawnSync } from 'node:child_process';
28
+ import { spawn, spawnSync } from 'node:child_process';
29
29
  import { dirname, join, isAbsolute } from 'node:path';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
  import { homedir } from 'node:os';
@@ -47,6 +47,34 @@ const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
47
47
  // the intentional silence (no stack trace per fire) stays detectable. (#4/#8)
48
48
  const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
49
49
 
50
+ // ── Native-binding (ABI) self-heal ──────────────────────────────────────────
51
+ // A stale better_sqlite3.node after a Node upgrade does NOT throw at import time
52
+ // — better-sqlite3 dlopen's it lazily at the first `new Database()`, deep inside
53
+ // the hook script, whose own catch swallows it. So it never reaches the
54
+ // ERR_MODULE_NOT_FOUND path below.
55
+ //
56
+ // scripts/setup.sh has probed + rebuilt the binding at SessionStart since v3.58,
57
+ // but ONLY on plugin-manifest installs: hooks/hooks.json registers setup.sh,
58
+ // while an install.mjs-managed settings.json does NOT — it wires the launcher
59
+ // alone. On that install shape nothing healed. Field result (2026-08-13): 4 days
60
+ // with a dead memory system, 79 failed fires in one day.
61
+ //
62
+ // The hook scripts now drop a marker on every such fire (via
63
+ // lib/hook-telemetry.mjs and lib/native-binding-hint.mjs); this heals from it at
64
+ // SESSION-START only — never on the per-tool hot path, where an npm run would
65
+ // stall the user's edit.
66
+ // Marker dir mirrors the standalone hook scripts (pre-tool-recall /
67
+ // pre-skill-bridge), which honor CLAUDE_MEM_RUNTIME_DIR — they write 78 of every
68
+ // 79 of these markers, so reading a different dir would mean never healing.
69
+ const NB_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
70
+ const NB_BROKEN_MARKER = join(NB_RUNTIME_DIR, 'native-binding-broken');
71
+ const NB_HEAL_MARKER = join(NB_RUNTIME_DIR, 'native-binding-lastheal');
72
+ // Literal, not imported: the pure-`node:` charter above forbids importing lib/
73
+ // here (this file must survive a broken install). Kept in sync with
74
+ // lib/binding-probe.mjs::NATIVE_BINDING_REBUILD_CMD, which is the single home
75
+ // everywhere the charter allows an import.
76
+ const NB_MANUAL_CMD = 'npm rebuild better-sqlite3 --dangerously-allow-all-scripts';
77
+
50
78
  // Resolvable invocation of the bundled CLI's repair path. Absolute via
51
79
  // INSTALL_DIR (import.meta.url) so it works on a plugin-only install, where
52
80
  // bare `claude-mem-lite` is not on PATH and ~/.claude-mem-lite/ holds no source.
@@ -248,6 +276,62 @@ async function attemptHeal(reason) {
248
276
  // falls through to the normal entry import. The dynamic import keeps this
249
277
  // launcher's pure-`node:` static-import charter intact (it must survive a broken
250
278
  // install even if hook-update.mjs is unimportable).
279
+ // → the function this describes is trySyncDataDirFromCache(), below.
280
+
281
+ // Rebuild the native binding when a prior fire recorded it as unusable.
282
+ //
283
+ // DETACHED, never awaited. This hook runs under a 15s Claude Code cap
284
+ // (hooks/hooks.json) while a rebuild can take far longer — prebuild-install has
285
+ // to fetch, and a node-gyp fallback is minutes. Waiting would trade a broken
286
+ // binding for a SIGKILL'd session-start (no memory context at all) plus a
287
+ // half-written .node, the exact hazard scripts/setup.sh's 20s exec cap documents.
288
+ // Detaching costs one fire: the rebuild lands within seconds and the NEXT hook
289
+ // fire — usually the same session's first PreToolUse — is already healthy.
290
+ // stdio is fully ignored: install.mjs logs to STDOUT, and SessionStart stdout is
291
+ // a JSON envelope Claude Code parses, so inheriting it corrupts the fire.
292
+ //
293
+ // Bounded by its own 6h cooldown so an unfixable case (no prebuild for this Node,
294
+ // no compiler, offline) does not re-spawn npm every session. The cooldown is
295
+ // dropped once the binding is confirmed healthy, so a LATER unrelated break heals
296
+ // immediately instead of waiting out a stale window.
297
+ // Best-effort throughout — a heal failure must never stop the hook fire.
298
+ function healNativeBindingIfBroken() {
299
+ try {
300
+ if (!existsSync(NB_BROKEN_MARKER)) {
301
+ // Healthy (or already healed by the child) → reset the cooldown.
302
+ try { unlinkSync(NB_HEAL_MARKER); } catch { /* nothing to reset */ }
303
+ return;
304
+ }
305
+ try {
306
+ if (Date.now() - statSync(NB_HEAL_MARKER).mtimeMs < HEAL_COOLDOWN_MS) return;
307
+ } catch { /* no marker → not on cooldown */ }
308
+ try {
309
+ mkdirSync(NB_RUNTIME_DIR, { recursive: true });
310
+ writeFileSync(NB_HEAL_MARKER, String(Date.now()));
311
+ } catch { /* best-effort */ }
312
+
313
+ const installer = join(INSTALL_DIR, 'install.mjs');
314
+ if (!existsSync(installer)) {
315
+ process.stderr.write(
316
+ `[claude-mem-lite] native DB binding unusable and install.mjs is missing — run: cd "${INSTALL_DIR}" && ${NB_MANUAL_CMD}\n`,
317
+ );
318
+ return;
319
+ }
320
+ // The CHILD clears the breakage marker, and only on a verified-good rebuild
321
+ // (install.mjs::rebuildBinding). Clearing it here would mean a rebuild that
322
+ // silently did nothing — lock contention, a no-op npm — still reads as
323
+ // "healed", dropping the cooldown and re-spawning npm on every session.
324
+ process.stderr.write(
325
+ '[claude-mem-lite] native DB binding unusable (Node version change?) — rebuilding in the background\n',
326
+ );
327
+ const child = spawn(process.execPath, [installer, 'rebuild-binding'], {
328
+ detached: true,
329
+ stdio: 'ignore',
330
+ });
331
+ child.unref();
332
+ } catch { /* best-effort — never block the hook fire */ }
333
+ }
334
+
251
335
  async function trySyncDataDirFromCache() {
252
336
  try {
253
337
  const { syncDataDirFromCache } = await import(
@@ -257,15 +341,27 @@ async function trySyncDataDirFromCache() {
257
341
  } catch { /* best-effort — proceed to the normal entry regardless */ }
258
342
  }
259
343
 
260
- if (rest.includes('session-start')) {
344
+ const IS_SESSION_START = rest.includes('session-start');
345
+
346
+ if (IS_SESSION_START) {
347
+ // Before the entry: this process has not dlopen'd better-sqlite3 yet, so the
348
+ // freshly built .node is picked up by the very fire that follows. (After a
349
+ // failed dlopen, only a NEW process can load the replacement — the module
350
+ // handle is cached and an in-process retry dies with "did not self-register".)
351
+ healNativeBindingIfBroken();
261
352
  await trySyncDataDirFromCache();
262
353
  }
263
354
 
355
+
264
356
  try {
265
357
  await runEntry();
266
358
  // A clean session-start fire confirms the install is healthy → clear any stale
267
359
  // breakage marker. Gated to session-start so the per-tool hot path pays nothing.
268
- if (rest.includes('session-start')) clearBreakage();
360
+ if (IS_SESSION_START) clearBreakage();
361
+ // After the entry too: the fire that DISCOVERS the breakage is the one that
362
+ // records it, so a pre-entry-only check would leave the whole session dead and
363
+ // heal one session late.
364
+ if (IS_SESSION_START) healNativeBindingIfBroken();
269
365
  } catch (e) {
270
366
  if (!isLocalModuleErr(e)) throw e;
271
367
  const reason = describeFailure(e);
@@ -36,7 +36,7 @@ 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, probeBetterSqlite3Binding } = await import('../lib/binding-probe.mjs');
39
+ const { ensureBetterSqlite3Working, probeBindingInFreshProcess } = await import('../lib/binding-probe.mjs');
40
40
  // The rebuild inside ensureBetterSqlite3Working mutates node_modules — the
41
41
  // same write class as install/repair/update, and this was the ONE rebuild
42
42
  // path outside the shared install.lock: a second MCP launch or a concurrent
@@ -56,7 +56,11 @@ try {
56
56
  if (release) {
57
57
  verify = await ensureBetterSqlite3Working(ROOT);
58
58
  } else {
59
- const probe = await probeBetterSqlite3Binding(ROOT);
59
+ // Out of process, like the rebuild-capable branch above: this process goes
60
+ // on to import the MCP server, and a stale .node loaded here would leave a
61
+ // dead module handle cached for it. Also keeps the exit(1) guidance below
62
+ // reachable — an in-process load of a stale binding can SIGSEGV instead.
63
+ const probe = probeBindingInFreshProcess(ROOT);
60
64
  verify = probe.ok
61
65
  ? { ok: true, action: 'verified' }
62
66
  : { ok: false, error: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)` };
package/scripts/setup.sh CHANGED
@@ -136,65 +136,38 @@ fi
136
136
  # stat, a new plugin-cache version dir (fresh node_modules) re-probes, and
137
137
  # a Node upgrade (new ABI) re-probes. While broken, every SessionStart
138
138
  # retries the rebuild until it heals.
139
- # shellcheck disable=SC2016 # node script single-quoted on purpose; ROOT passed via env, not shell expansion
139
+ # Delegates to scripts/binding-probe-cli.mjs a real module file, NOT an inline
140
+ # `node -e` string. That inline form SIGSEGV'd during exit after a verified-good
141
+ # rebuild (Node v24.18, ~50% of runs), so a successful heal returned 139 and this
142
+ # branch recorded .deps-broken over a healthy install; it also could not contain
143
+ # an apostrophe without truncating the shell command. See that file's header.
144
+ # Contract: exit 0 = binding usable now.
140
145
  probe_binding() {
141
- PROBE_ROOT="$ROOT" node --input-type=module -e '
142
- // NOTE: this whole script sits in a single-quoted bash string — no
143
- // apostrophes anywhere in it.
144
- const { pathToFileURL } = await import("node:url");
145
- const { join } = await import("node:path");
146
- const root = process.env.PROBE_ROOT;
147
- const libUrl = (f) => pathToFileURL(join(root, "lib", f)).href;
148
- let helpers = null;
149
- try {
150
- const [probeMod, lockMod, dirMod] = await Promise.all(
151
- ["binding-probe.mjs", "proc-lock.mjs", "resolve-data-dir.mjs"].map((f) => import(libUrl(f))));
152
- helpers = { ...probeMod, ...lockMod, ...dirMod };
153
- } catch {
154
- // Probe helpers missing (half-installed tree) fall back to a bare
155
- // probe with no rebuild: a WORKING binding must still clear the flag,
156
- // and a helperless broken tree is repaired by the hook-launcher path.
157
- const { createRequire } = await import("node:module");
158
- try {
159
- const D = createRequire(join(root, "package.json"))("better-sqlite3");
160
- new D(":memory:").close();
161
- process.exit(0);
162
- } catch (e) {
163
- process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
164
- process.exit(1);
165
- }
166
- }
167
- // Probe first — read-only, no lock needed. Healthy binding exits here.
168
- const first = await helpers.probeBetterSqlite3Binding(root);
169
- if (first.ok) process.exit(0);
170
- // Broken: rebuild ONLY under the shared install.lock (a second MCP launch
171
- // or install.mjs repair rebuilding the same node_modules concurrently can
172
- // tear the .node), and with the exec bounded to 20s — this script runs
173
- // under the SessionStart hook cap (hooks.json timeout 30), and letting the
174
- // hook SIGKILL a mid-flight node-gyp leaves a partial .node with no flag
175
- // written. On lock-miss or timeout: mark broken and defer the heal to the
176
- // MCP launch path (no hook cap, same lock).
177
- const lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), "runtime", "install.lock");
178
- const release = helpers.acquireLock(lockPath);
179
- if (!release) {
180
- const firstLine = String(first.error).split("\n")[0];
181
- process.stderr.write(`[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`);
182
- process.exit(1);
183
- }
184
- let r;
185
- try {
186
- const { execSync } = await import("node:child_process");
187
- r = await helpers.ensureBetterSqlite3Working(root, {
188
- exec: (cmd, opts) => execSync(cmd, { ...opts, timeout: 20000 }),
189
- });
190
- } finally {
191
- // process.exit skips finally blocks — exits live BELOW this so the
192
- // lock is always released.
193
- release();
194
- }
195
- if (!r.ok) { process.stderr.write(`[claude-mem-lite] binding probe: ${r.error}\n`); process.exit(1); }
196
- if (r.action === "rebuilt") process.stderr.write("[claude-mem-lite] rebuilt better-sqlite3 binding for current Node ABI\n");
197
- '
146
+ # Absent on a truncated tree: without this guard node prints a full
147
+ # MODULE_NOT_FOUND stack onto SessionStart stderr on every marker-miss.
148
+ [[ -f "$ROOT/scripts/binding-probe-cli.mjs" ]] || return 1
149
+ # stdout muted: this child runs `npm rebuild`, and SessionStart stdout is a
150
+ # JSON envelope Claude Code parses. Nothing writes there today (verified)
151
+ # this keeps a future non-piped exec from being able to.
152
+ PROBE_ROOT="$ROOT" node "$ROOT/scripts/binding-probe-cli.mjs" >/dev/null
153
+ }
154
+
155
+ # Ground truth about the binding, independent of how the healer above exited.
156
+ # Belt-and-braces: moving the probe out of the inline `node -e` string removed
157
+ # the observed SIGSEGV, but the healer's exit code is a PROXY for the question
158
+ # that actually matters, and a proxy can lie again (a future crash, a partial
159
+ # state, a kill at the hook cap). So ask the real question in a fresh process:
160
+ # can we open a DB right now? A false .deps-broken here is not cosmetic — it
161
+ # renders a "hooks degraded" banner into the user's session over a healthy
162
+ # install. Costs one ~50ms spawn, and only off the marker fast-path.
163
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; ROOT passed via env, not shell expansion
164
+ binding_usable() {
165
+ VERIFY_ROOT="$ROOT" node -e '
166
+ const { createRequire } = require("node:module");
167
+ const { join } = require("node:path");
168
+ const D = createRequire(join(process.env.VERIFY_ROOT, "package.json"))("better-sqlite3");
169
+ new D(":memory:").close();
170
+ ' >/dev/null 2>&1
198
171
  }
199
172
 
200
173
  if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
@@ -202,7 +175,7 @@ if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
202
175
  BINDING_MARKER="$ROOT/node_modules/.mem-binding-ok-$NODE_ABI"
203
176
  if [[ -f "$BINDING_MARKER" ]]; then
204
177
  mark_deps_ok
205
- elif probe_binding; then
178
+ elif probe_binding || binding_usable; then
206
179
  rm -f "$ROOT/node_modules/.mem-binding-ok-"* 2>/dev/null || true
207
180
  touch "$BINDING_MARKER" 2>/dev/null || true
208
181
  mark_deps_ok
package/source-files.mjs CHANGED
@@ -250,6 +250,9 @@ export const HOOK_SCRIPT_FILES = [
250
250
  // as the MCP server. v3.42 audit HIGH-1.
251
251
  // - setup.sh: run on plugin SessionStart via hooks.json. Signed for defense-in-depth
252
252
  // (no tarball→executed-path propagation today, but it ships in files[] and is executed).
253
+ // - binding-probe-cli.mjs: setup.sh spawns it on every SessionStart that misses the ABI
254
+ // marker, and it runs `npm rebuild` — an unsigned copy would be arbitrary code executed
255
+ // at session start with a build step attached. Same class as setup.sh itself.
253
256
  // These ship via package.json files[] directly, not via HOOK_SCRIPT_FILES' copy path, so
254
257
  // listing them here changes ONLY what is signed/verified, not what install materializes.
255
258
  // Module-internal (spread into RELEASE_SIGNED_FILES below); not exported — no external
@@ -258,6 +261,7 @@ const LAUNCHER_SCRIPT_FILES = [
258
261
  'launch.mjs',
259
262
  'launch-preflight.mjs',
260
263
  'setup.sh',
264
+ 'binding-probe-cli.mjs',
261
265
  ];
262
266
 
263
267
  // The complete set of files the release signature MUST cover: every runtime .mjs