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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +17 -2
- package/hook-shared.mjs +48 -3
- package/hook.mjs +35 -1
- package/install.mjs +151 -29
- package/lib/db-unusable.mjs +178 -0
- 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 +3 -1
- package/schema.mjs +5 -10
- package/server.mjs +2 -0
- package/source-files.mjs +2 -0
- package/tool-schemas.mjs +62 -48
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
445
|
-
//
|
|
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
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
454
|
-
|
|
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: ${
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|