claude-mem-lite 3.59.0 → 3.60.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.59.0",
13
+ "version": "3.60.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.59.0",
3
+ "version": "3.60.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/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, 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';
@@ -1447,6 +1448,23 @@ async function doctor() {
1447
1448
  ok('Hook self-heal: no recent silent hook breakage');
1448
1449
  }
1449
1450
 
1451
+ // Native DB binding. Two signals, because they answer different questions:
1452
+ // the marker says "hooks have been failing" (possibly for days, since the hint
1453
+ // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
1454
+ // right now". A Node upgrade breaks every DB-touching path at once, so this is
1455
+ // the single highest-value line in doctor when it fires.
1456
+ const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1457
+ const bindingProbe = await probeBetterSqlite3Binding(bindingHostDir());
1458
+ if (!bindingProbe.ok) {
1459
+ fail(`Native DB binding: unusable (${String(bindingProbe.error).split('\n')[0]}) — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\``);
1460
+ issues++;
1461
+ } else if (breakage) {
1462
+ const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
1463
+ 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`);
1464
+ } else {
1465
+ ok(`Native DB binding: loadable on Node ${process.version}`);
1466
+ }
1467
+
1450
1468
  // Plugin/hook lifecycle state
1451
1469
  const settings = readSettings();
1452
1470
  const hasHooks = hasMemHooksConfigured(settings);
@@ -2129,6 +2147,51 @@ function regenerateLockfile() {
2129
2147
 
2130
2148
  // ─── Main ───────────────────────────────────────────────────────────────────
2131
2149
 
2150
+ // An install can own MORE THAN ONE better-sqlite3 tree (dev repo, ~/.claude-mem-lite,
2151
+ // the plugin cache), each with its own .node — and only the one the RUNNING code
2152
+ // resolves matters, i.e. the one next to this file. Rebuilding the wrong tree
2153
+ // reports success while every hook keeps failing. Fall back to INSTALL_DIR when
2154
+ // this file sits in a source-only layout with no deps of its own.
2155
+ function bindingHostDir() {
2156
+ return existsSync(join(PROJECT_DIR, 'node_modules', 'better-sqlite3')) ? PROJECT_DIR : INSTALL_DIR;
2157
+ }
2158
+
2159
+ // Local, network-free repair for an unusable native DB binding — the Node-upgrade
2160
+ // fault (ABI 127 → 137) that `repair` is the wrong size for: repair re-downloads
2161
+ // and signature-verifies a whole GitHub release and fails closed offline, while
2162
+ // this recompiles one module in place. Named in the hook hint, run unattended by
2163
+ // scripts/hook-launcher.mjs at session-start, and usable by hand.
2164
+ //
2165
+ // Takes the same install.lock as the install write phase and launch.mjs's rebuild:
2166
+ // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
2167
+ // and exit 0 (it is doing this very work), never race it.
2168
+ async function rebuildBinding() {
2169
+ const host = bindingHostDir();
2170
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2171
+ if (!release) {
2172
+ // NOT exit 0: skipping is not healing. Callers key their state on the exit
2173
+ // code — a false success would let the launcher drop its cooldown and the
2174
+ // CLI re-exec into the same broken binding.
2175
+ console.error('[install] Another install/repair is in progress — it owns the rebuild; skipping.');
2176
+ process.exitCode = 1;
2177
+ return;
2178
+ }
2179
+ try {
2180
+ const verify = await ensureBetterSqlite3Working(host);
2181
+ if (verify.ok) {
2182
+ ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} (${host})`);
2183
+ // The fault is gone → drop the marker so session-start stops retrying.
2184
+ clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2185
+ } else {
2186
+ fail(`better-sqlite3 binding still unusable: ${verify.error}`);
2187
+ log(`Try manually: cd ${host} && ${NATIVE_BINDING_REBUILD_CMD}`);
2188
+ process.exitCode = 1;
2189
+ }
2190
+ } finally {
2191
+ release();
2192
+ }
2193
+ }
2194
+
2132
2195
  // Cross-process gate around the install write phase. repair() is intentionally
