claude-mem-lite 3.69.0 → 3.70.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.69.0",
13
+ "version": "3.70.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.69.0",
3
+ "version": "3.70.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/hook.mjs CHANGED
@@ -48,6 +48,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
+ import { queueHookContext, flushHookStdout } from './lib/hook-stdout.mjs';
51
52
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
52
53
  import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
53
54
  import { snapshotDb } from './lib/db-backup.mjs';
@@ -168,6 +169,11 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
168
169
  }
169
170
  }
170
171
  } catch {}
172
+ // The salvage path exits without falling through to the dispatcher tail, so
173
+ // it owns its own flush: a receipt queued before the signal arrived was
174
+ // delivered by the pre-v3.70 inline write and would otherwise be dropped here
175
+ // (pre-tag review NOTE N1).
176
+ try { flushHookStdout(); } catch { /* never change the exit code for a receipt */ }
171
177
  process.exit(0);
172
178
  });
173
179
  });
@@ -265,20 +271,13 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
265
271
  // bugfix-shape nudge above and may co-fire.
266
272
  const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
267
273
  if (citeBack) lines.push(citeBack);
268
- // Trailing newline is REQUIRED: when this receipt flushes at SessionStart
269
- // (leftover episode after /clear or /compact), the startup dashboard writes a
270
- // second hookSpecificOutput object right after. Without the '\n' the two land
271
- // back-to-back as `}{` on one line and Claude Code's line-based JSON parser
272
- // drops both losing the episode-flush / cite-back context exactly at the
273
- // session boundary. Every other hookSpecificOutput write appends '\n'; this
274
- // was the lone exception.
275
- process.stdout.write(JSON.stringify({
276
- suppressOutput: true,
277
- hookSpecificOutput: {
278
- hookEventName,
279
- additionalContext: lines.join('\n'),
280
- },
281
- }) + '\n');
274
+ // Queued, not written: when this receipt flushes at SessionStart (leftover
275
+ // episode after /clear or /compact) the startup dashboard also has something
276
+ // to say, and two envelopes on one stdout is not a JSON document — Claude
277
+ // Code's parser takes the whole thing as plain text (lib/hook-stdout.mjs).
278
+ // The older comment here claimed a line-based parser made two objects safe
279
+ // as long as each got its own line; the 2.1.233 bundle has no such parser.
280
+ queueHookContext(hookEventName, lines.join('\n'));
282
281
  } catch { /* never block on receipt */ }
283
282
  }
284
283
 
@@ -513,16 +512,14 @@ function triggerErrorRecall(db, toolInput, response) {
513
512
  // the G8 gate change (isError→isHardError) could not be volume-verified
514
513
  // from metrics. Counter only; no latency (query is bundled in the hook).
515
514
  recordMetric(join(RUNTIME_DIR, '..'), { event: 'error_recall', returned: rows.length });
516
- // MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
517
- // NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
518
- // receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
519
- // and is silently dropped on CC variants that ignore plain-text PostToolUse
520
- // stdout. This is the same suppressOutput+additionalContext channel
521
- // flushEpisode uses; two separate JSON lines each parse independently.
522
- process.stdout.write(JSON.stringify({
523
- suppressOutput: true,
524
- hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: out },
525
- }) + '\n');
515
+ // MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
516
+ // a raw multi-line write corrupts a co-emitted episode-flush receipt.
517
+ // The follow-up correction (2026-08-17): "two separate JSON lines each parse
518
+ // independently" was false. A hard-error Bash call reaches BOTH this and
519
+ // flushEpisode in one handlePostToolUse, and two documents make the parser
520
+ // fall back to plain text which the renderer drops entirely for
521
+ // PostToolUse. Both receipts vanished. Queue; one envelope is written at exit.
522
+ queueHookContext('PostToolUse', out);
526
523
  }
527
524
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
528
525
  }
@@ -1343,10 +1340,16 @@ function buildFallbackFastSummary(db, { project, now, prevSessionId }) {
1343
1340
  }
1344
1341
  }
1345
1342
 
1346
- async function emitStartupDashboard(db, project) {
1347
- // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
1348
- // structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
1349
- // <claude-mem-context> so both surfaces coexist. Empty string skip.
1343
+ async function buildStartupDashboardText(db, project) {
1344
+ // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into text.
1345
+ //
1346
+ // Returns the text rather than writing it: SessionStart has three would-be
1347
+ // stdout contributors (this, the <claude-mem-context> block, the update
1348
+ // banner) and handleSessionStart merges them into ONE envelope. Writing here
1349
+ // put a JSON document and raw prose on the same stdout, which stopped the
1350
+ // host from parsing the envelope at all — the whole `{"suppressOutput":true,
1351
+ // …}` object was delivered to the model as literal escaped text. See
1352
+ // tests/session-start-stdout-envelope.test.mjs.
1350
1353
  try {
1351
1354
  const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
1352
1355
  let dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
@@ -1381,16 +1384,8 @@ async function emitStartupDashboard(db, project) {
1381
1384
  dashboardText = dashboardText ? `${nudge}\n${dashboardText}` : nudge;
1382
1385
  }
1383
1386
  } catch (e) { debugCatch(e, 'session-start-deps-flag'); }
1384
- if (dashboardText) {
1385
- process.stdout.write(JSON.stringify({
1386
- suppressOutput: true,
1387
- hookSpecificOutput: {
1388
- hookEventName: 'SessionStart',
1389
- additionalContext: dashboardText,
1390
- },
1391
- }) + '\n');
1392
- }
1393
- } catch (e) { debugCatch(e, 'session-start-dashboard'); }
1387
+ return dashboardText || '';
1388
+ } catch (e) { debugCatch(e, 'session-start-dashboard'); return ''; }
1394
1389
  }
1395
1390
 
