claude-mem-lite 3.60.0 → 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.60.0",
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.60.0",
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/install.mjs CHANGED
@@ -40,7 +40,7 @@ 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, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
43
+ import { probeBetterSqlite3Binding, probeBindingInFreshProcess, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
44
44
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
45
45
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
46
46
  import { acquireLock } from './lib/proc-lock.mjs';
@@ -1396,14 +1396,17 @@ async function doctor() {
1396
1396
  issues++;
1397
1397
  }
1398
1398
 
1399
- // Dependencies
1400
- try {
1401
- const Database = (await import('better-sqlite3')).default;
1402
- const probe = new Database(':memory:');
1403
- 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) {
1404
1407
  ok('better-sqlite3: verified (import + open OK)');
1405
- } catch (e) {
1406
- fail(`better-sqlite3: import/init failed (${e.message})`);
1408
+ } else {
1409
+ fail(`better-sqlite3: import/init failed (${String(depProbe.error).split('\n')[0]})`);
1407
1410
  issues++;
1408
1411
  }
1409
1412
 
@@ -1454,7 +1457,9 @@ async function doctor() {
1454
1457
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
1455
1458
  // the single highest-value line in doctor when it fires.
1456
1459
  const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1457
- const bindingProbe = await probeBetterSqlite3Binding(bindingHostDir());
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;
1458
1463
  if (!bindingProbe.ok) {
1459
1464
  fail(`Native DB binding: unusable (${String(bindingProbe.error).split('\n')[0]}) — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\``);
1460
1465
  issues++;
@@ -70,20 +70,90 @@ export async function probeBetterSqlite3Binding(installDir) {
70
70
  }
71
71
  }
72
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
+
73
124
  /**
74
125
  * Verify better-sqlite3 binding works in `installDir`; if not, run
75
126
  * `npm rebuild better-sqlite3` and re-probe. Returns
76
127
  * { ok: true, action: 'verified' | 'rebuilt' } on success or
77
- * { ok: false, error } if rebuild can't fix it. The `probe` and `rebuild`
78
- * 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
79
130
  * subprocess.
80
131
  *
81
132
  * @param {string} installDir Directory containing node_modules/better-sqlite3
82
- * @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]
83
134
  * @returns {Promise<{ok: true, action: 'verified' | 'rebuilt'} | {ok: false, error: string}>}
84
135
  */
85
136
  export async function ensureBetterSqlite3Working(installDir, deps = {}) {
86
- const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
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));
87
157
  // Bounded by default: a node-gyp fallback that stalls (no compiler, a hung
88
158
  // registry fetch) must not hang the caller forever — the CLI blocks a user at
89
159
  // the terminal, and scripts/setup.sh passes an even tighter 20s cap because it
@@ -117,7 +187,7 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
117
187
  return { ok: false, error: `rebuild failed: ${e.message}` };
118
188
  }
119
189
 
120
- const second = await probe();
190
+ const second = await verify();
121
191
  if (second.ok) return { ok: true, action: 'rebuilt' };
122
192
 
123
193
  return { ok: false, error: second.error || first.error };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.60.0",
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.60.0",
9
+ "version": "3.60.1",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.60.0",
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);
@@ -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