claude-mem-lite 6.3.0 → 6.5.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 +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -2
- package/README.zh-CN.md +19 -2
- package/hook-context.mjs +17 -2
- package/hook-shared.mjs +48 -3
- package/hook-update.mjs +6 -1
- package/hook.mjs +35 -1
- package/install.mjs +431 -40
- package/lib/data-paths.mjs +33 -0
- package/lib/db-backup.mjs +42 -17
- package/lib/db-unusable.mjs +178 -0
- package/lib/plugin-key.mjs +4 -1
- package/lib/record-once.mjs +71 -0
- package/lib/save-observation.mjs +16 -5
- package/lib/schema-skew.mjs +21 -30
- package/lib/timeline-core.mjs +54 -15
- package/mem-cli.mjs +33 -32
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/schema.mjs +15 -22
- package/scripts/hook-launcher.mjs +25 -4
- package/server.mjs +59 -1
- package/source-files.mjs +7 -0
- package/tool-schemas.mjs +74 -47
package/install.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
statSync,
|
|
18
18
|
lstatSync,
|
|
19
19
|
} from 'fs';
|
|
20
|
-
import { join, resolve, dirname, basename } from 'path';
|
|
20
|
+
import { join, resolve, dirname, basename, sep } from 'path';
|
|
21
21
|
import { homedir, tmpdir } from 'os';
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
@@ -54,7 +54,7 @@ const SERVER_PATH = join(INSTALL_DIR, 'server.mjs');
|
|
|
54
54
|
const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
|
|
55
55
|
// P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
|
|
56
56
|
// imports — this pair used to be typed out in each.
|
|
57
|
-
import { MARKETPLACE_KEY, PLUGIN_KEY, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
|
|
57
|
+
import { MARKETPLACE_KEY, PLUGIN_KEY, PLUGIN_NAME, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
|
|
58
58
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
59
59
|
|
|
60
60
|
import {
|
|
@@ -68,9 +68,11 @@ import {
|
|
|
68
68
|
probeBetterSqlite3Binding,
|
|
69
69
|
ensureBetterSqlite3Working,
|
|
70
70
|
nativeBindingRepairHint,
|
|
71
|
+
isNativeBindingError,
|
|
71
72
|
} from './lib/binding-probe.mjs';
|
|
72
73
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
73
74
|
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
75
|
+
import { isDbUnusableError, dbUnusableRemedy } from './lib/db-unusable.mjs';
|
|
74
76
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
75
77
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
76
78
|
import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
@@ -333,6 +335,135 @@ function isDevInstall() {
|
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
|
|
338
|
+
// Last-resort recovery command, printed when the signature-verified repair path itself
|
|
339
|
+
// fails. It resolves the latest RELEASE tarball via the GitHub API rather than fetching
|
|
340
|
+
// `/tarball`, which serves the DEFAULT BRANCH — unreleased WIP. That mattered: repair()
|
|
341
|
+
// exists because the old auto-path ran main HEAD unverified, and until 2026-09-08 the
|
|
342
|
+
// fallback it printed on failure handed the user exactly that behaviour back. A shell
|
|
343
|
+
// one-liner cannot check an Ed25519 signature, so this remains a trust decision the user
|
|
344
|
+
// makes explicitly; pinning it to a release at least removes the unreleased-WIP half.
|
|
345
|
+
//
|
|
346
|
+
// FOUR surfaces carry this string — here, scripts/hook-launcher.mjs (pure-`node:` charter,
|
|
347
|
+
// cannot import lib/), README.md and README.zh-CN.md. Exported so
|
|
348
|
+
// tests/manual-fallback-sync.test.mjs can pin the other three to this one and fail if a
|
|
349
|
+
// fifth appears; a string kept in sync by a comment is a string that drifts.
|
|
350
|
+
export const MANUAL_TARBALL_FALLBACK =
|
|
351
|
+
'T=$(mktemp -d) && U=$(curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/releases/latest | grep -o \'"tarball_url"[^,]*\' | cut -d\'"\' -f4) && curl -sL "$U" | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install';
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether the local marketplace clone can still be fast-forwarded.
|
|
355
|
+
*
|
|
356
|
+
* This is the near cause of the failure v6.3.0 shipped a detector for. Claude Code updates a
|
|
357
|
+
* git-source marketplace by pulling that clone; a DIRTY working tree blocks the pull, the
|
|
358
|
+
* plugin silently stops updating, and eventually the database is written by a newer
|
|
359
|
+
* claude-mem-lite than the code that has to open it. On this machine the clone was pinned 22
|
|
360
|
+
* commits behind while everything reported green.
|
|
361
|
+
*
|
|
362
|
+
* It gets dirty on its own: with a DIRECTORY-source marketplace, `${CLAUDE_PLUGIN_ROOT}`
|
|
363
|
+
* resolves inside the clone, and `scripts/launch.mjs` runs `npm install` there whenever
|
|
364
|
+
* `node_modules/better-sqlite3` is missing — which is every materialization of a new version.
|
|
365
|
+
* That install rewrites **`package-lock.json`**, which IS tracked, and that is what blocks the
|
|
366
|
+
* pull. So the plugin's own launcher can create the state that stops the plugin updating.
|
|
367
|
+
*
|
|
368
|
+
* **Do not add a `node_modules` special case here.** A first cut did, and pre-ship review
|
|
369
|
+
* measured it dead: the clone is a clone of THIS repo, whose `.gitignore` carries
|
|
370
|
+
* `/node_modules`, so `git status --porcelain` never sees it — the branch was reachable only
|
|
371
|
+
* from a fixture that omitted the `.gitignore` the real clone always has. An ignored
|
|
372
|
+
* `node_modules` also does not block a fast-forward, so reporting it would have been noise
|
|
373
|
+
* even if it were visible. The tracked-file dirt is the whole signal.
|
|
374
|
+
*
|
|
375
|
+
* Five outcomes, and `unknown` is one of them on purpose: "I could not run git" must not be
|
|
376
|
+
* reported in the same voice as "the tree is clean".
|
|
377
|
+
*
|
|
378
|
+
* Exported for tests/marketplace-clone-health.test.mjs.
|
|
379
|
+
*/
|
|
380
|
+
export function marketplaceCloneHealth(
|
|
381
|
+
dir,
|
|
382
|
+
run = (args) => execFileSync('git', args, { encoding: 'utf8', timeout: 20000 }),
|
|
383
|
+
) {
|
|
384
|
+
if (!existsSync(dir)) return { kind: 'absent' };
|
|
385
|
+
if (!existsSync(join(dir, '.git'))) return { kind: 'not-git' };
|
|
386
|
+
let porcelain;
|
|
387
|
+
try {
|
|
388
|
+
porcelain = run(['-C', dir, 'status', '--porcelain']);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return { kind: 'unknown', reason: e?.code || e?.message || 'git failed' };
|
|
391
|
+
}
|
|
392
|
+
const entries = String(porcelain)
|
|
393
|
+
.split('\n')
|
|
394
|
+
.filter((l) => l.trim());
|
|
395
|
+
if (entries.length === 0) return { kind: 'clean' };
|
|
396
|
+
return { kind: 'dirty', count: entries.length };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The `mem-lite` / `mem` registrations in `claude mcp list` output that are NOT provided by
|
|
401
|
+
* a plugin manifest.
|
|
402
|
+
*
|
|
403
|
+
* `claude mcp list` prints one `<name>: <command>` line per server, and a plugin-provided
|
|
404
|
+
* one is named `plugin:<plugin>:<server>`. The old test — `list.includes('mem-lite:')` —
|
|
405
|
+
* matched inside `plugin:claude-mem-lite:mem-lite:`, so it could not tell the two apart and
|
|
406
|
+
* always answered "registered" for a plugin user.
|
|
407
|
+
*
|
|
408
|
+
* Deliberately named for what it MEASURES: a bare-name registration, whatever its scope.
|
|
409
|
+
* `mcp list` does not label user vs project scope on the line itself, so calling this
|
|
410
|
+
* "user-scope" would claim more than the output supports.
|
|
411
|
+
*
|
|
412
|
+
* Exported for tests/mcp-registration-parse.test.mjs.
|
|
413
|
+
*/
|
|
414
|
+
export function nonPluginMemRegistrations(listOutput) {
|
|
415
|
+
const names = [];
|
|
416
|
+
for (const line of String(listOutput ?? '').split('\n')) {
|
|
417
|
+
// Anchored, no leading whitespace: the diagnostics block below the list is indented, and
|
|
418
|
+
// its `└ [Warning] [mem-lite] mcpServers.mem-lite: …` lines are not registrations.
|
|
419
|
+
const m = /^(\S+):\s+\S/.exec(line);
|
|
420
|
+
if (!m) continue;
|
|
421
|
+
const name = m[1];
|
|
422
|
+
if (name.startsWith('plugin:')) continue;
|
|
423
|
+
if (name === 'mem-lite' || name === 'mem') names.push(name);
|
|
424
|
+
}
|
|
425
|
+
return names;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The remedy line for a `doctor` database check that threw — or null when the failure is
|
|
430
|
+
* one this cannot classify.
|
|
431
|
+
*
|
|
432
|
+
* Returning null is deliberate and is the case worth defending: a diagnostic that always
|
|
433
|
+
* prints a fix eventually prints the wrong one, and the bare error message is a better
|
|
434
|
+
* answer than a confident irrelevance. The three CLASSIFIED outcomes are kept apart for the
|
|
435
|
+
* same reason — "restore this snapshot", "there is no snapshot", and "I could not read the
|
|
436
|
+
* directory to find out" are three different situations, and collapsing the last two ends
|
|
437
|
+
* the reader's search with a fact nobody checked.
|
|
438
|
+
*
|
|
439
|
+
* Shell commands only, no `claude-mem-lite <cmd>`: the remedy for a broken store must not
|
|
440
|
+
* itself depend on which install shape the user has (the plugin cache has no CLI on PATH).
|
|
441
|
+
*
|
|
442
|
+
* Exported for tests/doctor-db-remedy.test.mjs, which also drives the shipped doctor over a
|
|
443
|
+
* corrupt file — a pure function nothing calls is the wiring gap this repo keeps finding.
|
|
444
|
+
*/
|
|
445
|
+
export function dbCheckRemedy(dbPath, err) {
|
|
446
|
+
if (isNativeBindingError(err)) return `Repair: ${nativeBindingRepairHint(PROJECT_DIR)}`;
|
|
447
|
+
// Classification and remedy both live in lib/db-unusable.mjs since v6.5.0, because the hook
|
|
448
|
+
// path now has to answer the same question in-session and two copies of a SQLite-message
|
|
449
|
+
// regex is this repo's named twin-drift class. Doctor keeps its own SENTENCE (one line, no
|
|
450
|
+
// leading banner); only the decision is shared.
|
|
451
|
+
if (!isDbUnusableError(err)) return null;
|
|
452
|
+
const remedy = dbUnusableRemedy(dbPath);
|
|
453
|
+
if (remedy.kind === 'unknown') return remedy.note;
|
|
454
|
+
if (remedy.kind === 'set-aside') {
|
|
455
|
+
return (
|
|
456
|
+
`No backup snapshot exists beside the database. Set the broken file aside so a fresh ` +
|
|
457
|
+
`store is created on the next session: ${remedy.command} ` +
|
|
458
|
+
`— memories in that file are not recoverable without a backup.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return (
|
|
462
|
+
`Restore the newest of ${remedy.snapshotCount} backup snapshot(s): ${remedy.command} ` +
|
|
463
|
+
`— move the broken file aside first if you want to keep it for inspection.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
336
467
|
// ─── Install ────────────────────────────────────────────────────────────────
|
|
337
468
|
|
|
338
469
|
// Dynamic-import helpers, resolved against the installed copy at INSTALL_DIR
|
|
@@ -512,6 +643,57 @@ function createCliSymlink() {
|
|
|
512
643
|
}
|
|
513
644
|
}
|
|
514
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Which of our MCP names a PROJECT-scoped `.mcp.json` in `cwd` registers.
|
|
648
|
+
*
|
|
649
|
+
* This exists because the installer used to run `claude mcp remove -s project <name>` as
|
|
650
|
+
* part of "purge any pre-existing registration before re-registering". That command edits
|
|
651
|
+
* `<cwd>/.mcp.json` — a file that belongs to whatever repository the user happens to be
|
|
652
|
+
* standing in, not to this installer's state. Measured 2026-09-08: running the installer
|
|
653
|
+
* from a clone of this repo emptied the tracked root `.mcp.json` (the plugin's own MCP
|
|
654
|
+
* manifest, and a RELEASE_SIGNED_FILES entry), and nothing said so; only a test noticed.
|
|
655
|
+
* For anyone else it is a silent edit to a checked-in file that breaks the registration
|
|
656
|
+
* for every teammate who pulls it.
|
|
657
|
+
*
|
|
658
|
+
* `uninstall` has always removed `-s user` only, so the scope discipline already existed
|
|
659
|
+
* on the other half of the lifecycle; this brings install into line with it and reports
|
|
660
|
+
* the duplicate instead — same doctrine as the README's mixed-install residue section,
|
|
661
|
+
* where the tool diagnoses and the user decides.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} cwd directory to inspect
|
|
664
|
+
* @returns {{file: string, names: string[]}} names present, empty when there is nothing to say
|
|
665
|
+
*/
|
|
666
|
+
export function projectScopedMemRegistrations(cwd) {
|
|
667
|
+
const file = join(cwd, '.mcp.json');
|
|
668
|
+
try {
|
|
669
|
+
const servers = JSON.parse(readFileSync(file, 'utf8'))?.mcpServers;
|
|
670
|
+
// Array.isArray is load-bearing: `typeof [] === 'object'`, so without it an array
|
|
671
|
+
// falls through to the membership filter and the guard cannot fire on its own
|
|
672
|
+
// named input — which is how the test case for it was passing.
|
|
673
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return { file, names: [] };
|
|
674
|
+
return { file, names: ['mem', 'mem-lite'].filter((n) => n in servers) };
|
|
675
|
+
} catch {
|
|
676
|
+
// Absent, unreadable, or not JSON — nothing we can honestly report.
|
|
677
|
+
return { file, names: [] };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Say so when the directory we are standing in registers our server at PROJECT scope.
|
|
683
|
+
*
|
|
684
|
+
* Reporting rather than removing is the whole point — see projectScopedMemRegistrations.
|
|
685
|
+
*/
|
|
686
|
+
function warnProjectScopedMcpDuplicate() {
|
|
687
|
+
const projectScoped = projectScopedMemRegistrations(process.cwd());
|
|
688
|
+
if (projectScoped.names.length === 0) return;
|
|
689
|
+
warn(
|
|
690
|
+
`${projectScoped.file} also registers ${projectScoped.names.map((n) => `"${n}"`).join(' and ')} ` +
|
|
691
|
+
`at PROJECT scope — that duplicate wins inside this directory. Left untouched: it is your ` +
|
|
692
|
+
`repo's file. Remove it with \`claude mcp remove -s project <name>\` if you want the ` +
|
|
693
|
+
`user-scope registration to apply here.`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
515
697
|
function registerMcpServer() {
|
|
516
698
|
// 3. Register MCP server (skip if plugin system already handles it)
|
|
517
699
|
// Plugin MCP must stay at root .mcp.json so Claude Code registers plugin:*:mem-lite.
|
|
@@ -530,6 +712,12 @@ function registerMcpServer() {
|
|
|
530
712
|
/* not installed via plugin system */
|
|
531
713
|
}
|
|
532
714
|
|
|
715
|
+
// The DISCLOSURE is unconditional even though the removal it replaced was not: a
|
|
716
|
+
// project-scoped `mem`/`mem-lite` entry shadows the user-scope one inside that directory
|
|
717
|
+
// whichever way this install provides the server, so a plugin-mode user standing in such a
|
|
718
|
+
// repo has the same problem and used to get the same silence.
|
|
719
|
+
warnProjectScopedMcpDuplicate();
|
|
720
|
+
|
|
533
721
|
if (pluginHandlesMcp) {
|
|
534
722
|
log('MCP server: plugin system handles registration (skipping global)');
|
|
535
723
|
// Clean up stale global registrations (both legacy "mem" and current "mem-lite")
|
|
@@ -542,14 +730,13 @@ function registerMcpServer() {
|
|
|
542
730
|
} else {
|
|
543
731
|
log('Registering MCP server...');
|
|
544
732
|
try {
|
|
545
|
-
// Purge legacy "mem" and any pre-existing "mem-lite" before
|
|
733
|
+
// Purge legacy "mem" and any pre-existing "mem-lite" from OUR scope before
|
|
734
|
+
// re-registering. User scope only — see projectScopedMemRegistrations for why the
|
|
735
|
+
// project-scope removal that used to sit here was a bug, not a cleanup.
|
|
546
736
|
for (const name of ['mem', 'mem-lite']) {
|
|
547
737
|
try {
|
|
548
738
|
execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' });
|
|
549
739
|
} catch {}
|
|
550
|
-
try {
|
|
551
|
-
execFileSync('claude', ['mcp', 'remove', '-s', 'project', name], { stdio: 'pipe' });
|
|
552
|
-
} catch {}
|
|
553
740
|
}
|
|
554
741
|
execFileSync(
|
|
555
742
|
'claude',
|
|
@@ -1136,11 +1323,23 @@ async function uninstall() {
|
|
|
1136
1323
|
ok('Marketplace directory removed');
|
|
1137
1324
|
}
|
|
1138
1325
|
|
|
1139
|
-
// 5b. Remove cache
|
|
1326
|
+
// 5b. Remove cache directories — OURS unconditionally, the marketplace-wide one gated.
|
|
1327
|
+
//
|
|
1328
|
+
// The gate exists so uninstalling this plugin does not delete a sibling plugin published
|
|
1329
|
+
// under the same marketplace. That reasoning covers `cache/<marketplace>/`; it does not
|
|
1330
|
+
// cover `cache/<marketplace>/claude-mem-lite/`, which is ours alone. Because only the
|
|
1331
|
+
// gated branch existed, a user with any other sdsrss plugin installed kept every cached
|
|
1332
|
+
// version of THIS one — measured at 241 MB on a machine where `/plugin uninstall` had
|
|
1333
|
+
// already removed the manifest, i.e. bytes belonging to a plugin that was gone.
|
|
1334
|
+
const ownCacheDir = join(pluginsDir, 'cache', marketplaceKey, PLUGIN_NAME);
|
|
1335
|
+
if (existsSync(ownCacheDir)) {
|
|
1336
|
+
rmSync(ownCacheDir, { recursive: true, force: true });
|
|
1337
|
+
ok('Plugin cache removed');
|
|
1338
|
+
}
|
|
1140
1339
|
const cacheDir = join(pluginsDir, 'cache', marketplaceKey);
|
|
1141
1340
|
if (canRemoveMarketplaceArtifacts && existsSync(cacheDir)) {
|
|
1142
1341
|
rmSync(cacheDir, { recursive: true, force: true });
|
|
1143
|
-
ok('
|
|
1342
|
+
ok('Marketplace cache directory removed');
|
|
1144
1343
|
}
|
|
1145
1344
|
|
|
1146
1345
|
// 5c. Clean known_marketplaces.json
|
|
@@ -1183,12 +1382,90 @@ async function uninstall() {
|
|
|
1183
1382
|
}
|
|
1184
1383
|
}
|
|
1185
1384
|
} else {
|
|
1186
|
-
|
|
1385
|
+
// "Data preserved" was true and incomplete, and the gap is what a user notices on
|
|
1386
|
+
// disk: the DB here is fractions of a megabyte while the installed code and its
|
|
1387
|
+
// node_modules — which uninstall has just made unreachable (symlink gone, hooks gone,
|
|
1388
|
+
// MCP registration gone) — are tens of megabytes and were never named. Measured on a
|
|
1389
|
+
// sandbox install right after uninstall: 56 MB total, 53 MB of it node_modules,
|
|
1390
|
+
// against a 0.2 MB DB. Report both halves so nothing large is left unnamed.
|
|
1391
|
+
//
|
|
1392
|
+
// These are APPARENT bytes (`statSync().size`), which is what `du --apparent-size`
|
|
1393
|
+
// reports and NOT what a bare `du -sh` does: block rounding over thousands of small
|
|
1394
|
+
// node_modules files put the same tree at 44.4 MB apparent against 54.3 MB of blocks,
|
|
1395
|
+
// a 22% gap. Do not "reconcile" this line against a plain `du` — they are two rulers.
|
|
1396
|
+
const kept = preservedFootprint();
|
|
1397
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
1398
|
+
log(`Data preserved: memories in ${MEM_DATA_DIR} (${mb(kept.memoryBytes)}MB)`);
|
|
1399
|
+
if (kept.restBytes > 0) {
|
|
1400
|
+
log(
|
|
1401
|
+
` Also kept: the installed code + node_modules under ${DATA_DIR} (${mb(kept.restBytes)}MB) — ` +
|
|
1402
|
+
`a later \`install\` reuses them; nothing runs them now.`,
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
log(' `uninstall --purge` removes the directory, memories included');
|
|
1187
1406
|
}
|
|
1188
1407
|
|
|
1189
1408
|
console.log('\n Done!\n');
|
|
1190
1409
|
}
|
|
1191
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Split what a non-purge uninstall leaves behind into the two halves a user cares about:
|
|
1413
|
+
* the memories, and everything else.
|
|
1414
|
+
*
|
|
1415
|
+
* "Memories" is every file whose name starts with `claude-mem-lite.db` in the data dir —
|
|
1416
|
+
* the DB, its WAL/SHM, and the `.db.<tag>.bak` snapshots. That is DELIBERATELY WIDER than
|
|
1417
|
+
* lib/db-backup.mjs::readSnapshots, which additionally requires the trailing dot and a
|
|
1418
|
+
* `.bak` suffix: this wants everything that is the user's data, that wants snapshots only. "Rest" is the whole install directory minus that,
|
|
1419
|
+
* so it covers the source files, node_modules, runtime/ and metrics/ in one number. The
|
|
1420
|
+
* point of the split is the node_modules order-of-magnitude (53 MB against a 0.2 MB DB on
|
|
1421
|
+
* a fresh sandbox install), not a per-subdirectory audit.
|
|
1422
|
+
*
|
|
1423
|
+
* Never throws: a missing dir, a permission error or a symlink loop all degrade to 0, and
|
|
1424
|
+
* the caller prints the shorter sentence. An uninstall must not fail on a size probe.
|
|
1425
|
+
*
|
|
1426
|
+
* @returns {{memoryBytes: number, restBytes: number}} bytes, 0 when unmeasurable
|
|
1427
|
+
*/
|
|
1428
|
+
function preservedFootprint() {
|
|
1429
|
+
const DB_PREFIX = 'claude-mem-lite.db';
|
|
1430
|
+
const walk = (dir, onFile) => {
|
|
1431
|
+
let entries;
|
|
1432
|
+
try {
|
|
1433
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1434
|
+
} catch {
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
for (const e of entries) {
|
|
1438
|
+
const p = join(dir, e.name);
|
|
1439
|
+
try {
|
|
1440
|
+
if (e.isDirectory()) walk(p, onFile);
|
|
1441
|
+
else if (e.isFile()) onFile(p, e.name, statSync(p).size);
|
|
1442
|
+
} catch {
|
|
1443
|
+
/* raced deletion / unreadable entry — skip */
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
let memoryBytes = 0;
|
|
1449
|
+
let restBytes = 0;
|
|
1450
|
+
// The memories may live outside the install dir (CLAUDE_MEM_DIR), so measure each dir
|
|
1451
|
+
// for what it actually holds rather than assuming the two are the same tree.
|
|
1452
|
+
walk(MEM_DATA_DIR, (_p, name, size) => {
|
|
1453
|
+
if (name.startsWith(DB_PREFIX)) memoryBytes += size;
|
|
1454
|
+
else if (MEM_DATA_DIR === DATA_DIR) restBytes += size;
|
|
1455
|
+
});
|
|
1456
|
+
if (MEM_DATA_DIR !== DATA_DIR) {
|
|
1457
|
+
// A relocated CLAUDE_MEM_DIR may still sit INSIDE the install dir, in which case walking
|
|
1458
|
+
// DATA_DIR would count the DB and its snapshots a second time — reported 9.0MB against a
|
|
1459
|
+
// true 6.0MB on a nested fixture. Skip the memory tree explicitly rather than assume the
|
|
1460
|
+
// two are disjoint; `+ sep` so a sibling named `<dir>-old` is not swallowed too.
|
|
1461
|
+
const memPrefix = MEM_DATA_DIR.endsWith(sep) ? MEM_DATA_DIR : MEM_DATA_DIR + sep;
|
|
1462
|
+
walk(DATA_DIR, (p, _name, size) => {
|
|
1463
|
+
if (p !== MEM_DATA_DIR && !p.startsWith(memPrefix)) restBytes += size;
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return { memoryBytes, restBytes };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1192
1469
|
// ─── Cleanup Hooks ───────────────────────────────────────────────────────────
|
|
1193
1470
|
|
|
1194
1471
|
async function cleanupHooks() {
|
|
@@ -1224,35 +1501,52 @@ async function status() {
|
|
|
1224
1501
|
// configured` at a correctly-installed plugin user — two red marks describing
|
|
1225
1502
|
// the intended state.
|
|
1226
1503
|
const shape = detectInstallShape({ home: homedir(), projectDir: PROJECT_DIR, installDir: INSTALL_DIR });
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1504
|
+
// A cache DIRECTORY is not an installed plugin — `/plugin uninstall` leaves version dirs
|
|
1505
|
+
// behind (this project's own README documents that), and `activePluginVersion` falls back to
|
|
1506
|
+
// "newest cache dir" when nothing recorded an install. Both branches below credit the
|
|
1507
|
+
// manifest with providing something, so both need the registration, not the directory.
|
|
1508
|
+
const pluginProvides =
|
|
1509
|
+
!!shape.activePluginVersion && pluginIsRegistered({ home: homedir(), settings: readSettings() });
|
|
1510
|
+
|
|
1511
|
+
// MCP. A plugin install answers this from the manifest and does NOT shell out.
|
|
1512
|
+
//
|
|
1513
|
+
// Two reasons, and the first is correctness rather than speed. `claude mcp list` prints a
|
|
1514
|
+
// plugin server as `plugin:claude-mem-lite:mem-lite: …`, and the old substring test
|
|
1515
|
+
// `list.includes('mem-lite:')` matched INSIDE that name — so a plugin user was reported as
|
|
1516
|
+
// having a user-scope registration they do not have, and the branch written for them below
|
|
1517
|
+
// was unreachable. That is the same accidental-match class as the `\bmem\b` regex this
|
|
1518
|
+
// comment block used to describe. Second: the official help says approved servers are
|
|
1519
|
+
// "health-checked", i.e. the call STARTS every MCP server configured on the machine —
|
|
1520
|
+
// measured 2026-09-08 at 2.546s wall for three servers, one of them a remote HTTP endpoint.
|
|
1521
|
+
// A status command should not pay that, and a plugin user gains nothing from it.
|
|
1522
|
+
//
|
|
1523
|
+
// `doctor` GAINS the exec instead (it had none before this change) and runs it
|
|
1524
|
+
// unconditionally: it is the deep check, and it is where the duplicate/legacy registration
|
|
1525
|
+
// the README's "Mixed-install residue" section describes now gets detected — nothing
|
|
1526
|
+
// detected it before. That means `doctor` now health-checks every MCP server on the
|
|
1527
|
+
// machine; both READMEs say so under their `doctor` sections.
|
|
1528
|
+
if (pluginProvides) {
|
|
1529
|
+
push(
|
|
1530
|
+
'ok',
|
|
1531
|
+
'mcp',
|
|
1532
|
+
`MCP server: provided by the plugin manifest (v${shape.activePluginVersion.version} .mcp.json) — no user-scope registration expected`,
|
|
1533
|
+
{ registered: false, via: 'plugin' },
|
|
1534
|
+
);
|
|
1535
|
+
} else
|
|
1536
|
+
try {
|
|
1537
|
+
const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8', timeout: 60000 });
|
|
1538
|
+
// Accept either the current "mem-lite" registration or the legacy "mem" name
|
|
1539
|
+
// (pre-v2.78) so a user mid-upgrade still sees a green status until setup.sh /
|
|
1540
|
+
// install.mjs purges the legacy entry on next run.
|
|
1541
|
+
const registered = nonPluginMemRegistrations(list).length > 0;
|
|
1542
|
+
if (registered) {
|
|
1543
|
+
push('ok', 'mcp', 'MCP server: registered', { registered });
|
|
1544
|
+
} else {
|
|
1545
|
+
push('fail', 'mcp', 'MCP server: not registered', { registered });
|
|
1546
|
+
}
|
|
1547
|
+
} catch {
|
|
1548
|
+
push('warn', 'mcp', 'Could not check MCP status', { registered: null });
|
|
1252
1549
|
}
|
|
1253
|
-
} catch {
|
|
1254
|
-
push('warn', 'mcp', 'Could not check MCP status', { registered: null });
|
|
1255
|
-
}
|
|
1256
1550
|
|
|
1257
1551
|
// Hooks
|
|
1258
1552
|
const settings = readSettings();
|
|
@@ -1707,6 +2001,65 @@ async function doctor() {
|
|
|
1707
2001
|
ok('Orphan hooks: none (all hook targets present)');
|
|
1708
2002
|
}
|
|
1709
2003
|
|
|
2004
|
+
// MCP registration. This lives in doctor, not status: `claude mcp list` health-checks —
|
|
2005
|
+
// i.e. STARTS — every MCP server configured on the machine (2.546s wall for three servers,
|
|
2006
|
+
// measured 2026-09-08), which is a cost the deep check can carry and a status line cannot.
|
|
2007
|
+
//
|
|
2008
|
+
// What it buys beyond status: the DUPLICATE. The README's "Mixed-install residue" section
|
|
2009
|
+
// has warned since v3 that a plugin user who once ran the npx/git-clone installer keeps a
|
|
2010
|
+
// bare-name registration that double-registers the server — and nothing in the tool
|
|
2011
|
+
// detected it. Orphan hooks had a check; its MCP twin did not.
|
|
2012
|
+
try {
|
|
2013
|
+
const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8', timeout: 60000 });
|
|
2014
|
+
const bare = nonPluginMemRegistrations(list);
|
|
2015
|
+
// Registration, not directory — see pluginIsRegistered. Crediting a leftover cache dir
|
|
2016
|
+
// here told a working npm-channel install to delete its ONLY MCP registration.
|
|
2017
|
+
const viaPlugin =
|
|
2018
|
+
!!shape?.activePluginVersion && pluginIsRegistered({ home: homedir(), settings: readSettings() });
|
|
2019
|
+
if (viaPlugin && bare.length > 0) {
|
|
2020
|
+
dwarn(
|
|
2021
|
+
`MCP registration: the plugin manifest provides the server AND a bare "${bare.join('", "')}" registration exists — the server is registered twice`,
|
|
2022
|
+
);
|
|
2023
|
+
// No `-s` flag, deliberately: `nonPluginMemRegistrations`'s own docblock says `mcp list`
|
|
2024
|
+
// does not label scope, and this repo's tracked `.mcp.json` registers a bare `mem-lite`
|
|
2025
|
+
// at PROJECT scope, which `-s user` cannot remove. `claude mcp remove` without the flag
|
|
2026
|
+
// removes from whichever scope the entry is in. Every name, not just the first.
|
|
2027
|
+
for (const name of bare) log(` Fix: claude mcp remove ${name}`);
|
|
2028
|
+
} else if (viaPlugin) {
|
|
2029
|
+
ok('MCP registration: provided by the plugin manifest only (no duplicate)');
|
|
2030
|
+
} else if (bare.length > 0) {
|
|
2031
|
+
ok(`MCP registration: "${bare.join('", "')}" registered`);
|
|
2032
|
+
} else {
|
|
2033
|
+
dwarn('MCP registration: no claude-mem-lite MCP server is registered and no plugin provides one');
|
|
2034
|
+
}
|
|
2035
|
+
} catch (e) {
|
|
2036
|
+
// Third outcome, kept apart from "none found" on purpose: the `claude` CLI may not be on
|
|
2037
|
+
// PATH at all, and a green "no duplicate" would end the reader's search on a check that
|
|
2038
|
+
// never ran.
|
|
2039
|
+
dwarn(`MCP registration: could not run \`claude mcp list\` (${e.code || e.message}) — not checked`);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Marketplace clone updatability — see marketplaceCloneHealth for why this is the
|
|
2043
|
+
// precondition behind the schema-skew lock-in v6.3.0 shipped a detector for.
|
|
2044
|
+
const marketplaceClone = join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY);
|
|
2045
|
+
const clone = marketplaceCloneHealth(marketplaceClone);
|
|
2046
|
+
if (clone.kind === 'dirty') {
|
|
2047
|
+
dwarn(`Marketplace clone: ${clone.count} uncommitted change(s) in ${marketplaceClone}`);
|
|
2048
|
+
log(
|
|
2049
|
+
' Claude Code updates a git-source marketplace by pulling this clone, and a dirty tree blocks the pull —',
|
|
2050
|
+
);
|
|
2051
|
+
log(
|
|
2052
|
+
' the plugin then stops updating silently, which is how a machine ends up running code older than its DB.',
|
|
2053
|
+
);
|
|
2054
|
+
log(` Inspect: git -C ${marketplaceClone} status`);
|
|
2055
|
+
} else if (clone.kind === 'unknown') {
|
|
2056
|
+
dwarn(`Marketplace clone: could not check ${marketplaceClone} (${clone.reason}) — not checked`);
|
|
2057
|
+
} else if (clone.kind === 'clean') {
|
|
2058
|
+
ok('Marketplace clone: clean (the marketplace updater can fast-forward it)');
|
|
2059
|
+
}
|
|
2060
|
+
// 'absent' / 'not-git' are silent: an npm-channel or npx user has no marketplace clone,
|
|
2061
|
+
// and a check that reports on a thing you do not have is noise.
|
|
2062
|
+
|
|
1710
2063
|
// Database
|
|
1711
2064
|
if (existsSync(DB_PATH)) {
|
|
1712
2065
|
try {
|
|
@@ -1750,6 +2103,11 @@ async function doctor() {
|
|
|
1750
2103
|
}
|
|
1751
2104
|
} catch (e) {
|
|
1752
2105
|
fail('Database: ' + e.message);
|
|
2106
|
+
// Every other ✗ on this screen carries a remedy; this one used to be the exception,
|
|
2107
|
+
// and a corrupt store is the failure a user is least able to diagnose unaided.
|
|
2108
|
+
// dbCheckRemedy returns null rather than invent one for an error it cannot classify.
|
|
2109
|
+
const remedy = dbCheckRemedy(DB_PATH, e);
|
|
2110
|
+
if (remedy) log(` ${remedy}`);
|
|
1753
2111
|
issues++;
|
|
1754
2112
|
}
|
|
1755
2113
|
} else {
|
|
@@ -2356,6 +2714,41 @@ export function hasOtherMarketplacePlugins(
|
|
|
2356
2714
|
return Object.keys(plugins).some((key) => key !== pluginKey && key.endsWith(`@${marketplaceKey}`));
|
|
2357
2715
|
}
|
|
2358
2716
|
|
|
2717
|
+
/**
|
|
2718
|
+
* Whether Claude Code actually has this plugin INSTALLED — as opposed to a leftover version
|
|
2719
|
+
* directory sitting in its cache.
|
|
2720
|
+
*
|
|
2721
|
+
* `detectInstallShape`'s `activePluginVersion` is not that question. Its own comment calls its
|
|
2722
|
+
* third tier — the newest cache directory — "a guess, and after a rollback the wrong one", and
|
|
2723
|
+
* a terminal has neither of the first two tiers (`CLAUDE_PLUGIN_ROOT`, `installed_plugins.json`)
|
|
2724
|
+
* after `/plugin uninstall`. `/plugin uninstall` leaves the version dirs behind, which this
|
|
2725
|
+
* project's own README now documents — so "a cache directory exists" is true on machines that
|
|
2726
|
+
* have no plugin at all.
|
|
2727
|
+
*
|
|
2728
|
+
* Using it as "the plugin provides the MCP server" was measured to tell a working npm-channel
|
|
2729
|
+
* install that its server was registered twice, with a remedy that removes its ONLY
|
|
2730
|
+
* registration. Read from the two places that RECORD an installation instead.
|
|
2731
|
+
*
|
|
2732
|
+
* Deliberately NOT `!shape.managed`: a mixed install has both, and that is precisely the state
|
|
2733
|
+
* the duplicate check exists for. Over-narrowing here is safe by construction — the caller
|
|
2734
|
+
* falls back to asking `claude mcp list`, which is the pre-fix behaviour and correct.
|
|
2735
|
+
*
|
|
2736
|
+
* Exported for tests/mcp-registration-parse.test.mjs.
|
|
2737
|
+
*/
|
|
2738
|
+
export function pluginIsRegistered({ home = homedir(), settings = {} } = {}) {
|
|
2739
|
+
if (isPluginExplicitlyDisabled(settings)) return false;
|
|
2740
|
+
if (settings?.enabledPlugins?.[PLUGIN_KEY] === true) return true;
|
|
2741
|
+
try {
|
|
2742
|
+
const installed = JSON.parse(
|
|
2743
|
+
readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'),
|
|
2744
|
+
);
|
|
2745
|
+
return PLUGIN_KEY in getInstalledPluginEntries(installed);
|
|
2746
|
+
} catch {
|
|
2747
|
+
// Missing or unparseable registry: not evidence of an installation.
|
|
2748
|
+
return false;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2359
2752
|
/** Thrown when settings.json exists but is not parseable. Never a reason to write. */
|
|
2360
2753
|
class SettingsUnparseableError extends Error {}
|
|
2361
2754
|
|
|
@@ -2624,9 +3017,7 @@ async function repair() {
|
|
|
2624
3017
|
console.log(' Automatic repair fails closed rather than run unverified code.');
|
|
2625
3018
|
console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
|
|
2626
3019
|
console.log('');
|
|
2627
|
-
console.log(
|
|
2628
|
-
' T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install',
|
|
2629
|
-
);
|
|
3020
|
+
console.log(` ${MANUAL_TARBALL_FALLBACK}`);
|
|
2630
3021
|
console.log('');
|
|
2631
3022
|
process.exit(1);
|
|
2632
3023
|
} finally {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three location constants, in a leaf module.
|
|
3
|
+
*
|
|
4
|
+
* They lived in `schema.mjs`, which statically imports `better-sqlite3`. That made the
|
|
5
|
+
* native driver a LOAD-TIME dependency of anything that wanted a path — including
|
|
6
|
+
* `hook-update.mjs`, which `install.mjs::repair()` imports to reach the Ed25519-verified
|
|
7
|
+
* release path. Measured 2026-09-08: on a tree with no `node_modules`, that import threw
|
|
8
|
+
* `ERR_MODULE_NOT_FOUND` from `schema.mjs`, repair() caught it, refused to auto-install
|
|
9
|
+
* unverified code, and printed the unverified default-branch tarball instead. So the
|
|
10
|
+
* signature check was unreachable on the one install state the self-heal exists to fix.
|
|
11
|
+
*
|
|
12
|
+
* Keep this module free of package imports — `node:` builtins and `lib/resolve-data-dir.mjs`
|
|
13
|
+
* only. `tests/repair-path-no-native-dep.test.mjs` walks the static import graph from
|
|
14
|
+
* `hook-update.mjs` and fails on ANY package edge, so a future import here has to argue
|
|
15
|
+
* with a test rather than silently disarm the repair path.
|
|
16
|
+
*
|
|
17
|
+
* `schema.mjs` re-exports all three names, so every existing importer keeps working and
|
|
18
|
+
* this is not a contract change.
|
|
19
|
+
*/
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { resolveDataDir } from './resolve-data-dir.mjs';
|
|
23
|
+
|
|
24
|
+
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
25
|
+
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
26
|
+
export const DB_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
27
|
+
export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
|
|
28
|
+
// CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
|
|
29
|
+
// here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
|
|
30
|
+
// bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
|
|
31
|
+
// CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
|
|
32
|
+
// DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
|
|
33
|
+
export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|