1396
1391
  async function handleSessionStart() {
@@ -1517,7 +1512,7 @@ async function handleSessionStart() {
1517
1512
 
1518
1513
  buildFallbackFastSummary(db, { project, now, prevSessionId });
1519
1514
 
1520
- await emitStartupDashboard(db, project);
1515
+ const dashboardText = await buildStartupDashboardText(db, project);
1521
1516
 
1522
1517
  // Build the full context body via shared helper (also used by `mem-cli context`).
1523
1518
  // Queries session_summaries, key observations, clear handoff, and the
@@ -1527,16 +1522,43 @@ async function handleSessionStart() {
1527
1522
  const contextCollector = {};
1528
1523
  const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
1529
1524
 
1530
- // Stdout is the sole context-delivery channel. The SessionStart hook output
1531
- // is injected as a <system-reminder> at session start, giving Claude the
1532
- // full summary + handoff state + observations table fresh from the DB.
1525
+ // Stdout is the sole context-delivery channel, and it carries exactly ONE
1526
+ // JSON envelope. Everything SessionStart wants to say is collected here and
1527
+ // written once below: a JSON document followed by raw prose is not a JSON
1528
+ // document, and the host then declines to parse the envelope — delivering
1529
+ // `{"suppressOutput":true,…}` to the model as literal escaped text instead
1530
+ // of honouring it.
1531
+ //
1533
1532
  // Skip the wrapper entirely when there is no body. On a brand-new install every
1534
1533
  // section is empty, and the hook still emitted `<claude-mem-context>\n\n</...>` —
1535
1534
  // a framing block that asserts a memory surface and then shows nothing, which is
1536
1535
  // both wasted context and an active misread ("memory exists and is empty" is a
1537
- // reason NOT to call mem_*). Non-empty output is byte-identical.
1536
+ // reason NOT to call mem_*).
1537
+ const stdoutParts = [];
1538
+ if (dashboardText) stdoutParts.push(dashboardText);
1538
1539
  if (fullContext.trim()) {
1539
- process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1540
+ stdoutParts.push(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>`);
1541
+ }
1542
+
1543
+ // Auto-update banner (audit P3d): NON-BLOCKING — read from cached state
1544
+ // (zero network) and, if the 24h check is due, refresh in a detached
1545
+ // background worker so SessionStart never blocks on a GitHub fetch (was an
1546
+ // inline `await checkForUpdate()` that could stall the session 3-6s).
1547
+ // Collected here rather than written at the end of the handler so it joins
1548
+ // the single envelope; the spawn stays a side effect and is fired below.
1549
+ let updateCheckDue = false;
1550
+ try {
1551
+ const banner = getCachedUpdateBanner();
1552
+ if (banner) stdoutParts.push(String(banner).trim());
1553
+ updateCheckDue = isUpdateCheckDue();
1554
+ } catch (e) { debugCatch(e, 'session-start-update'); }
1555
+
1556
+ // Queued into the same single envelope a leftover episode receipt may also
1557
+ // be contributing to (flushEpisode runs earlier in this very process after
1558
+ // /clear or /compact). Written once, at the dispatcher's exit.
1559
+ if (stdoutParts.length) queueHookContext('SessionStart', stdoutParts.join('\n\n'));
1560
+ if (updateCheckDue) {
1561
+ try { spawnBackground('update-check'); } catch (e) { debugCatch(e, 'session-start-update-spawn'); }
1540
1562
  }
1541
1563
 
1542
1564
  // D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
@@ -1580,16 +1602,6 @@ async function handleSessionStart() {
1580
1602
  // Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
1581
1603
  try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
1582
1604
 
1583
- // Auto-update check (audit P3d): NON-BLOCKING. Emit the banner from cached
1584
- // state (zero network) and, if the 24h check is due, refresh in a detached
1585
- // background worker so SessionStart never blocks on a GitHub fetch (was an
1586
- // inline `await checkForUpdate()` that could stall the session 3-6s).
1587
- try {
1588
- const banner = getCachedUpdateBanner();
1589
- if (banner) process.stdout.write(banner);
1590
- if (isUpdateCheckDue()) spawnBackground('update-check');
1591
- } catch (e) { debugCatch(e, 'session-start-update'); }
1592
-
1593
1605
  } finally {
1594
1606
  db.close();
1595
1607
  }
@@ -1995,4 +2007,9 @@ try {
1995
2007
  recordHookError(`hook:${event}`, err, RUNTIME_DIR);
1996
2008
  }
1997
2009
 
2010
+ // Single stdout write for the whole process (lib/hook-stdout.mjs). Runs after the
2011
+ // catch too: a handler that queued a receipt and then threw should still deliver
2012
+ // what it had, and Claude Code only ever reads one JSON document from here.
2013
+ try { flushHookStdout(); } catch { /* a receipt must never change the exit code */ }
2014
+
1998
2015
  process.exit(0);
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, probeBindingInFreshProcess, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
43
+ import { probeBetterSqlite3Binding, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
44
+ import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
44
45
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
45
46
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
46
47
  import { acquireLock } from './lib/proc-lock.mjs';
@@ -501,6 +502,23 @@ if (IS_DEV) {
501
502
  log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --dangerously-allow-all-scripts');
502
503
  process.exit(1);
503
504
  }
505
+
506
+ // The package this installer is RUNNING from owns a second tree, and after
507
+ // `npm i -g claude-mem-lite` npm >= 12 has left its better-sqlite3 install
508
+ // scripts blocked — so the binding is present-but-uncompiled and nothing
509
+ // above touches it. The shell CLI heals it on first DB use, but only after
510
+ // the user has already seen `doctor` report `2 issue(s) found` on a
511
+ // correct install. Close the window here instead. Never fatal: this tree is
512
+ // not what hooks or the MCP server load.
513
+ if (PROJECT_DIR !== INSTALL_DIR && existsSync(join(PROJECT_DIR, 'node_modules', 'better-sqlite3'))) {
514
+ const selfVerify = await ensureBetterSqlite3Working(PROJECT_DIR);
515
+ if (selfVerify.ok) {
516
+ if (selfVerify.action === 'rebuilt') ok(`better-sqlite3: rebuilt for the running package too (${PROJECT_DIR})`);
517
+ } else {
518
+ warn(`better-sqlite3 unusable in the package this installer runs from (${PROJECT_DIR}): ${selfVerify.error}`);
519
+ log(` The install itself is fine; the \`claude-mem-lite\` shell command will self-heal on first use, or run: cd ${PROJECT_DIR} && ${NATIVE_BINDING_REBUILD_CMD}`);
520
+ }
521
+ }
504
522
  }
505
523
  }