2133
2196
  // NOT locked here: it spawns `install.mjs install` as a child, which takes this
2134
2197
  // lock — locking the parent too would deadlock. A live peer (another session's
@@ -2173,6 +2236,9 @@ export async function main(argv = process.argv.slice(2)) {
2173
2236
  case 'repair':
2174
2237
  await repair();
2175
2238
  break;
2239
+ case 'rebuild-binding':
2240
+ await rebuildBinding();
2241
+ break;
2176
2242
  case 'release':
2177
2243
  syncVersions();
2178
2244
  if (!flags.has('--no-lock')) regenerateLockfile();
@@ -2203,6 +2269,7 @@ Usage:
2203
2269
  node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
2204
2270
  node install.mjs self-update Check for and install updates
2205
2271
  node install.mjs repair Recover a broken install: download latest tarball, re-run install
2272
+ node install.mjs rebuild-binding Recompile better-sqlite3 for the running Node (fixes "NODE_MODULE_VERSION" after a Node upgrade)
2206
2273
  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
2274
 
2208
2275
  npx claude-mem-lite Install via npx (one-liner)
package/lib/activity.mjs CHANGED
@@ -7,7 +7,9 @@
7
7
  import { sanitizeFtsQuery } from '../utils.mjs';
8
8
  import { scrubRecord } from './scrub-record.mjs';
9
9
  import { saveObservation } from './save-observation.mjs';
10
- import { notLowSignalTitleClause } from '../scoring-sql.mjs';
10
+ // Pure title-only builder: this query runs on the EVENTS table, which has no
11
+ // lesson_learned column — the lesson-escape variant would be a SQL error here.
12
+ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
11
13
  import { OBS_TYPE_SET } from './obs-types.mjs';
12
14
 
13
15
  // Observation types (mirrors the observations.type enum) — events carry a wider
@@ -150,7 +152,7 @@ export function promoteInsightEvents(db, { project = null, minImportance = 2, ex
150
152
  FROM events
151
153
  WHERE body IS NOT NULL AND TRIM(body) != '' AND importance >= ?
152
154
  AND superseded_at_epoch IS NULL
153
- AND ${notLowSignalTitleClause('')}
155
+ AND ${buildNotLowSignalSql('')}
154
156
  ${projClause}
155
157
  ORDER BY created_at_epoch DESC LIMIT ?
156
158
  `;
@@ -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?}.
@@ -43,7 +84,11 @@ export async function probeBetterSqlite3Binding(installDir) {
43
84
  */
44
85
  export async function ensureBetterSqlite3Working(installDir, deps = {}) {
45
86
  const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
46
- const exec = deps.exec || execSync;
87
+ // Bounded by default: a node-gyp fallback that stalls (no compiler, a hung
88
+ // registry fetch) must not hang the caller forever — the CLI blocks a user at
89
+ // the terminal, and scripts/setup.sh passes an even tighter 20s cap because it
90
+ // runs under a hook timeout. Callers needing a different budget inject `exec`.
91
+ const exec = deps.exec || ((cmd, opts) => execSync(cmd, { timeout: 240_000, ...opts }));
47
92
  const rebuild = deps.rebuild || (async () => {
48
93
  // npm >= 12 blocks install/lifecycle scripts by default (the `allow-scripts`
49
94
  // allowlist ships empty). better-sqlite3's install step
@@ -57,7 +102,7 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
57
102
  // npm has no such gate and treats the unknown flag as an ignored config; if
58
103
  // it instead errors on the flag, fall back to the plain rebuild.
59
104
  try {
60
- exec('npm rebuild better-sqlite3 --dangerously-allow-all-scripts', { cwd: installDir, stdio: 'pipe' });
105
+ exec(NATIVE_BINDING_REBUILD_CMD, { cwd: installDir, stdio: 'pipe' });
61
106
  } catch {
62
107
  exec('npm rebuild better-sqlite3', { cwd: installDir, stdio: 'pipe' });
63
108
  }
@@ -77,3 +122,46 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
77
122
 
78
123
  return { ok: false, error: second.error || first.error };
79
124
  }
125
+
126
+ /**
127
+ * Foreground heal for a user-invoked process (the CLI): rebuild the binding,
128
+ * then RE-EXEC this process with its original argv and return the child's exit
129
+ * code. The re-exec is not a convenience — better-sqlite3 dlopen's its .node
130
+ * lazily and caches the handle, so a process that has already hit the stale
131
+ * binary cannot use the fresh one: retrying in-process fails with "Module did
132
+ * not self-register" (observed 2026-08-13 while healing this exact fault).
133
+ *
134
+ * Refuses to act when the guard env is already set, so a heal that does not
135
+ * actually fix the binding cannot spawn an unbounded chain of children.
136
+ *
137
+ * @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
138
+ * @returns {Promise<{healed: true, exitCode: number} | {healed: false, reason: string, error?: string}>}
139
+ */
140
+ export async function healAndReexec(opts) {
141
+ const {
142
+ installDir,
143
+ argv = process.argv,
144
+ env = process.env,
145
+ log = () => {},
146
+ } = opts;
147
+ const ensure = opts.ensure || (() => ensureBetterSqlite3Working(installDir));
148
+ const reexec = opts.reexec || ((childArgv, childEnv) => {
149
+ const r = spawnSync(childArgv[0], childArgv.slice(1), { stdio: 'inherit', env: childEnv });
150
+ return typeof r.status === 'number' ? r.status : 1;
151
+ });
152
+
153
+ if (env[BINDING_HEAL_GUARD_ENV]) return { healed: false, reason: 'already-attempted' };
154
+
155
+ log(`native DB binding unusable — rebuilding for this Node (${process.version})…`);
156
+ let verify;
157
+ try {
158
+ verify = await ensure();
159
+ } catch (e) {
160
+ return { healed: false, reason: 'rebuild-failed', error: e.message };
161
+ }
162
+ if (!verify.ok) return { healed: false, reason: 'rebuild-failed', error: verify.error };
163
+
164
+ log('binding rebuilt — retrying');
165
+ const exitCode = reexec(argv, { ...env, [BINDING_HEAL_GUARD_ENV]: '1' });
166
+ return { healed: true, exitCode };
167
+ }
@@ -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
 
@@ -50,13 +50,29 @@ export function buildLowSignalRegex() {
50
50
  * Build the SQL NOT LIKE clause chain, optionally prefixed with a table alias.
51
51
  * Output is a single parenthesized AND-chain — safe to combine with other AND/OR.
52
52
  *
53
+ * `lessonEscape` (2026-07-24 audit P1, D#11): retrieval consumers on the
54
+ * observations table pass `{ lessonEscape: true }` to admit rows whose title
55
+ * matches a LOW_SIGNAL pattern but which carry a real lesson_learned — the
56
+ * read-side counterpart of the isNoiseObservation/capNoiseImportance write-side
57
+ * signal escapes. Without it, a substantive obs titled "npm pack drops …" is
58
+ * unsearchable forever. Escape is lesson-only by design: an importance>=2
59
+ * escape would resurrect pre-v2.47 Haiku-inflated noise on legacy DBs.
60
+ * Default stays title-only because two consumers query the events table
61
+ * (no lesson_learned column) and two are noise-title METRICS, not filters.
62
+ *
53
63
  * @param {string} [alias=''] Table alias (e.g. 'o') — empty for unqualified.
64
+ * @param {object} [opts]
65
+ * @param {boolean} [opts.lessonEscape=false] Admit rows with non-empty, non-'none' lesson_learned.
54
66
  * @returns {string} SQL boolean expression
55
67
  */
56
- export function buildNotLowSignalSql(alias = '') {
68
+ export function buildNotLowSignalSql(alias = '', { lessonEscape = false } = {}) {
57
69
  const p = alias ? `${alias}.` : '';
58
70
  const clauses = LOW_SIGNAL_PATTERNS.map(({ like }) => `${p}title NOT LIKE '${like}'`);
59
- return '(\n ' + clauses.join('\n AND ') + '\n )';
71
+ const chain = '(\n ' + clauses.join('\n AND ') + '\n )';
72
+ if (!lessonEscape) return chain;
73
+ // Mirrors the write-side lesson test: String(lesson).trim().toLowerCase() not in ('', 'none').
74
+ const escape = `(${p}lesson_learned IS NOT NULL AND LOWER(TRIM(${p}lesson_learned)) NOT IN ('', 'none'))`;
75
+ return `(${chain} OR ${escape})`;
60
76
  }
61
77
 
62
78
  // Cached singleton — isNoiseObservation is called once per observation insert.
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  // Splits pure data aggregation from text rendering so MCP handlers don't
4
4
  // collide with CLI's `out()` stdout-write pattern.
5
5
 
6
- import { notLowSignalTitleClause } from '../scoring-sql.mjs';
6
+ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
7
7
  import { truncate } from '../format-utils.mjs';
8
8
  import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
9
9
 
@@ -27,9 +27,11 @@ export function computeQualityStats(db, { project, days }) {
27
27
  const baseParams = project ? [project] : [];
28
28
  const cutoff = Date.now() - days * 86400000;
29
29
 
30
- // LOW_SIGNAL match = NOT notLowSignal. Shared helper keeps SQL in sync
31
- // with scoring-sql.mjs and pre-tool-recall.js Edit-fallback filter.
32
- const lowSignalIsMatchExpr = `NOT ${notLowSignalTitleClause('')}`;
30
+ // LOW_SIGNAL match = NOT notLowSignal. Pure title-only builder: this is a
31
+ // METRIC counting pattern-titled rows ("Low-signal titles" in stats output),
32
+ // not a retrieval filter — the lesson-escape variant would silently exclude
33
+ // lesson-bearing rows from the count and understate title degradation.
34
+ const lowSignalIsMatchExpr = `NOT ${buildNotLowSignalSql('')}`;
33
35
 
34
36
  // Narrative-text proxy for bugfix investigations that never landed a fix.
35
37
  const unresolvedNarrativeExpr = `(
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;
package/memdir.mjs CHANGED
@@ -304,9 +304,30 @@ export function removePluginDoc(memdir, slug) {
304
304
  // noise.
305
305
 
306
306
  const AUDIT_FILE_RE = /^(feedback|project)_[A-Za-z0-9_-]+\.md$/;
307
+ // Legacy skip prefixes: user_*/reference_* have no Why/How requirement. A known
308
+ // filename prefix wins over frontmatter type (a user_*.md stays excluded even if
309
+ // its frontmatter says feedback).
310
+ const SKIP_FILE_RE = /^(user|reference)_[A-Za-z0-9_-]+\.md$/;
307
311
  const WHY_RE = /^\s*\*\*Why:\*\*/m;
308
312
  const HOW_RE = /^\s*\*\*How to apply:\*\*/m;
309
313
 
314
+ /**
315
+ * Extract the memory type from a file's YAML frontmatter (2026-07-24 audit P2).
316
+ * The current CC harness writes kebab-case filenames (ship-runbook.md) and puts
317
+ * the type in frontmatter — either top-level `type: X` or nested under
318
+ * `metadata:` as ` type: X`. Anchored `^\s*type:` cannot match `node_type:`.
319
+ *
320
+ * @param {string} raw Full file content
321
+ * @returns {string|null} Lowercased type, or null when absent/no frontmatter
322
+ */
323
+ function frontmatterType(raw) {
324
+ if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return null;
325
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
326
+ if (!m) return null;
327
+ const t = m[1].match(/^\s*type:\s*([A-Za-z]+)\s*$/m);
328
+ return t ? t[1].toLowerCase() : null;
329
+ }
330
+
310
331
  /**
311
332
  * Strip the leading YAML frontmatter block (between `---` fences) so audit
312
333
  * checks run only against body content. Returns input unchanged if no
@@ -339,13 +360,29 @@ export function auditMemdir(memdir) {
339
360
  let entries;
340
361
  try { entries = readdirSync(memdir); } catch { return result; }
341
362
 
342
- const targets = entries.filter(n => AUDIT_FILE_RE.test(n)).sort();
343
- for (const name of targets) {
344
- let body = '';
345
- try {
346
- const raw = readFileSync(join(memdir, name), 'utf8');
347
- body = stripFrontmatter(raw);
348
- } catch { /* unreadable — count as missingBoth */ }
363
+ // Two selection paths (2026-07-24 audit P2):
364
+ // 1. legacy filename prefix feedback_*/project_* audited, user_*/reference_* skipped
365
+ // 2. kebab-case (current harness) — frontmatter type ∈ {feedback, project}
366
+ // Path 2 needs the file content; read once and reuse for the body check.
367
+ const candidates = entries
368
+ .filter(n => n.endsWith('.md') && n !== 'MEMORY.md' && !n.startsWith('.'))
369
+ .sort();
370
+ let total = 0;
371
+ for (const name of candidates) {
372
+ const legacyAudit = AUDIT_FILE_RE.test(name);
373
+ if (!legacyAudit && SKIP_FILE_RE.test(name)) continue;
374
+
375
+ let raw = null;
376
+ try { raw = readFileSync(join(memdir, name), 'utf8'); } catch { /* unreadable */ }
377
+
378
+ if (!legacyAudit) {
379
+ // Frontmatter decides; unreadable or untyped files carry no Why/How contract.
380
+ const type = raw === null ? null : frontmatterType(raw);
381
+ if (type !== 'feedback' && type !== 'project') continue;
382
+ }
383
+ // Legacy-prefixed unreadable file keeps the old behavior: counted as missingBoth.
384
+ const body = raw === null ? '' : stripFrontmatter(raw);
385
+ total += 1;
349
386
 
350
387
  const hasWhy = WHY_RE.test(body);
351
388
  const hasHow = HOW_RE.test(body);
@@ -354,6 +391,6 @@ export function auditMemdir(memdir) {
354
391
  else if (!hasWhy) result.missingWhy.push(name);
355
392
  else result.missingHowToApply.push(name);
356
393
  }
357
- result.total = targets.length;
394
+ result.total = total;
358
395
  return result;
359
396
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.59.0",
3
+ "version": "3.60.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.59.0",
9
+ "version": "3.60.0",
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.0",
3
+ "version": "3.60.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/scoring-sql.mjs CHANGED
@@ -152,8 +152,17 @@ export function noisePenaltyClause(alias = 'o') {
152
152
  // The SQL path (this), the regex path (utils.mjs::LOW_SIGNAL_TITLE), and the
153
153
  // pre-tool-recall.js inline SQL now all derive from one authoritative
154
154
  // pattern list. Previously hand-mirrored with "keep in sync" comments.
155
+ //
156
+ // lessonEscape (2026-07-24 audit P1, D#11): every consumer of THIS clause is an
157
+ // observations-table retrieval surface (search, recall, error-recall, context/
158
+ // handoff/UPS injection, optimize candidates), so all get the read-side lesson
159
+ // escape — a low-signal TITLE no longer hides a row with a real lesson_learned.
160
+ // Consumers that must stay title-only import buildNotLowSignalSql directly:
161
+ // events-table queries (lib/activity.mjs, pre-tool-recall.js events fallback —
162
+ // no lesson_learned column) and noise-title metrics (lib/stats-core.mjs,
163
+ // lib/stats-quality.mjs — they COUNT pattern-titled rows, not filter them).
155
164
  export function notLowSignalTitleClause(alias = 'o') {
156
- return buildNotLowSignalSql(alias);
165
+ return buildNotLowSignalSql(alias, { lessonEscape: true });
157
166
  }
158
167
 
159
168
  // ─── Cite-history factor (A1, v2.83) ────────────────────────────────────────
@@ -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);