claude-mem-lite 6.2.0 → 6.4.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/install.mjs CHANGED
@@ -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,8 +68,11 @@ import {
68
68
  probeBetterSqlite3Binding,
69
69
  ensureBetterSqlite3Working,
70
70
  nativeBindingRepairHint,
71
+ isNativeBindingError,
71
72
  } from './lib/binding-probe.mjs';
73
+ import { readSnapshots } from './lib/db-backup.mjs';
72
74
  import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
75
+ import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
73
76
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
74
77
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
75
78
  import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
@@ -332,6 +335,147 @@ function isDevInstall() {
332
335
  }
333
336
  }
334
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
+ const msg = String(err?.message ?? err ?? '');
448
+ // SQLite's own spellings for "this file is not a usable database".
449
+ if (!/not a database|disk image is malformed|file is not a database/i.test(msg)) return null;
450
+
451
+ const clear = `rm -f "${dbPath}-wal" "${dbPath}-shm"`;
452
+ const snap = readSnapshots(dbPath);
453
+ if (!snap.ok) {
454
+ return (
455
+ `Could not read ${dirname(dbPath)} to look for a backup snapshot (${snap.reason}) — ` +
456
+ `fix that directory first, then look for ${basename(dbPath)}.*.bak beside the database.`
457
+ );
458
+ }
459
+ if (snap.snapshots.length === 0) {
460
+ return (
461
+ `No backup snapshot exists beside the database. Set the broken file aside so a fresh ` +
462
+ `store is created on the next session: ${clear} && mv "${dbPath}" "${dbPath}.corrupt" ` +
463
+ `— memories in that file are not recoverable without a backup.`
464
+ );
465
+ }
466
+ // Newest by mtime. Ties are broken by name, which carries an ISO stamp, so the answer is
467
+ // total rather than dependent on which of two same-millisecond files readdir returned
468
+ // first (the D#9 shape).
469
+ const newest = snap.snapshots
470
+ .slice()
471
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? 1 : -1))[0];
472
+ return (
473
+ `Restore the newest of ${snap.snapshots.length} backup snapshot(s): ` +
474
+ `${clear} && cp "${newest.path}" "${dbPath}" ` +
475
+ `— move the broken file aside first if you want to keep it for inspection.`
476
+ );
477
+ }
478
+
335
479
  // ─── Install ────────────────────────────────────────────────────────────────
336
480
 
337
481
  // Dynamic-import helpers, resolved against the installed copy at INSTALL_DIR
@@ -1135,11 +1279,23 @@ async function uninstall() {
1135
1279
  ok('Marketplace directory removed');
1136
1280
  }
1137
1281
 
1138
- // 5b. Remove cache directory
1282
+ // 5b. Remove cache directories — OURS unconditionally, the marketplace-wide one gated.
1283
+ //
1284
+ // The gate exists so uninstalling this plugin does not delete a sibling plugin published
1285
+ // under the same marketplace. That reasoning covers `cache/<marketplace>/`; it does not
1286
+ // cover `cache/<marketplace>/claude-mem-lite/`, which is ours alone. Because only the
1287
+ // gated branch existed, a user with any other sdsrss plugin installed kept every cached
1288
+ // version of THIS one — measured at 241 MB on a machine where `/plugin uninstall` had
1289
+ // already removed the manifest, i.e. bytes belonging to a plugin that was gone.
1290
+ const ownCacheDir = join(pluginsDir, 'cache', marketplaceKey, PLUGIN_NAME);
1291
+ if (existsSync(ownCacheDir)) {
1292
+ rmSync(ownCacheDir, { recursive: true, force: true });
1293
+ ok('Plugin cache removed');
1294
+ }
1139
1295
  const cacheDir = join(pluginsDir, 'cache', marketplaceKey);
1140
1296
  if (canRemoveMarketplaceArtifacts && existsSync(cacheDir)) {
1141
1297
  rmSync(cacheDir, { recursive: true, force: true });
1142
- ok('Plugin cache removed');
1298
+ ok('Marketplace cache directory removed');
1143
1299
  }
1144
1300
 