506
524
 
@@ -1352,6 +1370,14 @@ async function status() {
1352
1370
  const checks = [];
1353
1371
  const push = (level, key, message, extra = {}) => checks.push({ level, key, message, ...extra });
1354
1372
 
1373
+ // A plugin install registers its MCP server and its hooks through the plugin
1374
+ // manifest, never through `claude mcp add` / settings.json. Without knowing
1375
+ // that, status printed `✗ MCP server: not registered` and `✗ Hooks: not
1376
+ // configured` at a correctly-installed plugin user — two red marks describing
1377
+ // the intended state.
1378
+ const shape = detectInstallShape({ home: homedir(), projectDir: PROJECT_DIR, installDir: INSTALL_DIR });
1379
+ const pluginProvides = !!shape.activePluginVersion;
1380
+
1355
1381
  // MCP
1356
1382
  try {
1357
1383
  const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
@@ -1364,7 +1390,13 @@ async function status() {
1364
1390
  // circuited first). `claude mcp list` formats as `<name>: <command>`, so
1365
1391
  // the two colon-form checks below cover every shape.
1366
1392
  const registered = list.includes('mem-lite:') || list.includes('mem:');
1367
- push(registered ? 'ok' : 'fail', 'mcp', registered ? 'MCP server: registered' : 'MCP server: not registered', { registered });
1393
+ if (registered) {
1394
+ push('ok', 'mcp', 'MCP server: registered', { registered });
1395
+ } else if (pluginProvides) {
1396
+ push('ok', 'mcp', `MCP server: provided by the plugin manifest (v${shape.activePluginVersion.version} .mcp.json) — no user-scope registration expected`, { registered: false, via: 'plugin' });
1397
+ } else {
1398
+ push('fail', 'mcp', 'MCP server: not registered', { registered });
1399
+ }
1368
1400
  } catch {
1369
1401
  push('warn', 'mcp', 'Could not check MCP status', { registered: null });
1370
1402
  }
@@ -1385,6 +1417,8 @@ async function status() {
1385
1417
  push('ok', 'hooks', 'Hooks: configured', { configured: true });
1386
1418
  } else if (pluginDisabled) {
1387
1419
  push('ok', 'hooks', 'Hooks: not configured', { configured: false });
1420
+ } else if (pluginProvides) {
1421
+ push('ok', 'hooks', `Hooks: provided by the plugin manifest (v${shape.activePluginVersion.version} hooks/hooks.json) — settings.json correctly holds none`, { configured: false, via: 'plugin' });
1388
1422
  } else {
1389
1423
  push('fail', 'hooks', 'Hooks: not configured', { configured: false });
1390
1424
  }
@@ -1487,18 +1521,36 @@ async function doctor() {
1487
1521
  issues++;
1488
1522
  }
1489
1523
 
1524
+ // Which code homes does this machine actually run? A machine can hold three
1525
+ // at once (plugin cache / ~/.claude-mem-lite / npm-global) and each owns its
1526
+ // own native binding. Answering about only the dir install.mjs sits in got it
1527
+ // wrong both ways in the field: `✗ server.mjs: missing` on a healthy
1528
+ // plugin-only install, and `✓ better-sqlite3: verified` while the registered
1529
+ // MCP server FATAL'd because a DIFFERENT tree was stale. See lib/install-shape.mjs.
1530
+ const shape = detectInstallShape({ home: homedir(), projectDir: PROJECT_DIR, installDir: INSTALL_DIR });
1531
+
1490
1532
  // Dependencies. Out of process: an in-process open of a STALE .node caches a
1491
1533
  // dead module handle for the rest of doctor and can SIGSEGV on teardown —
1492
1534
  // truncating the report of the very run the user started because things are
1493
1535
  // broken. This is also what makes the native-binding check further down
1494
- // (which reads the same tree) honest rather than answering from a poisoned
1536
+ // (which reuses these results) honest rather than answering from a poisoned
1495
1537
  // process.
1496
- const depProbe = probeBindingInFreshProcess(bindingHostDir());
1497
- if (depProbe.ok) {
1498
- ok('better-sqlite3: verified (import + open OK)');
1499
- } else {
1500
- fail(`better-sqlite3: import/init failed (${String(depProbe.error).split('\n')[0]})`);
1538
+ const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
1539
+ const brokenRoots = rootProbes.filter((r) => !r.ok);
1540
+ if (rootProbes.length === 0) {
1541
+ fail('better-sqlite3: no install on this machine owns a native binding — nothing here can open the DB');
1501
1542
  issues++;
1543
+ } else if (brokenRoots.length === 0) {
1544
+ ok(`better-sqlite3: verified in ${rootProbes.length} install${rootProbes.length === 1 ? '' : 's'} (${rootProbes.map((r) => r.label).join('; ')})`);
1545
+ } else {
1546
+ // Name the ROOT, not just the fault: the repair is per-tree, and pointing a
1547
+ // user at the wrong `cd` is how `rebuild-binding` used to report success
1548
+ // while the broken install stayed broken.
1549
+ for (const b of brokenRoots) {
1550
+ fail(`better-sqlite3 unusable in ${b.label}: ${b.error}`);
1551
+ log(` repair: ${b.repair}`);
1552
+ issues++;
1553
+ }
1502
1554
  }
1503
1555
 
1504
1556
  try {
@@ -1509,20 +1561,27 @@ async function doctor() {
1509
1561
  issues++;
1510
1562
  }
1511
1563
 
1512
- // Server file
1513
- if (existsSync(SERVER_PATH)) {
1564
+ // Entry points. These live in ~/.claude-mem-lite ONLY in the install.mjs-managed
1565
+ // layout; `/plugin install` provisions the data dir but serves code from the
1566
+ // plugin cache, so demanding them there reported two ✗ and exit 1 on a healthy
1567
+ // install of the README's recommended method. Grade against the shape that is
1568
+ // actually in use.
1569
+ if (shape.managed) {
1514
1570
  ok(`server.mjs: ${SERVER_PATH}`);
1515
- } else {
1516
- fail('server.mjs: missing');
1517
- issues++;
1518
- }
1519
-
1520
- // Hook file
1521
- if (existsSync(HOOK_PATH)) {
1522
1571
  ok(`hook.mjs: ${HOOK_PATH}`);
1572
+ } else if (shape.activePluginVersion) {
1573
+ const v = shape.activePluginVersion;
1574
+ ok(`Entry points: served from plugin cache v${v.version} (plugin-only install — the ~/.claude-mem-lite code layout is not used)`);
1575
+ for (const entry of ['server.mjs', 'hook.mjs', 'cli.mjs']) {
1576
+ if (!existsSync(join(v.root, entry))) {
1577
+ fail(`Plugin cache v${v.version}: ${entry} missing — reinstall with \`/plugin install claude-mem-lite@sdsrss\``);
1578
+ issues++;
1579
+ }
1580
+ }
1523
1581
  } else {
1582
+ fail('server.mjs: missing');
1524
1583
  fail('hook.mjs: missing');
1525
- issues++;
1584
+ issues += 2;
1526
1585
  }
1527
1586
 
1528
1587
  // Hook self-heal runtime: the launcher (scripts/hook-launcher.mjs) degrades a
@@ -1548,11 +1607,10 @@ async function doctor() {
1548
1607
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
1549
1608
  // the single highest-value line in doctor when it fires.
1550
1609
  const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
1551
- // Reuses the dependency probe above — same tree, same question, and doctor
1552
- // should not pay for two child spawns to ask it twice.
1553
- const bindingProbe = depProbe;
1554
- if (!bindingProbe.ok) {
1555
- fail(`Native DB binding: unusable (${String(bindingProbe.error).split('\n')[0]}) — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\``);
1610
+ // Reuses the per-root probes above — same trees, same question, and doctor
1611
+ // should not pay for another round of child spawns to ask it twice.
1612
+ if (brokenRoots.length > 0) {
1613
+ fail(`Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\` (repairs every broken install, not just this one)`);
1556
1614
  issues++;
1557
1615
  } else if (breakage) {
1558
1616
  const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
@@ -1593,6 +1651,11 @@ async function doctor() {
1593
1651
  ok('Plugin lifecycle: disabled cleanly (no active mem hooks)');
1594
1652
  } else if (hasHooks) {
1595
1653
  ok('Plugin lifecycle: hooks active');
1654
+ } else if (shape.activePluginVersion) {
1655
+ // Plugin-only: hooks come from the cache's hooks/hooks.json, and an EMPTY
1656
+ // settings.json hooks block is the correct state — warning about it told a
1657
+ // correctly-installed user their hooks were missing.
1658
+ ok(`Plugin lifecycle: hooks served by the plugin manifest (v${shape.activePluginVersion.version}); settings.json correctly holds none`);
1596
1659
  } else {
1597
1660
  dwarn('Plugin lifecycle: hooks not configured');
1598
1661
  }
@@ -1668,15 +1731,11 @@ async function doctor() {
1668
1731
  // when their version segment ≠ current package.json version; dev-install
1669
1732
  // paths (no version segment) are never flagged.
1670
1733
  try {
1671
- const procs = execFileSync('pgrep', ['-af', 'chroma|claude-mem-lite.*(scripts/launch|server)\\.mjs|claude-mem.*worker'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }).trim();
1734
+ const procs = execFileSync('pgrep', ['-af', 'chroma|claude-mem-lite.*(scripts/launch|server)\\.mjs|\\.claude-mem/.*worker'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }).trim();
1672
1735
  const lines = procs.split('\n').filter(l => l && !l.includes('pgrep'));
1673
1736
  let currentVersion = '';
1674
1737
  try { currentVersion = JSON.parse(readFileSync(join(PROJECT_DIR, 'package.json'), 'utf8')).version; } catch { /* fall through with empty version */ }
1675
- const stale = lines.filter(l => {
1676
- if (/chroma|claude-mem.*worker/.test(l)) return true;
1677
- const m = l.match(/claude-mem-lite\/(\d+\.\d+\.\d+)\/(scripts\/launch|server)\.mjs/);
1678
- return m && currentVersion && m[1] !== currentVersion;
1679
- });
1738
+ const stale = lines.filter(l => isStaleMemProcess(l, currentVersion));
1680
1739
  if (stale.length > 0) {
1681
1740
  warn(`Old processes running${currentVersion ? ` (current: v${currentVersion})` : ''}:\n ` + stale.join('\n '));
1682
1741
  issues++;
@@ -1718,15 +1777,23 @@ async function doctor() {
1718
1777
  // class. Per #8043: "is this file present ≠ is this install consistent" —
1719
1778
  // missing is tracked separately by checkDevDrift but the caller MUST surface
1720
1779
  // it to honour #8268's "gate the all-green string on every counter" rule.
1780
+ // Gated on the managed layout existing at all. SOURCE_FILES describes what
1781
+ // `install` deploys into ~/.claude-mem-lite; on a plugin-only install nothing
1782
+ // was ever deployed there, so every entry reads as "missing" and this reported
1783
+ // `⚠ Managed files: 121 missing` + an issue on a correct install — prescribing
1784
+ // a repair against a path that does not exist.
1721
1785
  try {
1786
+ const skipDrift = !shape.managed && !!shape.activePluginVersion;
1722
1787
  const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
1723
- const r = checkDevDrift(INSTALL_DIR, SOURCE_FILES);
1788
+ const r = skipDrift ? null : checkDevDrift(INSTALL_DIR, SOURCE_FILES);
1724
1789
  const devRemedy = `re-run: node ${join(PROJECT_DIR, 'install.mjs')} install --dev`;
1725
1790
  const nameList = (files, count) => {
1726
1791
  const suffix = count > files.length ? ` +${count - files.length} more` : '';
1727
1792
  return `${files.join(', ')}${suffix}`;
1728
1793
  };
1729
- if (r.devMode) {
1794
+ if (skipDrift) {
1795
+ ok('Managed files: n/a (plugin-only install — code is served from the plugin cache, so ~/.claude-mem-lite holds data only)');
1796
+ } else if (r.devMode) {
1730
1797
  const parts = [];
1731
1798
  if (r.plainCount > 0) {
1732
1799
  parts.push(`${r.plainCount} non-symlink: ${nameList(r.plainFiles.slice(0, 5), r.plainCount)}`);
@@ -1772,8 +1839,11 @@ async function doctor() {
1772
1839
  if (r.missingModuleCount > 0) {
1773
1840
  parts.push(`${r.missingModuleCount} module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`);
1774
1841
  }
1842
+ // `claude-mem-lite update` is the observation editor (`update <id>`); the
1843
+ // self-updater is `self-update`. Naming the wrong one sent the user to a
1844
+ // usage error at the exact moment their install was incomplete.
1775
1845
  warn(`Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves `
1776
- + `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite update `
1846
+ + `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update `
1777
1847
  + `(or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`);
1778
1848
  issues++;
1779
1849
  }
@@ -2308,6 +2378,55 @@ function regenerateLockfile() {
2308
2378
  // resolves matters, i.e. the one next to this file. Rebuilding the wrong tree
2309
2379
  // reports success while every hook keeps failing. Fall back to INSTALL_DIR when
2310
2380
  // this file sits in a source-only layout with no deps of its own.
2381
+ /**
2382
+ * Is this `pgrep -af` line a stale claude-mem process worth flagging?
2383
+ *
2384
+ * Extracted and tightened after CI reported `1 issue(s) found` on a healthy
2385
+ * plugin-only install (v3.70.0 Release run 32068227636). The legacy clause was
2386
+ * `/claude-mem.*worker/`, which matches ANY command line where `claude-mem`
2387
+ * precedes `worker` — including vitest's own
2388
+ * `…/claude-mem-lite/node_modules/vitest/dist/workers/forks.js` whenever the repo
2389
+ * is checked out into a directory called `claude-mem-lite`, as GitHub Actions does.
2390
+ * doctor then counted an issue and exited 1 while every other check was green: the
2391
+ * exact class of false-red this release exists to remove, invisible locally only
2392
+ * because the dev checkout is not named after the package.
2393
+ *
2394
+ * The legacy worker lived under the pre-v2.20 DATA dir `~/.claude-mem/`, so anchor
2395
+ * on that dot-prefixed path segment. It cannot appear in a repo checkout path.
2396
+ *
2397
+ * @param {string} line One `pgrep -af` output line.
2398
+ * @param {string} currentVersion Running package version, '' when unreadable.
2399
+ * @returns {boolean}
2400
+ */
2401
+ export function isStaleMemProcess(line, currentVersion) {
2402
+ if (!line) return false;
2403
+ const cmd = (line.match(/^\s*\d+\s+(.*)$/)?.[1] ?? line).trim();
2404
+ if (!cmd) return false;
2405
+ const tokens = cmd.split(/\s+/);
2406
+ const exe = tokens[0] || '';
2407
+
2408
+ // A shell or wrapper that merely MENTIONS these names in its arguments is not one
2409
+ // of our processes. Searching the whole line as free text bit twice within one
2410
+ // release: first vitest workers under a checkout named `claude-mem-lite`, then the
2411
+ // `git commit -F -` publishing THIS fix, whose message text contains the word
2412
+ // "chroma". Anything that takes a program as an argument can quote us.
2413
+ if (/(^|\/)(ba|z|k|da|c|t)?sh$/.test(exe) || /(^|\/)(env|xargs|timeout|nohup|sudo|git|grep|rg|less|vi|vim|nano|code)$/.test(exe)) {
2414
+ return false;
2415
+ }
2416
+
2417
+ // Legacy chroma server: the EXECUTABLE, not a substring of some argument.
2418
+ if (/(^|\/)chroma$/.test(exe)) return true;
2419
+ // Legacy worker: a script path under the pre-v2.20 DATA dir. Dot-prefixed, so a
2420
+ // repo checkout called `claude-mem-lite` cannot produce it.
2421
+ if (tokens.some((t) => /\.claude-mem\/[^/]*worker[^/]*$/.test(t))) return true;
2422
+
2423
+ // A plugin-cache launcher/server whose version segment is not the running one.
2424
+ // Anchored at end-of-token so it is a script being executed, not prose.
2425
+ const script = tokens.find((t) => /claude-mem-lite\/\d+\.\d+\.\d+\/(scripts\/launch|server)\.mjs$/.test(t));
2426
+ if (!script || !currentVersion) return false;
2427
+ return script.match(/claude-mem-lite\/(\d+\.\d+\.\d+)\//)[1] !== currentVersion;
2428
+ }
2429
+
2311
2430
  function bindingHostDir() {
2312
2431
  return existsSync(join(PROJECT_DIR, 'node_modules', 'better-sqlite3')) ? PROJECT_DIR : INSTALL_DIR;
2313
2432
  }
@@ -2322,7 +2441,6 @@ function bindingHostDir() {
2322
2441
  // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
2323
2442
  // and exit 0 (it is doing this very work), never race it.
2324
2443
  async function rebuildBinding() {
2325
- const host = bindingHostDir();
2326
2444
  const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2327
2445
  if (!release) {
2328
2446
  // NOT exit 0: skipping is not healing. Callers key their state on the exit
@@ -2333,15 +2451,33 @@ async function rebuildBinding() {
2333
2451
  return;
2334
2452
  }
2335
2453
  try {
2336
- const verify = await ensureBetterSqlite3Working(host);
2337
- if (verify.ok) {
2338
- ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} (${host})`);
2339
- // The fault is gone drop the marker so session-start stops retrying.
2340
- clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2341
- } else {
2342
- fail(`better-sqlite3 binding still unusable: ${verify.error}`);
2343
- log(`Try manually: cd ${host} && ${NATIVE_BINDING_REBUILD_CMD}`);
2454
+ // Every code home on this machine, not just the one this file sits in.
2455
+ // Pre-fix this rebuilt bindingHostDir() alone and reported `✓ ... verified`
2456
+ // — so a user whose ~/.claude-mem-lite tree was stale (hooks silently dead,
2457
+ // MCP server FATAL'ing) ran the documented repair, watched it succeed, and
2458
+ // still had no memory. Falling back to INSTALL_DIR keeps a source-only
2459
+ // layout with no deps of its own repairable.
2460
+ const shape = detectInstallShape({ home: homedir(), projectDir: PROJECT_DIR, installDir: INSTALL_DIR });
2461
+ const targets = shape.runtimeRoots.length > 0
2462
+ ? shape.runtimeRoots
2463
+ : [{ label: 'install dir', root: bindingHostDir() }];
2464
+
2465
+ let failed = 0;
2466
+ for (const { label, root } of targets) {
2467
+ const verify = await ensureBetterSqlite3Working(root);
2468
+ if (verify.ok) {
2469
+ ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} — ${label} (${root})`);
2470
+ } else {
2471
+ fail(`better-sqlite3 binding still unusable in ${label}: ${verify.error}`);
2472
+ log(`Try manually: cd ${root} && ${NATIVE_BINDING_REBUILD_CMD}`);
2473
+ failed++;
2474
+ }
2475
+ }
2476
+ if (failed > 0) {
2344
2477
  process.exitCode = 1;
2478
+ } else {
2479
+ // Every tree is loadable → drop the marker so session-start stops retrying.
2480
+ clearNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
2345
2481
  }
2346
2482
  } finally {
2347
2483
  release();
@@ -0,0 +1,83 @@
1
+ // lib/hook-stdout.mjs — one hook process, at most ONE JSON document on stdout.
2
+ //
3
+ // Claude Code parses a command hook's stdout as a SINGLE JSON document. From the
4
+ // 2.1.233 bundle, the whole parser is:
5
+ //
6
+ // function Hxi(e) {
7
+ // let t = e.trim();
8
+ // if (!t.startsWith("{")) return { plainText: e }; // whole stdout = prose
9
+ // try { let r = XZf(t); ... } // JSON.parse(WHOLE stdout) + zod
10
+ // catch (r) { return { plainText: e } } // ← throw ⇒ whole stdout = prose
11
+ // }
12
+ //
13
+ // There is no line splitting anywhere in it. That matters because this codebase
14
+ // had assumed the opposite ("Claude Code's line-based JSON parser", hook.mjs
15
+ // flushEpisode) and shipped surfaces that emit two envelopes, or an envelope
16
+ // plus a raw block, on one stdout. Both shapes make JSON.parse throw, so:
17
+ //
18
+ // • SessionStart / UserPromptSubmit / UserPromptExpansion — the plainText is
19
+ // injected verbatim, so the model receives `{"suppressOutput":true,…}` as
20
+ // literal escaped text and suppressOutput is never honoured.
21
+ // • every other event — the renderer returns [] for plain text, so BOTH
22
+ // receipts are dropped in silence.
23
+ //
24
+ // So contributions are queued and written once. Callers keep their own gating
25
+ // (RECEIPT_EVENTS, significance, etc.); this only owns the writing.
26
+
27
+ let parts = [];
28
+ let queuedEvent = null;
29
+
30
+ /**
31
+ * Queue a contribution to this process's single stdout envelope.
32
+ *
33
+ * @param {string} hookEventName Event name for hookSpecificOutput.
34
+ * @param {string} text additionalContext contribution; empty/blank is ignored.
35
+ * @returns {void}
36
+ */
37
+ export function queueHookContext(hookEventName, text) {
38
+ if (!hookEventName) return;
39
+ const body = String(text ?? '').trim();
40
+ if (!body) return;
41
+ // Mixed event names cannot be merged — Claude Code throws when
42
+ // hookSpecificOutput.hookEventName does not match the event it dispatched.
43
+ // In practice one process serves one event; keep the first and drop the
44
+ // stragglers rather than emit an envelope the host rejects outright.
45
+ if (queuedEvent && queuedEvent !== hookEventName) return;
46
+ queuedEvent = hookEventName;
47
+ parts.push(body);
48
+ }
49
+
50
+ /**
51
+ * Write the queued contributions as one envelope. Idempotent: a second call
52
+ * with nothing queued writes nothing, so calling it from both the dispatcher
53
+ * and an exit backstop is safe.
54
+ *
55
+ * @param {{write?: (s: string) => void}} [deps]
56
+ * @returns {boolean} true when an envelope was written.
57
+ */
58
+ export function flushHookStdout(deps = {}) {
59
+ if (!queuedEvent || parts.length === 0) return false;
60
+ const write = deps.write || ((s) => process.stdout.write(s));
61
+ const payload = JSON.stringify({
62
+ suppressOutput: true,
63
+ hookSpecificOutput: {
64
+ hookEventName: queuedEvent,
65
+ additionalContext: parts.join('\n\n'),
66
+ },
67
+ }) + '\n';
68
+ parts = [];
69
+ queuedEvent = null;
70
+ write(payload);
71
+ return true;
72
+ }
73
+
74
+ /** Test seam: forget anything queued but not yet written. */
75
+ export function resetHookStdout() {
76
+ parts = [];
77
+ queuedEvent = null;
78
+ }
79
+
80
+ /** Test seam: what is queued right now. */
81
+ export function peekHookStdout() {
82
+ return { hookEventName: queuedEvent, parts: [...parts] };
83
+ }
@@ -0,0 +1,218 @@
1
+ // lib/install-shape.mjs — which code homes does this machine actually RUN?
2
+ //
3
+ // claude-mem-lite can occupy three code homes at once and they are not
4
+ // interchangeable:
5
+ //
6
+ // plugin cache ~/.claude/plugins/cache/<mp>/claude-mem-lite/<ver>/
7
+ // runs the manifest hooks + the plugin MCP launcher
8
+ // managed dir ~/.claude-mem-lite/
9
+ // runs the settings.json hooks + the registered MCP server
10
+ // npm-global <prefix>/lib/node_modules/claude-mem-lite/
11
+ // runs the `claude-mem-lite` shell command
12
+ //
13
+ // Each carries its OWN node_modules, so each has its own native binding that
14
+ // can go stale independently. install.mjs used to answer every "is the binding
15
+ // OK / are the files there" question about exactly one of them — the directory
16
+ // install.mjs itself sits in. That is the right question for install.mjs's own
17
+ // imports and the wrong one for a health check, and it failed in both
18
+ // directions in a sandbox run of the documented install flows (2026-08-17):
19
+ //
20
+ // • plugin-only user, healthy system → `✗ server.mjs: missing`,
21
+ // `✗ hook.mjs: missing`, `⚠ Managed files: 121 missing`, exit 1. Those
22
+ // files only ever exist in the managed layout, which a plugin install does
23
+ // not create.
24
+ // • npm-global CLI + a stale ~/.claude-mem-lite binding → `✓ better-sqlite3:
25
+ // verified`, exit 0, while the registered MCP server FATAL'd on startup
26
+ // ("wrong ELF class") and every hook degraded to a silent exit 0. The
27
+ // documented repair, `rebuild-binding`, then rebuilt the healthy tree and
28
+ // reported success. That is the v3.60 field failure (memory dead for four
29
+ // days) with the whole diagnose→repair chain reporting green.
30
+ //
31
+ // So: enumerate the roots, probe each, and name the one that is broken.
32
+
33
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import { homedir } from 'node:os';
36
+
37
+ import { probeBindingInFreshProcess, NATIVE_BINDING_REBUILD_CMD } from './binding-probe.mjs';
38
+
39
+ // Module-private: nothing outside needs these, and a new unused export is a
40
+ // review signal against the knip baseline recorded in CLAUDE.md.
41
+ const DEFAULT_MARKETPLACE = 'sdsrss';
42
+ const DEFAULT_PLUGIN = 'claude-mem-lite';
43
+
44
+ // Both must be present before ~/.claude-mem-lite counts as a CODE home. Either
45
+ // one alone is a torn install, and `runtime/` + the DB alone is the data-only
46
+ // dir every install shape creates — including plugin-only, which is exactly the
47
+ // case that must NOT be graded against the managed layout.
48
+ const MANAGED_ENTRY_POINTS = ['server.mjs', 'hook.mjs'];
49
+
50
+ function cacheBaseFor({ home = homedir(), marketplace = DEFAULT_MARKETPLACE, plugin = DEFAULT_PLUGIN } = {}) {
51
+ return join(home, '.claude', 'plugins', 'cache', marketplace, plugin);
52
+ }
53
+
54
+ // Leading integer per dot-segment, so a prerelease dir (`3.70.0-rc1`) orders by its
55
+ // numeric part instead of collapsing to "equal": `Number('0-rc1')` is NaN, and a NaN
56
+ // difference is falsy, which silently made the comparator return 0 and left ordering
57
+ // up to readdir insertion order (pre-tag review NOTE N5). `/^\d+\./` admits such dirs,
58
+ // so this is reachable the moment a prerelease is ever cached.
59
+ function semverDesc(a, b) {
60
+ const parts = (v) => v.split('.').map((s) => {
61
+ const n = parseInt(s, 10);
62
+ return Number.isFinite(n) ? n : 0;
63
+ });
64
+ const pa = parts(a);
65
+ const pb = parts(b);
66
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
67
+ const d = (pb[i] ?? 0) - (pa[i] ?? 0);
68
+ if (d) return d;
69
+ }
70
+ return 0;
71
+ }
72
+
73
+ /**
74
+ * True when `installDir` holds a managed CODE install, not merely the data dir.
75
+ *
76
+ * @param {string} installDir
77
+ * @returns {boolean}
78
+ */
79
+ export function hasManagedCodeInstall(installDir) {
80
+ if (!installDir || !existsSync(installDir)) return false;
81
+ return MANAGED_ENTRY_POINTS.every((f) => existsSync(join(installDir, f)));
82
+ }
83
+
84
+ /**
85
+ * Plugin-cache version dirs that carry runnable code, newest first.
86
+ *
87
+ * Gated on scripts/launch.mjs rather than mere directory presence: a
88
+ * half-pruned or half-written version dir is not something the runtime can
89
+ * start, and listing it would invent roots to probe.
90
+ *
91
+ * @param {{home?: string, marketplace?: string, plugin?: string}} [opts]
92
+ * @returns {Array<{version: string, root: string}>}
93
+ */
94
+ export function listPluginCacheVersions(opts = {}) {
95
+ const base = cacheBaseFor(opts);
96
+ if (!existsSync(base)) return [];
97
+ const out = [];
98
+ let entries;
99
+ try { entries = readdirSync(base); } catch { return []; }
100
+ for (const version of entries) {
101
+ if (!/^\d+\./.test(version)) continue;
102
+ const root = join(base, version);
103
+ if (!existsSync(join(root, 'scripts', 'launch.mjs'))) continue;
104
+ out.push({ version, root });
105
+ }
106
+ return out.sort((a, b) => semverDesc(a.version, b.version));
107
+ }
108
+
109
+ /**
110
+ * Every distinct code home on this machine, plus the subset that owns a native
111
+ * binding worth probing.
112
+ *
113
+ * `runtimeRoots` deduplicates on the REALPATH OF THE BINDING, not of the root:
114
+ * scripts/setup.sh's fast path symlinks a plugin cache's node_modules at the
115
+ * managed dir's, so two distinct roots routinely share one tree. Probing it
116
+ * twice would double every failure message for a single fault.
117
+ *
118
+ * @param {{home?: string, projectDir?: string, installDir?: string, marketplace?: string, plugin?: string, pluginRoot?: string}} opts
119
+ * @returns {{managed: boolean, pluginVersions: Array<{version: string, root: string}>, activePluginVersion: {version: string, root: string}|null, runtimeRoots: Array<{label: string, root: string, depsMissing?: boolean}>}}
120
+ */
121
+ export function detectInstallShape({
122
+ home = homedir(), projectDir, installDir, marketplace, plugin,
123
+ pluginRoot = process.env.CLAUDE_PLUGIN_ROOT,
124
+ } = {}) {
125
+ const managed = hasManagedCodeInstall(installDir);
126
+ const pluginVersions = listPluginCacheVersions({ home, marketplace, plugin });
127
+
128
+ // Only ONE cache version is live. Claude Code never prunes old version dirs and
129
+ // each keeps its own real node_modules, so probing all of them meant a Node major
130
+ // upgrade left every never-started version permanently stale: doctor red forever
131
+ // about trees nothing loads, and rebuild-binding — which clears the breakage marker
132
+ // only when EVERY target succeeds — could never clear it, reproducing the
133
+ // "launcher re-spawns npm every 6h forever" state from 2026-08-13. Prefer the
134
+ // version this process was actually launched from; else the newest.
135
+ const activePluginVersion = pluginVersions.find((v) => pluginRoot && resolvesSame(v.root, pluginRoot))
136
+ || pluginVersions[0]
137
+ || null;
138
+
139
+ const runtimeRoots = [];
140
+ const byBinding = new Map();
141
+ const add = (label, root, { certified = false } = {}) => {
142
+ if (!root) return;
143
+ const bs3 = join(root, 'node_modules', 'better-sqlite3');
144
+ if (!existsSync(bs3)) {
145
+ // A dir that merely lacks deps is not a runtime root — but one this function
146
+ // just CERTIFIED as a code home is. Its hooks and MCP server load from it and
147
+ // throw ERR_MODULE_NOT_FOUND on every fire, and that error is not in
148
+ // NATIVE_BINDING_PATTERNS, so nothing else records it either. Dropping it
149
+ // turned a pre-v3.70 exit 1 into exit 0 (pre-tag review, SHOULD-FIX 1).
150
+ if (certified && existsSync(root)) runtimeRoots.push({ label, root, depsMissing: true });
151
+ return;
152
+ }
153
+ let key = bs3;
154
+ try { key = realpathSync(bs3); } catch { /* unresolvable → dedupe on the literal path */ }
155
+ const existing = byBinding.get(key);
156
+ if (existing) {
157
+ // Same tree reached through a second home. One probe still answers for
158
+ // both, but the label has to say so — otherwise a plugin user reading
159
+ // "managed install is broken" has no way to know their plugin shares it.
160
+ existing.label += `, ${label}`;
161
+ return;
162
+ }
163
+ const entry = { label, root };
164
+ byBinding.set(key, entry);
165
+ runtimeRoots.push(entry);
166
+ };
167
+
168
+ // Order is significance order for the report: the tree the user's own command
169
+ // runs from, then the one hooks/MCP run from, then the live plugin version.
170
+ add('running CLI', projectDir);
171
+ if (managed) add('managed install (~/.claude-mem-lite)', installDir, { certified: true });
172
+ if (activePluginVersion) {
173
+ add(`plugin cache v${activePluginVersion.version}`, activePluginVersion.root, { certified: true });
174
+ }
175
+
176
+ return { managed, pluginVersions, activePluginVersion, runtimeRoots };
177
+ }
178
+
179
+ /** True when two paths denote the same directory, tolerating symlinks. */
180
+ function resolvesSame(a, b) {
181
+ if (a === b) return true;
182
+ try { return realpathSync(a) === realpathSync(b); } catch { return false; }
183
+ }
184
+
185
+ /**
186
+ * Probe each root's native binding out of process, carrying a per-root repair
187
+ * command so a failure cannot send the user to rebuild a healthy tree.
188
+ *
189
+ * @param {Array<{label: string, root: string}>} roots
190
+ * @param {{probe?: (root: string) => {ok: boolean, error?: string}}} [deps]
191
+ * @returns {Array<{label: string, root: string, ok: boolean, error?: string, repair?: string}>}
192
+ */
193
+ export function probeRuntimeRoots(roots, deps = {}) {
194
+ const probe = deps.probe || ((root) => probeBindingInFreshProcess(root));
195
+ return roots.map(({ label, root, depsMissing }) => {
196
+ // Nothing to dlopen: the tree is absent, not stale. Say so and prescribe an
197
+ // install — `npm rebuild` on a missing package exits 0 and heals nothing.
198
+ if (depsMissing) {
199
+ return {
200
+ label,
201
+ root,
202
+ ok: false,
203
+ error: 'node_modules/better-sqlite3 is absent — every hook and the MCP server '
204
+ + 'that load this install throw ERR_MODULE_NOT_FOUND',
205
+ repair: `cd ${root} && npm install --omit=dev`,
206
+ };
207
+ }
208
+ const r = probe(root);
209
+ if (r.ok) return { label, root, ok: true };
210
+ return {
211
+ label,
212
+ root,
213
+ ok: false,
214
+ error: String(r.error || 'unknown').split('\n')[0],
215
+ repair: `cd ${root} && ${NATIVE_BINDING_REBUILD_CMD}`,
216
+ };
217
+ });
218
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.69.0",
3
+ "version": "3.70.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.69.0",
9
+ "version": "3.70.0",
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.69.0",
3
+ "version": "3.70.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",
@@ -78,6 +78,8 @@
78
78
  "lib/task-imperative.mjs",
79
79
  "lib/lesson-bridge.mjs",
80
80
  "lib/binding-probe.mjs",
81
+ "lib/install-shape.mjs",
82
+ "lib/hook-stdout.mjs",
81
83
  "lib/proc-lock.mjs",
82
84
  "lib/atomic-write.mjs",
83
85
  "lib/release-digest.mjs",
package/source-files.mjs CHANGED
@@ -93,6 +93,15 @@ export const SOURCE_FILES = [
93
93
  // self-heal after Node ABI changes). Missing from manifest → auto-update
94
94
  // ships a stale install that FATALs on first DB open after Node upgrade.
95
95
  'lib/binding-probe.mjs',
96
+ // Which code homes this machine runs (plugin cache / ~/.claude-mem-lite /
97
+ // npm-global) — imported by install.mjs for doctor, status and rebuild-binding.
98
+ // Missing from the manifest → an updated install ships a doctor that throws
99
+ // ERR_MODULE_NOT_FOUND on the command users run when something is already wrong.
100
+ 'lib/install-shape.mjs',
101
+ // Single-envelope stdout for hook processes — imported by hook.mjs. Claude Code
102
+ // parses hook stdout as ONE JSON document; missing from the manifest → an updated
103
+ // install throws ERR_MODULE_NOT_FOUND on every hook fire.
104
+ 'lib/hook-stdout.mjs',
96
105
  // audit P0/P1: inter-process install lock + atomic config writes — imported by
97
106
  // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
98
107
  // + auto-update lock). Must ship or a partial install/update skips them.