claude-mem-lite 6.4.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.4.0",
12
+ "version": "6.5.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.4.0",
3
+ "version": "6.5.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-context.mjs CHANGED
@@ -585,7 +585,12 @@ export function buildSessionContextLines(
585
585
  } else if (!latestSummary && !effectiveQuiet()) {
586
586
  // Fallback: no summary AND no key observations — show recent activity.
587
587
  // Skipped under QUIET_HOOKS since the Recent table already carries titles.
588
- const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
588
+ // Slice FIRST, then sort: the slice is the selection (top 3 by value density) and must
589
+ // stay that way; only the order they are printed in is corrected, same as the Recent
590
+ // table below. Sorting before the slice would silently change WHICH three are injected.
591
+ const recentObs = (observations.length >= 3 ? observations : fallbackObs)
592
+ .slice(0, 3)
593
+ .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id);
589
594
  if (recentObs.length > 0) {
590
595
  summaryLines.push('### Recent Activity');
591
596
  for (const o of recentObs) {
@@ -695,8 +700,18 @@ export function buildSessionContextLines(
695
700
  }
696
701
 
697
702
  // 6. Recent observations table
703
+ //
704
+ // SELECTION order (greedy knapsack, value density) is not DISPLAY order. This block used
705
+ // to render the picks in the order the knapsack happened to take them, under a heading
706
+ // that says "Recent" next to a Time column — so row 1 was not the newest row, and both a
707
+ // human and the model read it as if it were. Sorting here is display-only: `obsToShow` is
708
+ // already chosen, so the token budget and the row set are untouched (pinned by a case in
709
+ // tests/hook-context.test.mjs). Tiebroken on id for the same reason D#9 gives — an
710
+ // untiebroken tie flips direction, and two saves in one millisecond are common.
698
711
  const obsLines = [];
699
- const obsToShow = observations.length >= 3 ? observations : fallbackObs;
712
+ const obsToShow = [...(observations.length >= 3 ? observations : fallbackObs)].sort(
713
+ (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id,
714
+ );
700
715
  if (obsToShow.length > 0) {
701
716
  const today = now.toISOString().slice(0, 10);
702
717
  obsLines.push(`### Recent (${today})`);
package/hook-shared.mjs CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  shouldRecordSkew,
29
29
  SKEW_MARKER_PREFIX,
30
30
  } from './lib/schema-skew.mjs';
31
+ import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
32
+ import { shouldRecordOnce } from './lib/record-once.mjs';
31
33
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
32
34
  // predicates and the handoff constants moved into `lib/` because two lib modules
33
35
  // imported them from here and dragged this file's whole import graph — haiku-client,
@@ -244,6 +246,9 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
244
246
  // v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
245
247
  'last-mark-compressible-', // per-project auto-compress 24h gate
246
248
  SKEW_MARKER_PREFIX, // per-project schema-skew log dedup; regenerated on the next skewed open
249
+ // Same shape, same reason, and it was missed here first time round — the note above is
250
+ // about exactly this defect, two entries up.
251
+ DB_UNUSABLE_MARKER_PREFIX, // per-project unopenable-DB log dedup; regenerated on the next failing open
247
252
  ]);
248
253
 
249
254
  // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
@@ -405,12 +410,28 @@ export function lastSchemaSkew() {
405
410
  return lastSkew;
406
411
  }
407
412
 
413
+ // Same idea, other unhealable family: the file exists and SQLite will not open it. Held as a
414
+ // boolean rather than the error, because the only thing SessionStart needs is "which notice",
415
+ // and keeping an Error alive here would tempt a caller into rendering a stack trace at a user.
416
+ let lastUnusable = false;
417
+
418
+ /**
419
+ * True when the most recent openDb() returned null because the database file is not a usable
420
+ * database. Cleared by any successful open, so a repair mid-session stops the notice.
421
+ *
422
+ * @returns {boolean}
423
+ */
424
+ export function lastDbUnusable() {
425
+ return lastUnusable;
426
+ }
427
+
408
428
  export function openDb() {
409
429
  try {
410
430
  // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
411
431
  // silently dead (null DB) on a corrupt WAL until the next MCP server start.
412
432
  const db = ensureDbWithWalRecovery();
413
433
  lastSkew = null;
434
+ lastUnusable = false;
414
435
  return db;
415
436
  } catch (e) {
416
437
  // Forward-incompat is its own family: it cannot be healed by anything this process can
@@ -423,10 +444,11 @@ export function openDb() {
423
444
  //
424
445
  // shouldRecordSkew is TOTAL by contract. Nothing in this catch may throw: the first cut
425
446
  // called getSessionId() here, which MINTS and writes a session id, so an unwritable
426
- // runtime dir turned openDb() itself into a thrower. All 13 call sites are written to
447
+ // runtime dir turned openDb() itself into a thrower. All 12 openDb() call sites in hook.mjs are written to
427
448
  // no-op on null and none of them expects an exception.
428
449
  if (isSchemaSkewError(e)) {
429
450
  lastSkew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
451
+ lastUnusable = false;
430
452
  // Guarded even though inferProject() reads env and cwd: "the only statement in this
431
453
  // catch cannot throw" was true of the original one-line body and stopped being true
432
454
  // the moment anything was added. An unscoped marker is a worse dedup, not a crash.
@@ -441,8 +463,31 @@ export function openDb() {
441
463
  }
442
464
  return null;
443
465
  }
444
- // Still null, still no throw a hook must never crash the host session, and all
445
- // eight call sites in hook.mjs are written to no-op on null. But "returned null"
466
+ // The OTHER unhealable family, and it was the silent one. A file that is not a database
467
+ // repeats on every fire exactly like a skew does, and until now took the generic branch
468
+ // below: one full stack trace per SessionStart fire (measured: 20 fires → 20 records, ~860 B
469
+ // each), no dedup, and no user-visible word anywhere in the session. Same treatment as skew
470
+ // — record once per project per hour, and hand SessionStart a flag to speak with.
471
+ //
472
+ // Nothing in this branch may throw: `isDbUnusableError` is a regex over a string and
473
+ // `shouldRecordOnce` is total by contract, which is exactly the property the first cut of
474
+ // the skew dedup lost by calling a function that WRITES.
475
+ if (isDbUnusableError(e)) {
476
+ lastUnusable = true;
477
+ lastSkew = null; // the two flags are a set: whichever family fired last is the true one
478
+ let project = '';
479
+ try {
480
+ project = inferProject();
481
+ } catch {
482
+ /* total: the marker degrades to one shared file */
483
+ }
484
+ if (shouldRecordOnce(RUNTIME_DIR, DB_UNUSABLE_MARKER_PREFIX, project, 'unusable')) {
485
+ recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
486
+ }
487
+ return null;
488
+ }
489
+ // Still null, still no throw — a hook must never crash the host session, and all 12
490
+ // openDb() call sites in hook.mjs are written to no-op on null. But "returned null"
446
491
  // used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
447
492
  // reported 0 and doctor printed "no recent silent hook breakage" while every
448
493
  // capture path was dead (audit B1, 2026-08-14 — the same blindness that hid the
package/hook.mjs CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  } from './hook-episode.mjs';
63
63
  // CODE_DIR, not DB_DIR: the schema-skew notice asks which CODE homes exist, and those are
64
64
  // always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
65
- import { DB_DIR, CODE_DIR } from './schema.mjs';
65
+ import { DB_DIR, DB_PATH, CODE_DIR } from './schema.mjs';
66
66
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
67
67
  import { entry as preCompactEntry } from './hook-precompact.mjs';
68
68
  import {
@@ -85,6 +85,7 @@ import {
85
85
  sweepOrphanEpisodeFiles,
86
86
  sweepStaleProjectMarkers,
87
87
  lastSchemaSkew,
88
+ lastDbUnusable,
88
89
  } from './hook-shared.mjs';
89
90
  import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
90
91
  import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
@@ -2354,6 +2355,33 @@ async function emitSchemaSkewNotice() {
2354
2355
  }
2355
2356
  }
2356
2357
 
2358
+ /**
2359
+ * The corruption twin of emitSchemaSkewNotice. Same channels, same reason they are BOTH used:
2360
+ * queueHookContext reaches the model, queueHookSystemMessage reaches the human, and a notice
2361
+ * whose whole job is handing the user a command must not depend on the assistant volunteering
2362
+ * it (lib/hook-stdout.mjs names v3.70.0 for exactly that mistake).
2363
+ *
2364
+ * Dynamically imported like its twin: a cold path must not cost the healthy SessionStart a
2365
+ * directory scan for backup snapshots.
2366
+ */
2367
+ async function emitDbUnusableNotice() {
2368
+ try {
2369
+ if (!lastDbUnusable()) return;
2370
+ const mod = await import('./lib/db-unusable.mjs');
2371
+ // TWO DIFFERENT STRINGS, unlike the skew twin, and the asymmetry is the point: the
2372
+ // human channel carries the repair command, the model channel carries only the fact.
2373
+ // See formatDbUnusableModelNotice — this family's remedy overwrites the database.
2374
+ queueHookSystemMessage(
2375
+ mod.formatDbUnusableNotice({ dbPath: DB_PATH, remedy: mod.dbUnusableRemedy(DB_PATH) }),
2376
+ );
2377
+ queueHookContext('SessionStart', mod.formatDbUnusableModelNotice());
2378
+ } catch (e) {
2379
+ // A hook must never crash the host session, and a notice that cannot render is better
2380
+ // handled by staying quiet than by taking SessionStart down with it.
2381
+ debugCatch(e, 'session-start-db-unusable');
2382
+ }
2383
+ }
2384
+
2357
2385
  async function handleSessionStart() {
2358
2386
  // GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
2359
2387
  // moved here from pre-tool-recall.js's hot path.
@@ -2542,7 +2570,13 @@ async function handleSessionStart() {
2542
2570
  // only other signal it produces is a `-32000 Connection closed` from the MCP host, which
2543
2571
  // names nothing. Measured 2026-09-08: a whole day of it, >=648 log lines, zero words to
2544
2572
  // the user. This is the surface the user actually reads.
2573
+ //
2574
+ // A database file that is not a database is the same shape and got the same treatment:
2575
+ // unhealable without the user, disables every path, and every OTHER surface (CLI exit 1,
2576
+ // `status`, `doctor` with an exact repair command) already reports it — leaving the
2577
+ // in-session one as the only silent one.
2545
2578
  await emitSchemaSkewNotice();
2579
+ await emitDbUnusableNotice();
2546
2580
  return;
2547
2581
  }
2548
2582
 
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';
@@ -70,9 +70,9 @@ import {
70
70
  nativeBindingRepairHint,
71
71
  isNativeBindingError,
72
72
  } from './lib/binding-probe.mjs';
73
- import { readSnapshots } from './lib/db-backup.mjs';
74
73
  import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
75
74
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
75
+ import { isDbUnusableError, dbUnusableRemedy } from './lib/db-unusable.mjs';
76
76
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
77
77
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
78
78
  import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
@@ -444,34 +444,22 @@ export function nonPluginMemRegistrations(listOutput) {
444
444
  */
445
445
  export function dbCheckRemedy(dbPath, err) {
446
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) {
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') {
460
455
  return (
461
456
  `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" ` +
457
+ `store is created on the next session: ${remedy.command} ` +
463
458
  `— memories in that file are not recoverable without a backup.`
464
459
  );
465
460
  }
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
461
  return (
473
- `Restore the newest of ${snap.snapshots.length} backup snapshot(s): ` +
474
- `${clear} && cp "${newest.path}" "${dbPath}" ` +
462
+ `Restore the newest of ${remedy.snapshotCount} backup snapshot(s): ${remedy.command} ` +
475
463
  `— move the broken file aside first if you want to keep it for inspection.`
476
464
  );
477
465
  }
@@ -655,6 +643,57 @@ function createCliSymlink() {
655
643
  }
656
644
  }
657
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
+
658
697
  function registerMcpServer() {
659
698
  // 3. Register MCP server (skip if plugin system already handles it)
660
699
  // Plugin MCP must stay at root .mcp.json so Claude Code registers plugin:*:mem-lite.
@@ -673,6 +712,12 @@ function registerMcpServer() {
673
712
  /* not installed via plugin system */
674
713
  }
675
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
+
676
721
  if (pluginHandlesMcp) {
677
722
  log('MCP server: plugin system handles registration (skipping global)');
678
723
  // Clean up stale global registrations (both legacy "mem" and current "mem-lite")
@@ -685,14 +730,13 @@ function registerMcpServer() {
685
730
  } else {
686
731
  log('Registering MCP server...');
687
732
  try {
688
- // Purge legacy "mem" and any pre-existing "mem-lite" before re-registering
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.
689
736
  for (const name of ['mem', 'mem-lite']) {
690
737
  try {
691
738
  execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' });
692
739
  } catch {}
693
- try {
694
- execFileSync('claude', ['mcp', 'remove', '-s', 'project', name], { stdio: 'pipe' });
695
- } catch {}
696
740
  }
697
741
  execFileSync(
698
742
  'claude',
@@ -1338,12 +1382,90 @@ async function uninstall() {
1338
1382
  }
1339
1383
  }
1340
1384
  } else {
1341
- log('Data preserved (use --purge to remove)');
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');
1342
1406
  }
1343
1407
 
1344
1408
  console.log('\n Done!\n');
1345
1409
  }
1346
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
+
1347
1469
  // ─── Cleanup Hooks ───────────────────────────────────────────────────────────
1348
1470
 
1349
1471
  async function cleanupHooks() {
@@ -0,0 +1,178 @@
1
+ // lib/db-unusable.mjs — the database file is not a usable database.
2
+ //
3
+ // The sibling of lib/schema-skew.mjs, and it exists for the same reason: the throw is
4
+ // correct, and everything downstream of it was missing. Measured 2026-09-08 in a sandboxed
5
+ // HOME with a corrupted header:
6
+ //
7
+ // • CLI (`search` / `recent` / `stats` / `get` / `save` / `browse` / `fts-check`) — all exit
8
+ // 1 with the SQLite message. Correct.
9
+ // • `status` — "⚠ Database: exists but check failed". Correct.
10
+ // • `doctor` — names the file and prints an exact repair command. Correct.
11
+ // • Hooks — `openDb()` returns null, `hook.mjs`'s `const db = openDb(); if (!db) return;`
12
+ // ends SessionStart with EMPTY stdout and EMPTY stderr, and every fire wrote another
13
+ // stack trace to runtime/hook-errors/. Nothing the user sees says memory is off.
14
+ //
15
+ // State that last figure carefully, because the first draft got it wrong twice over: it said
16
+ // "20 fires → 10 identical ~1.5 KB records", which was a MIXED fire set (only some events
17
+ // reach this path) reported as if it were 20 SessionStarts, and a size nobody measured.
18
+ // Re-measured over 20 SessionStart fires: 20 records, 859-873 bytes each — one per fire, and
19
+ // ~1.7x smaller than claimed. `recordHookError` caps the stack at 6 frames and the message at
20
+ // 500 chars, so the per-record size is bounded; the unbounded quantity is the COUNT.
21
+ //
22
+ // So the one surface the user is actually looking at during a session was the only silent
23
+ // one, on a condition that never heals by itself. That is the schema-skew shape exactly, and
24
+ // this module is deliberately its mirror image so the two cannot drift apart.
25
+ //
26
+ // It imports no native binding: the static graph is 10 modules and zero package edges (the
27
+ // only bare specifiers anywhere in it are the `node:` builtins). That is the property that
28
+ // matters — a classifier the hook path may need while the binding is the broken thing must
29
+ // not sit behind a native import. It is NOT as tight as schema-skew.mjs, which imports
30
+ // `node:` builtins and nothing else; this one reaches `db-backup → utils → …` for
31
+ // `readSnapshots`. Same guarantee, larger graph — do not restate it as "same discipline".
32
+
33
+ import { basename, dirname } from 'node:path';
34
+ import { readSnapshots } from './db-backup.mjs';
35
+
36
+ /** Marker prefix for lib/record-once.mjs. Per project, like the skew one. */
37
+ export const DB_UNUSABLE_MARKER_PREFIX = '.db-unusable-logged-';
38
+
39
+ // SQLite's own spellings for "this file is not a usable database". Kept here as the single
40
+ // definition so install.mjs's doctor check and the hook path classify identically — they used
41
+ // to hold one copy each, which is this repo's named twin-drift class.
42
+ //
43
+ // Deliberately NOT matching "unable to open database file": that is a PERMISSIONS or
44
+ // missing-directory failure, whose remedy is nothing like the two below, and answering it
45
+ // with "move the file aside" would tell a user to destroy a healthy store.
46
+ const UNUSABLE_RE = /not a database|disk image is malformed/i;
47
+
48
+ /**
49
+ * Whether an error is a damaged FTS5 INDEX rather than a damaged database file.
50
+ *
51
+ * THE MESSAGE CANNOT TELL THEM APART. SQLite reports a damaged index as
52
+ * SQLITE_CORRUPT_VTAB with the same "database disk image is malformed" text the file-level
53
+ * faults use, so the code is the only discriminator — and matching on message alone is what
54
+ * conflated them before R10 P3-9. The remedy is rebuildFTS, which the FTS content lets us do
55
+ * losslessly; treating it as file corruption would offer `cp <old snapshot> <db>` over a
56
+ * database whose rows are all intact.
57
+ *
58
+ * This lives HERE rather than in schema.mjs (which re-exports it, so every existing importer
59
+ * is unchanged) because both consumers of the distinction now need it and schema.mjs imports
60
+ * better-sqlite3 — a classifier the hook path may need while the binding is the broken thing
61
+ * must not be behind a native import.
62
+ *
63
+ * @param {unknown} err
64
+ * @returns {boolean}
65
+ */
66
+ export function isFtsCorruptionError(err) {
67
+ return /SQLITE_CORRUPT_VTAB/i.test(`${err?.code || ''}`);
68
+ }
69
+
70
+ /**
71
+ * True when `err` means "this file exists and SQLite cannot use it as a database".
72
+ *
73
+ * Accepts anything thrown (Error, string, null) because recordHookError does.
74
+ *
75
+ * @param {unknown} err
76
+ * @returns {boolean}
77
+ */
78
+ export function isDbUnusableError(err) {
79
+ if (!err) return false;
80
+ // FIRST, and for the same reason isDbCorruptionError does it first: a damaged index is a
81
+ // HEALTHY FILE, and every remedy below is wrong — and destructive — for one.
82
+ if (isFtsCorruptionError(err)) return false;
83
+ return UNUSABLE_RE.test(String(err.message ?? err ?? ''));
84
+ }
85
+
86
+ /**
87
+ * What the user should actually run. Three outcomes, never two — the same rule schema-skew.mjs
88
+ * states: "there is no backup" and "I could not look for one" must not print in the same
89
+ * voice, because a green-sounding line ends the reader's search.
90
+ *
91
+ * `snapshotCount` is part of the return rather than something a caller re-derives: doctor's
92
+ * sentence names it ("Restore the newest of 2 backup snapshot(s)"), and a second
93
+ * `readSnapshots` call to recover a number this function already had would be a second
94
+ * directory read that can disagree with the first.
95
+ *
96
+ * @param {string} dbPath
97
+ * @returns {{kind: 'restore'|'set-aside'|'unknown', command: string, note: string, snapshotCount: number}}
98
+ */
99
+ export function dbUnusableRemedy(dbPath) {
100
+ const clear = `rm -f "${dbPath}-wal" "${dbPath}-shm"`;
101
+ const snap = readSnapshots(dbPath);
102
+ if (!snap.ok) {
103
+ return {
104
+ kind: 'unknown',
105
+ command: '',
106
+ snapshotCount: 0,
107
+ note:
108
+ `Could not read ${dirname(dbPath)} to look for a backup snapshot (${snap.reason}) — ` +
109
+ `fix that directory first, then look for ${basename(dbPath)}.*.bak beside the database.`,
110
+ };
111
+ }
112
+ if (snap.snapshots.length === 0) {
113
+ return {
114
+ kind: 'set-aside',
115
+ command: `${clear} && mv "${dbPath}" "${dbPath}.corrupt"`,
116
+ snapshotCount: 0,
117
+ note:
118
+ 'No backup snapshot exists beside the database. That command sets the broken file ' +
119
+ 'aside so a fresh store is created on the next session — memories in it are not ' +
120
+ 'recoverable without a backup.',
121
+ };
122
+ }
123
+ // Newest by mtime, ties broken by name (which carries an ISO stamp) so the answer is total
124
+ // rather than dependent on readdir order — the D#9 shape.
125
+ const newest = snap.snapshots
126
+ .slice()
127
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? 1 : -1))[0];
128
+ return {
129
+ kind: 'restore',
130
+ command: `${clear} && cp "${newest.path}" "${dbPath}"`,
131
+ snapshotCount: snap.snapshots.length,
132
+ note:
133
+ `Restores the newest of ${snap.snapshots.length} backup snapshot(s). Move the broken ` +
134
+ 'file aside first if you want to keep it for inspection.',
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The user-facing block. Short on purpose — at SessionStart it shares one stdout envelope
140
+ * with the startup dashboard and the `<claude-mem-context>` block.
141
+ *
142
+ * @param {{dbPath: string, remedy: ReturnType<typeof dbUnusableRemedy>}} info
143
+ * @returns {string}
144
+ */
145
+ export function formatDbUnusableNotice({ dbPath, remedy }) {
146
+ const lines = [
147
+ '⚠️ [claude-mem-lite] Memory is OFF: the database file cannot be opened.',
148
+ ` ${dbPath} exists but SQLite does not recognise it as a database.`,
149
+ ];
150
+ if (remedy.command) lines.push(` ${remedy.command}`);
151
+ if (remedy.note) lines.push(` ${remedy.note}`);
152
+ lines.push(' Until then, saves and recall are disabled. `claude-mem-lite doctor` re-checks.');
153
+ return lines.join('\n');
154
+ }
155
+
156
+ /**
157
+ * The same fact, with NO COMMAND IN IT, for the model channel.
158
+ *
159
+ * The schema-skew twin sends one string to both channels and that is safe there — its
160
+ * commands are `git pull` / `claude plugin update` / `npm i -g`. This family's remedy is
161
+ * `rm -f …-wal …-shm && cp "<snapshot>" "<db>"`, which OVERWRITES the database, and handing
162
+ * a ready-to-run irreversible shell line to an agent that has Bash — with no addressee and no
163
+ * "ask first" — is a different proposition. Worse, the restore arm defeats its own advice: a
164
+ * reader who acts on the command line destroys the broken file the next line tells them to
165
+ * keep for inspection.
166
+ *
167
+ * So the human gets the command and the model gets the situation. Both still learn memory is
168
+ * off, which is the point of using both channels at all.
169
+ *
170
+ * @returns {string}
171
+ */
172
+ export function formatDbUnusableModelNotice() {
173
+ return [
174
+ '⚠️ [claude-mem-lite] Memory is OFF for this session: the database file is unreadable.',
175
+ ' Saves and recall are disabled. Do not attempt to repair it yourself — tell the user to',
176
+ ' run `claude-mem-lite doctor`, which prints the exact command for their machine.',
177
+ ].join('\n');
178
+ }