1145
1301
  // 5c. Clean known_marketplaces.json
@@ -1223,35 +1379,52 @@ async function status() {
1223
1379
  // configured` at a correctly-installed plugin user — two red marks describing
1224
1380
  // the intended state.
1225
1381
  const shape = detectInstallShape({ home: homedir(), projectDir: PROJECT_DIR, installDir: INSTALL_DIR });
1226
- const pluginProvides = !!shape.activePluginVersion;
1227
-
1228
- // MCP
1229
- try {
1230
- const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
1231
- // Accept either the current "mem-lite" registration or the legacy "mem"
1232
- // name (pre-v2.78) so a user mid-upgrade still sees a green status until
1233
- // setup.sh / install.mjs purges the legacy entry on next run.
1234
- // v2.79.1: dropped a `/\bmem\b\s/` fallback regex — the `\b` word boundary
1235
- // also matched "mem-lite" (because `-` is a non-word char), so the regex
1236
- // was always-true noise (benign only because the mem-lite checks short-
1237
- // circuited first). `claude mcp list` formats as `<name>: <command>`, so
1238
- // the two colon-form checks below cover every shape.
1239
- const registered = list.includes('mem-lite:') || list.includes('mem:');
1240
- if (registered) {
1241
- push('ok', 'mcp', 'MCP server: registered', { registered });
1242
- } else if (pluginProvides) {
1243
- push(
1244
- 'ok',
1245
- 'mcp',
1246
- `MCP server: provided by the plugin manifest (v${shape.activePluginVersion.version} .mcp.json) — no user-scope registration expected`,
1247
- { registered: false, via: 'plugin' },
1248
- );
1249
- } else {
1250
- push('fail', 'mcp', 'MCP server: not registered', { registered });
1382
+ // A cache DIRECTORY is not an installed plugin — `/plugin uninstall` leaves version dirs
1383
+ // behind (this project's own README documents that), and `activePluginVersion` falls back to
1384
+ // "newest cache dir" when nothing recorded an install. Both branches below credit the
1385
+ // manifest with providing something, so both need the registration, not the directory.
1386
+ const pluginProvides =
1387
+ !!shape.activePluginVersion && pluginIsRegistered({ home: homedir(), settings: readSettings() });
1388
+
1389
+ // MCP. A plugin install answers this from the manifest and does NOT shell out.
1390
+ //
1391
+ // Two reasons, and the first is correctness rather than speed. `claude mcp list` prints a
1392
+ // plugin server as `plugin:claude-mem-lite:mem-lite: …`, and the old substring test
1393
+ // `list.includes('mem-lite:')` matched INSIDE that name — so a plugin user was reported as
1394
+ // having a user-scope registration they do not have, and the branch written for them below
1395
+ // was unreachable. That is the same accidental-match class as the `\bmem\b` regex this
1396
+ // comment block used to describe. Second: the official help says approved servers are
1397
+ // "health-checked", i.e. the call STARTS every MCP server configured on the machine —
1398
+ // measured 2026-09-08 at 2.546s wall for three servers, one of them a remote HTTP endpoint.
1399
+ // A status command should not pay that, and a plugin user gains nothing from it.
1400
+ //
1401
+ // `doctor` GAINS the exec instead (it had none before this change) and runs it
1402
+ // unconditionally: it is the deep check, and it is where the duplicate/legacy registration
1403
+ // the README's "Mixed-install residue" section describes now gets detected — nothing
1404
+ // detected it before. That means `doctor` now health-checks every MCP server on the
1405
+ // machine; both READMEs say so under their `doctor` sections.
1406
+ if (pluginProvides) {
1407
+ push(
1408
+ 'ok',
1409
+ 'mcp',
1410
+ `MCP server: provided by the plugin manifest (v${shape.activePluginVersion.version} .mcp.json) — no user-scope registration expected`,
1411
+ { registered: false, via: 'plugin' },
1412
+ );
1413
+ } else
1414
+ try {
1415
+ const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8', timeout: 60000 });
1416
+ // Accept either the current "mem-lite" registration or the legacy "mem" name
1417
+ // (pre-v2.78) so a user mid-upgrade still sees a green status until setup.sh /
1418
+ // install.mjs purges the legacy entry on next run.
1419
+ const registered = nonPluginMemRegistrations(list).length > 0;
1420
+ if (registered) {
1421
+ push('ok', 'mcp', 'MCP server: registered', { registered });
1422
+ } else {
1423
+ push('fail', 'mcp', 'MCP server: not registered', { registered });
1424
+ }
1425
+ } catch {
1426
+ push('warn', 'mcp', 'Could not check MCP status', { registered: null });
1251
1427
  }
1252
- } catch {
1253
- push('warn', 'mcp', 'Could not check MCP status', { registered: null });
1254
- }
1255
1428
 
1256
1429
  // Hooks
1257
1430
  const settings = readSettings();
@@ -1479,6 +1652,73 @@ async function doctor() {
1479
1652
  }
1480
1653
  }
1481
1654
 
1655
+ // Can each code home actually OPEN this database? A binding that loads is not the same
1656
+ // question: better-sqlite3 can be perfect and the store still unreadable, because
1657
+ // schema.mjs refuses a DB written by a newer claude-mem-lite (correctly — replaying old
1658
+ // migrations over a newer layout would corrupt it). That is a one-way ratchet, and on a
1659
+ // plugin install it is REACHED ROUTINELY: the cache only advances when Claude Code's
1660
+ // marketplace updater advances it, so anything else that opens the DB — an npm-global
1661
+ // CLI, a dev checkout — can leave the cache locked out. Measured 2026-09-08: DB v49 vs a
1662
+ // live 5.6.0 cache supporting v48, >=648 identical hook errors in one day, and the only
1663
+ // user-visible signal was `-32000 Connection closed` from the MCP host.
1664
+ //
1665
+ // Probed per root, out of process, exactly like the binding check above and for the same
1666
+ // reason: this is the check that has to survive answering the question, and importing
1667
+ // another tree's schema.mjs would poison the process that must report the answer. It is
1668
+ // also why this check is USEFUL TODAY rather than only after the next upgrade — doctor
1669
+ // runs from whichever tree the user invoked, so new code here can diagnose an old cache.
1670
+ if (!existsSync(DB_PATH)) {
1671
+ ok('DB schema: no database yet — nothing to compare');
1672
+ } else if (rootProbes.length === 0) {
1673
+ // The fourth outcome the first cut had and did not print. The `fail` above already tells
1674
+ // the reader no install owns a binding, but a block whose stated design point is "three
1675
+ // outcomes, never two" must not answer a fourth case with silence.
1676
+ dwarn('DB schema: not checked — no install on this machine owns a native binding to read it with');
1677
+ } else {
1678
+ const compat = probeSchemaCompat(shape.runtimeRoots, DB_PATH);
1679
+ const behind = compat.filter((c) => c.status === 'skew');
1680
+ const unknown = compat.filter((c) => c.status === 'unknown');
1681
+ if (behind.length === 0 && unknown.length === 0) {
1682
+ ok(`DB schema: v${compat[0]?.dbVersion} — readable by all ${compat.length} install(s)`);
1683
+ }
1684
+ if (behind.length > 0) {
1685
+ // Dynamic: only a skewed machine pays for it, and it reuses hook-update's isDevMode
1686
+ // rather than re-deriving "is this a checkout", which that file has already had to
1687
+ // correct twice (whole-dir symlink, then per-file drift).
1688
+ let dev = false;
1689
+ try {
1690
+ const { isDevMode } = await import('./hook-update.mjs');
1691
+ dev = isDevMode();
1692
+ } catch {
1693
+ /* unreadable → the initialiser stands: a non-dev install gets the common remedy */
1694
+ }
1695
+ for (const b of behind) {
1696
+ // PER ROOT, inside the loop. Computing one remedy for every skewed tree printed the
1697
+ // machine's global answer beneath a label naming a different tree — on a mixed
1698
+ // managed+plugin install that meant `self-update` under "plugin cache v5.6.0",
1699
+ // which advances nothing. b.root is the tree that is actually behind.
1700
+ const remedy = schemaSkewRemedy({
1701
+ managed: shape.managed,
1702
+ activePluginVersion: shape.activePluginVersion,
1703
+ dev,
1704
+ root: b.root,
1705
+ });
1706
+ // fail, not warn: every write path is dead in this state and only the user can fix it.
1707
+ fail(`DB schema v${b.dbVersion} is newer than ${b.label}, which supports up to v${b.supported}`);
1708
+ for (const c of remedy.commands) log(` ${c}`);
1709
+ if (remedy.note) log(` ${remedy.note}`);
1710
+ issues++;
1711
+ }
1712
+ }
1713
+ for (const u of unknown) {
1714
+ // Deliberately its own outcome. "I could not determine what this install supports"
1715
+ // printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
1716
+ // caught before the tag — a check that says "nothing to check" and "I could not look"
1717
+ // in the same voice ends the reader's search instead of directing it.
1718
+ dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
1719
+ }
1720
+ }
1721
+
1482
1722
  try {
1483
1723
  await import('@modelcontextprotocol/sdk/server/mcp.js');
1484
1724
  ok('@modelcontextprotocol/sdk: verified (import OK)');
@@ -1639,6 +1879,65 @@ async function doctor() {
1639
1879
  ok('Orphan hooks: none (all hook targets present)');
1640
1880
  }
1641
1881
 
1882
+ // MCP registration. This lives in doctor, not status: `claude mcp list` health-checks —
1883
+ // i.e. STARTS — every MCP server configured on the machine (2.546s wall for three servers,
1884
+ // measured 2026-09-08), which is a cost the deep check can carry and a status line cannot.
1885
+ //
1886
+ // What it buys beyond status: the DUPLICATE. The README's "Mixed-install residue" section
1887
+ // has warned since v3 that a plugin user who once ran the npx/git-clone installer keeps a
1888
+ // bare-name registration that double-registers the server — and nothing in the tool
1889
+ // detected it. Orphan hooks had a check; its MCP twin did not.
1890
+ try {
1891
+ const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8', timeout: 60000 });
1892
+ const bare = nonPluginMemRegistrations(list);
1893
+ // Registration, not directory — see pluginIsRegistered. Crediting a leftover cache dir
1894
+ // here told a working npm-channel install to delete its ONLY MCP registration.
1895
+ const viaPlugin =
1896
+ !!shape?.activePluginVersion && pluginIsRegistered({ home: homedir(), settings: readSettings() });
1897
+ if (viaPlugin && bare.length > 0) {
1898
+ dwarn(
1899
+ `MCP registration: the plugin manifest provides the server AND a bare "${bare.join('", "')}" registration exists — the server is registered twice`,
1900
+ );
1901
+ // No `-s` flag, deliberately: `nonPluginMemRegistrations`'s own docblock says `mcp list`
1902
+ // does not label scope, and this repo's tracked `.mcp.json` registers a bare `mem-lite`
1903
+ // at PROJECT scope, which `-s user` cannot remove. `claude mcp remove` without the flag
1904
+ // removes from whichever scope the entry is in. Every name, not just the first.
1905
+ for (const name of bare) log(` Fix: claude mcp remove ${name}`);
1906
+ } else if (viaPlugin) {
1907
+ ok('MCP registration: provided by the plugin manifest only (no duplicate)');
1908
+ } else if (bare.length > 0) {
1909
+ ok(`MCP registration: "${bare.join('", "')}" registered`);
1910
+ } else {
1911
+ dwarn('MCP registration: no claude-mem-lite MCP server is registered and no plugin provides one');
1912
+ }
1913
+ } catch (e) {
1914
+ // Third outcome, kept apart from "none found" on purpose: the `claude` CLI may not be on
1915
+ // PATH at all, and a green "no duplicate" would end the reader's search on a check that
1916
+ // never ran.
1917
+ dwarn(`MCP registration: could not run \`claude mcp list\` (${e.code || e.message}) — not checked`);
1918
+ }
1919
+
1920
+ // Marketplace clone updatability — see marketplaceCloneHealth for why this is the
1921
+ // precondition behind the schema-skew lock-in v6.3.0 shipped a detector for.
1922
+ const marketplaceClone = join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY);
1923
+ const clone = marketplaceCloneHealth(marketplaceClone);
1924
+ if (clone.kind === 'dirty') {
1925
+ dwarn(`Marketplace clone: ${clone.count} uncommitted change(s) in ${marketplaceClone}`);
1926
+ log(
1927
+ ' Claude Code updates a git-source marketplace by pulling this clone, and a dirty tree blocks the pull —',
1928
+ );
1929
+ log(
1930
+ ' the plugin then stops updating silently, which is how a machine ends up running code older than its DB.',
1931
+ );
1932
+ log(` Inspect: git -C ${marketplaceClone} status`);
1933
+ } else if (clone.kind === 'unknown') {
1934
+ dwarn(`Marketplace clone: could not check ${marketplaceClone} (${clone.reason}) — not checked`);
1935
+ } else if (clone.kind === 'clean') {
1936
+ ok('Marketplace clone: clean (the marketplace updater can fast-forward it)');
1937
+ }
1938
+ // 'absent' / 'not-git' are silent: an npm-channel or npx user has no marketplace clone,
1939
+ // and a check that reports on a thing you do not have is noise.
1940
+
1642
1941
  // Database
1643
1942
  if (existsSync(DB_PATH)) {
1644
1943
  try {
@@ -1682,6 +1981,11 @@ async function doctor() {
1682
1981
  }
1683
1982
  } catch (e) {
1684
1983
  fail('Database: ' + e.message);
1984
+ // Every other ✗ on this screen carries a remedy; this one used to be the exception,
1985
+ // and a corrupt store is the failure a user is least able to diagnose unaided.
1986
+ // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
1987
+ const remedy = dbCheckRemedy(DB_PATH, e);
1988
+ if (remedy) log(` ${remedy}`);
1685
1989
  issues++;
1686
1990
  }
1687
1991
  } else {
@@ -2288,6 +2592,41 @@ export function hasOtherMarketplacePlugins(
2288
2592
  return Object.keys(plugins).some((key) => key !== pluginKey && key.endsWith(`@${marketplaceKey}`));
2289
2593
  }
2290
2594
 
2595
+ /**
2596
+ * Whether Claude Code actually has this plugin INSTALLED — as opposed to a leftover version
2597
+ * directory sitting in its cache.
2598
+ *
2599
+ * `detectInstallShape`'s `activePluginVersion` is not that question. Its own comment calls its
2600
+ * third tier — the newest cache directory — "a guess, and after a rollback the wrong one", and
2601
+ * a terminal has neither of the first two tiers (`CLAUDE_PLUGIN_ROOT`, `installed_plugins.json`)
2602
+ * after `/plugin uninstall`. `/plugin uninstall` leaves the version dirs behind, which this
2603
+ * project's own README now documents — so "a cache directory exists" is true on machines that
2604
+ * have no plugin at all.
2605
+ *
2606
+ * Using it as "the plugin provides the MCP server" was measured to tell a working npm-channel
2607
+ * install that its server was registered twice, with a remedy that removes its ONLY
2608
+ * registration. Read from the two places that RECORD an installation instead.
2609
+ *
2610
+ * Deliberately NOT `!shape.managed`: a mixed install has both, and that is precisely the state
2611
+ * the duplicate check exists for. Over-narrowing here is safe by construction — the caller
2612
+ * falls back to asking `claude mcp list`, which is the pre-fix behaviour and correct.
2613
+ *
2614
+ * Exported for tests/mcp-registration-parse.test.mjs.
2615
+ */
2616
+ export function pluginIsRegistered({ home = homedir(), settings = {} } = {}) {
2617
+ if (isPluginExplicitlyDisabled(settings)) return false;
2618
+ if (settings?.enabledPlugins?.[PLUGIN_KEY] === true) return true;
2619
+ try {
2620
+ const installed = JSON.parse(
2621
+ readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'),
2622
+ );
2623
+ return PLUGIN_KEY in getInstalledPluginEntries(installed);
2624
+ } catch {
2625
+ // Missing or unparseable registry: not evidence of an installation.
2626
+ return false;
2627
+ }
2628
+ }
2629
+
2291
2630
  /** Thrown when settings.json exists but is not parseable. Never a reason to write. */
2292
2631
  class SettingsUnparseableError extends Error {}
2293
2632
 
@@ -2556,9 +2895,7 @@ async function repair() {
2556
2895
  console.log(' Automatic repair fails closed rather than run unverified code.');
2557
2896
  console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
2558
2897
  console.log('');
2559
- console.log(
2560
- ' 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',
2561
- );
2898
+ console.log(` ${MANUAL_TARBALL_FALLBACK}`);
2562
2899
  console.log('');
2563
2900
  process.exit(1);
2564
2901
  } 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');
package/lib/db-backup.mjs CHANGED
@@ -43,26 +43,51 @@ export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
43
43
  // user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
44
44
  const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
45
45
 
46
- /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
47
- export function listSnapshots(dbPath) {
46
+ /**
47
+ * Snapshots for `dbPath`, keeping "there are none" and "I could not look" APART.
48
+ *
49
+ * `listSnapshots` below collapses both into `[]`, which is fine for its callers (a budget
50
+ * sweep has nothing to do either way) and wrong for anything that REPORTS to a human: a
51
+ * diagnostic that says "no backup exists" when it actually could not read the directory
52
+ * ends the reader's search with a false fact. Same rule the doctor bash-hook check
53
+ * settled on — "nothing to check" and "I could not look" are different answers.
54
+ *
55
+ * @returns {{ok: true, snapshots: Array<{path:string,size:number,mtimeMs:number}>}
56
+ * | {ok: false, reason: string}}
57
+ */
58
+ export function readSnapshots(dbPath) {
59
+ let names;
60
+ let dir;
61
+ let prefix;
62
+ // dirname/basename inside the try, not outside it: `listSnapshots` below has always been
63
+ // total (it returned [] for any input), and one of this function's callers runs inside
64
+ // `doctor`'s DB catch block, where the repo's openDb precedent says nothing may throw.
65
+ // Computing the path outside would make listSnapshots(undefined) a TypeError.
48
66
  try {
49
- const dir = dirname(dbPath);
50
- const prefix = `${basename(dbPath)}.`;
51
- const out = [];
52
- for (const n of readdirSync(dir)) {
53
- if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
54
- const full = join(dir, n);
55
- try {
56
- const st = statSync(full);
57
- out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
58
- } catch {
59
- /* raced away */
60
- }
67
+ dir = dirname(dbPath);
68
+ prefix = `${basename(dbPath)}.`;
69
+ names = readdirSync(dir);
70
+ } catch (e) {
71
+ return { ok: false, reason: e?.code || e?.message || 'unreadable' };
72
+ }
73
+ const snapshots = [];
74
+ for (const n of names) {
75
+ if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
76
+ const full = join(dir, n);
77
+ try {
78
+ const st = statSync(full);
79
+ snapshots.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
80
+ } catch {
81
+ /* raced away */
61
82
  }
62
- return out;
63
- } catch {
64
- return [];
65
83
  }
84
+ return { ok: true, snapshots };
85
+ }
86
+
87
+ /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
88
+ export function listSnapshots(dbPath) {
89
+ const r = readSnapshots(dbPath);
90
+ return r.ok ? r.snapshots : [];
66
91
  }
67
92
 
68
93
  /**
@@ -24,8 +24,11 @@
24
24
  /** Marketplace this plugin is published under. */
25
25
  export const MARKETPLACE_KEY = 'sdsrss';
26
26
 
27
+ /** This plugin's own name — the directory Claude Code materializes versions under. */
28
+ export const PLUGIN_NAME = 'claude-mem-lite';
29
+
27
30
  /** The key Claude Code writes under `enabledPlugins` in `~/.claude/settings.json`. */
28
- export const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
31
+ export const PLUGIN_KEY = `${PLUGIN_NAME}@${MARKETPLACE_KEY}`;
29
32
 
30
33
  /**
31
34
  * Whether the user has EXPLICITLY switched the plugin off.