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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook.mjs +71 -54
- package/install.mjs +177 -41
- package/lib/hook-stdout.mjs +83 -0
- package/lib/install-shape.mjs +218 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/source-files.mjs +9 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
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):
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
1347
|
-
// T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
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
|
-
|
|
1385
|
-
|
|
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
|
|
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
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
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_*).
|
|
1536
|
+
// reason NOT to call mem_*).
|
|
1537
|
+
const stdoutParts = [];
|
|
1538
|
+
if (dashboardText) stdoutParts.push(dashboardText);
|
|
1538
1539
|
if (fullContext.trim()) {
|
|
1539
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1536
|
+
// (which reuses these results) honest rather than answering from a poisoned
|
|
1495
1537
|
// process.
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
//
|
|
1513
|
-
|
|
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
|
|
1552
|
-
// should not pay for
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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.
|