claude-mem-lite 6.2.0 → 6.3.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-shared.mjs +54 -1
- package/hook-update.mjs +5 -1
- package/hook.mjs +75 -2
- package/install.mjs +68 -0
- package/lib/schema-skew.mjs +379 -0
- package/mem-cli.mjs +42 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/schema.mjs +16 -1
- package/scripts/launch.mjs +72 -14
- package/scripts/user-prompt-search.js +20 -0
- package/source-files.mjs +1 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "6.
|
|
13
|
+
"version": "6.3.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.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-shared.mjs
CHANGED
|
@@ -22,6 +22,12 @@ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
|
22
22
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
23
23
|
// neither imports this file) — no cycle.
|
|
24
24
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
25
|
+
import {
|
|
26
|
+
isSchemaSkewError,
|
|
27
|
+
schemaSkewFromError,
|
|
28
|
+
shouldRecordSkew,
|
|
29
|
+
SKEW_MARKER_PREFIX,
|
|
30
|
+
} from './lib/schema-skew.mjs';
|
|
25
31
|
// Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
|
|
26
32
|
// predicates and the handoff constants moved into `lib/` because two lib modules
|
|
27
33
|
// imported them from here and dragged this file's whole import graph — haiku-client,
|
|
@@ -237,6 +243,7 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
|
237
243
|
// forever. `.skill-cooldown-` / `.skill-reco-cooldown-` left with the skill registry in
|
|
238
244
|
// v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
|
|
239
245
|
'last-mark-compressible-', // per-project auto-compress 24h gate
|
|
246
|
+
SKEW_MARKER_PREFIX, // per-project schema-skew log dedup; regenerated on the next skewed open
|
|
240
247
|
]);
|
|
241
248
|
|
|
242
249
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
@@ -382,12 +389,58 @@ export function createSessionId() {
|
|
|
382
389
|
|
|
383
390
|
// ─── Database ────────────────────────────────────────────────────────────────
|
|
384
391
|
|
|
392
|
+
// Last forward-incompat ("the DB is newer than me") failure seen in THIS process, or null.
|
|
393
|
+
// SessionStart needs the two version numbers to render its notice and openDb has just been
|
|
394
|
+
// handed them, so this beats a second DB open — and on a skew there may be no working
|
|
395
|
+
// binding to open with anyway.
|
|
396
|
+
let lastSkew = null;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The schema skew that made the most recent openDb() return null, or null.
|
|
400
|
+
* Cleared by any successful open, so a heal mid-session stops the notice.
|
|
401
|
+
*
|
|
402
|
+
* @returns {{dbVersion: number|null, binaryVersion: number|null}|null}
|
|
403
|
+
*/
|
|
404
|
+
export function lastSchemaSkew() {
|
|
405
|
+
return lastSkew;
|
|
406
|
+
}
|
|
407
|
+
|
|
385
408
|
export function openDb() {
|
|
386
409
|
try {
|
|
387
410
|
// WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
|
|
388
411
|
// silently dead (null DB) on a corrupt WAL until the next MCP server start.
|
|
389
|
-
|
|
412
|
+
const db = ensureDbWithWalRecovery();
|
|
413
|
+
lastSkew = null;
|
|
414
|
+
return db;
|
|
390
415
|
} catch (e) {
|
|
416
|
+
// Forward-incompat is its own family: it cannot be healed by anything this process can
|
|
417
|
+
// do, it repeats on every single open, and it is the one failure the USER has to act on.
|
|
418
|
+
// Record it once and hand the numbers to SessionStart, which is the surface that speaks.
|
|
419
|
+
// Forward-incompat is its own family: nothing this process can do heals it, it repeats on
|
|
420
|
+
// every single open, and it is the one failure the USER has to act on. Dedup lives in
|
|
421
|
+
// lib/schema-skew.mjs so the `ups` face — which opens the DB itself and logged its own 15
|
|
422
|
+
// of the day's 727 lines — shares one implementation instead of drifting from this one.
|
|
423
|
+
//
|
|
424
|
+
// shouldRecordSkew is TOTAL by contract. Nothing in this catch may throw: the first cut
|
|
425
|
+
// 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
|
|
427
|
+
// no-op on null and none of them expects an exception.
|
|
428
|
+
if (isSchemaSkewError(e)) {
|
|
429
|
+
lastSkew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
|
|
430
|
+
// Guarded even though inferProject() reads env and cwd: "the only statement in this
|
|
431
|
+
// catch cannot throw" was true of the original one-line body and stopped being true
|
|
432
|
+
// the moment anything was added. An unscoped marker is a worse dedup, not a crash.
|
|
433
|
+
let project = '';
|
|
434
|
+
try {
|
|
435
|
+
project = inferProject();
|
|
436
|
+
} catch {
|
|
437
|
+
/* total: the marker degrades to one shared file */
|
|
438
|
+
}
|
|
439
|
+
if (shouldRecordSkew(RUNTIME_DIR, project, lastSkew)) {
|
|
440
|
+
recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
391
444
|
// Still null, still no throw — a hook must never crash the host session, and all
|
|
392
445
|
// eight call sites in hook.mjs are written to no-op on null. But "returned null"
|
|
393
446
|
// used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
|
package/hook-update.mjs
CHANGED
|
@@ -228,7 +228,11 @@ function isPluginMode() {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
// ── Dev Mode Detection ─────────────────────────────────────
|
|
231
|
-
|
|
231
|
+
// Exported since the schema-skew notice needs it: a dev checkout must be told `git pull`,
|
|
232
|
+
// never a command that would overwrite its working tree. Re-implementing the check at the
|
|
233
|
+
// call site would make it the second copy of a predicate this file has already had to get
|
|
234
|
+
// right twice (whole-dir symlink, then per-file drift) — the twin-drift class.
|
|
235
|
+
export function isDevMode() {
|
|
232
236
|
try {
|
|
233
237
|
// A dev checkout always carries a .git dir. This catches a whole-directory
|
|
234
238
|
// symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
|
package/hook.mjs
CHANGED
|
@@ -60,7 +60,9 @@ import {
|
|
|
60
60
|
episodeHasSignificantContent,
|
|
61
61
|
explainSignificance,
|
|
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
|
+
// always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
|
|
65
|
+
import { DB_DIR, CODE_DIR } from './schema.mjs';
|
|
64
66
|
import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
|
|
65
67
|
import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
66
68
|
import {
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
spawnBackground,
|
|
83
85
|
sweepOrphanEpisodeFiles,
|
|
84
86
|
sweepStaleProjectMarkers,
|
|
87
|
+
lastSchemaSkew,
|
|
85
88
|
} from './hook-shared.mjs';
|
|
86
89
|
import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
87
90
|
import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
|
|
@@ -2290,6 +2293,67 @@ async function buildStartupDashboardText(db, project) {
|
|
|
2290
2293
|
}
|
|
2291
2294
|
}
|
|
2292
2295
|
|
|
2296
|
+
/**
|
|
2297
|
+
* Tell the user their memory is version-skewed, on the one surface they read.
|
|
2298
|
+
*
|
|
2299
|
+
* Only fires when openDb() failed for THIS reason — hook-shared records the two version
|
|
2300
|
+
* numbers as it catches, so nothing is re-derived and no second DB open is attempted (on a
|
|
2301
|
+
* skew there may be no usable binding to open with).
|
|
2302
|
+
*
|
|
2303
|
+
* Everything is dynamically imported: this is a cold path that must not cost the healthy
|
|
2304
|
+
* SessionStart an install-shape scan. And it goes through the queue helpers, never a bare
|
|
2305
|
+
* console.log — SessionStart merges three would-be stdout contributors into ONE envelope,
|
|
2306
|
+
* and writing raw prose alongside it once made the host deliver the whole JSON object to
|
|
2307
|
+
* the model as literal text (tests/session-start-stdout-envelope.test.mjs).
|
|
2308
|
+
*/
|
|
2309
|
+
async function emitSchemaSkewNotice() {
|
|
2310
|
+
try {
|
|
2311
|
+
const skew = lastSchemaSkew();
|
|
2312
|
+
if (!skew) return;
|
|
2313
|
+
const [shapeMod, updateMod, skewMod] = await Promise.all([
|
|
2314
|
+
import('./lib/install-shape.mjs'),
|
|
2315
|
+
import('./hook-update.mjs'),
|
|
2316
|
+
import('./lib/schema-skew.mjs'),
|
|
2317
|
+
]);
|
|
2318
|
+
const shape = shapeMod.detectInstallShape({ installDir: CODE_DIR });
|
|
2319
|
+
// WHICH tree is running this hook, not which trees exist. CLAUDE_PLUGIN_ROOT is set in
|
|
2320
|
+
// every hook process Claude Code spawns, so on a machine holding BOTH a managed install
|
|
2321
|
+
// and a plugin cache it is the only thing that knows which one is behind. Deciding from
|
|
2322
|
+
// the machine's global shape printed `claude-mem-lite self-update` beneath a line naming
|
|
2323
|
+
// the plugin cache — a repair that cannot advance the tree it had just named.
|
|
2324
|
+
const runningRoot = process.env.CLAUDE_PLUGIN_ROOT || CODE_DIR;
|
|
2325
|
+
const remedy = skewMod.schemaSkewRemedy({
|
|
2326
|
+
managed: shape.managed,
|
|
2327
|
+
activePluginVersion: shape.activePluginVersion,
|
|
2328
|
+
dev: updateMod.isDevMode(),
|
|
2329
|
+
root: runningRoot,
|
|
2330
|
+
});
|
|
2331
|
+
const notice = skewMod.formatSchemaSkewNotice({
|
|
2332
|
+
dbVersion: skew.dbVersion,
|
|
2333
|
+
binaryVersion: skew.binaryVersion,
|
|
2334
|
+
remedy,
|
|
2335
|
+
// Name the home only when the remedy is about that home, so the two can never disagree.
|
|
2336
|
+
codeHome:
|
|
2337
|
+
remedy.kind === 'plugin' && shape.activePluginVersion
|
|
2338
|
+
? `plugin cache v${shape.activePluginVersion.version}`
|
|
2339
|
+
: undefined,
|
|
2340
|
+
});
|
|
2341
|
+
// BOTH channels, and the HUMAN one is the point. queueHookContext reaches the model;
|
|
2342
|
+
// lib/hook-stdout.mjs's queueHookSystemMessage is documented "for the HUMAN, not the
|
|
2343
|
+
// model" and names v3.70.0 for making exactly this mistake — folding a banner into
|
|
2344
|
+
// additionalContext "kept its content and lost its audience". A notice whose whole job is
|
|
2345
|
+
// to hand the user a command must not depend on the assistant volunteering it.
|
|
2346
|
+
// flushHookStdout merges both into one envelope, so this is additive: the model learns
|
|
2347
|
+
// memory is unavailable, the user gets the repair.
|
|
2348
|
+
queueHookSystemMessage(notice);
|
|
2349
|
+
queueHookContext('SessionStart', notice);
|
|
2350
|
+
} catch (e) {
|
|
2351
|
+
// A hook must never crash the host session, and a notice that cannot render is still
|
|
2352
|
+
// better handled by staying quiet than by taking SessionStart down with it.
|
|
2353
|
+
debugCatch(e, 'session-start-schema-skew');
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2293
2357
|
async function handleSessionStart() {
|
|
2294
2358
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
2295
2359
|
// moved here from pre-tool-recall.js's hot path.
|
|
@@ -2471,7 +2535,16 @@ async function handleSessionStart() {
|
|
|
2471
2535
|
const project = inferProject();
|
|
2472
2536
|
|
|
2473
2537
|
const db = openDb();
|
|
2474
|
-
if (!db)
|
|
2538
|
+
if (!db) {
|
|
2539
|
+
// A null DB used to end SessionStart in total silence. For most causes that is right —
|
|
2540
|
+
// they are transient, or a repair path is already running. Forward-incompat is neither:
|
|
2541
|
+
// it persists until the user installs newer code, it disables every write path, and the
|
|
2542
|
+
// only other signal it produces is a `-32000 Connection closed` from the MCP host, which
|
|
2543
|
+
// names nothing. Measured 2026-09-08: a whole day of it, >=648 log lines, zero words to
|
|
2544
|
+
// the user. This is the surface the user actually reads.
|
|
2545
|
+
await emitSchemaSkewNotice();
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2475
2548
|
|
|
2476
2549
|
try {
|
|
2477
2550
|
const now = new Date();
|
package/install.mjs
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
nativeBindingRepairHint,
|
|
71
71
|
} from './lib/binding-probe.mjs';
|
|
72
72
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
73
|
+
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
73
74
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
74
75
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
75
76
|
import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
@@ -1479,6 +1480,73 @@ async function doctor() {
|
|
|
1479
1480
|
}
|
|
1480
1481
|
}
|
|
1481
1482
|
|
|
1483
|
+
// Can each code home actually OPEN this database? A binding that loads is not the same
|
|
1484
|
+
// question: better-sqlite3 can be perfect and the store still unreadable, because
|
|
1485
|
+
// schema.mjs refuses a DB written by a newer claude-mem-lite (correctly — replaying old
|
|
1486
|
+
// migrations over a newer layout would corrupt it). That is a one-way ratchet, and on a
|
|
1487
|
+
// plugin install it is REACHED ROUTINELY: the cache only advances when Claude Code's
|
|
1488
|
+
// marketplace updater advances it, so anything else that opens the DB — an npm-global
|
|
1489
|
+
// CLI, a dev checkout — can leave the cache locked out. Measured 2026-09-08: DB v49 vs a
|
|
1490
|
+
// live 5.6.0 cache supporting v48, >=648 identical hook errors in one day, and the only
|
|
1491
|
+
// user-visible signal was `-32000 Connection closed` from the MCP host.
|
|
1492
|
+
//
|
|
1493
|
+
// Probed per root, out of process, exactly like the binding check above and for the same
|
|
1494
|
+
// reason: this is the check that has to survive answering the question, and importing
|
|
1495
|
+
// another tree's schema.mjs would poison the process that must report the answer. It is
|
|
1496
|
+
// also why this check is USEFUL TODAY rather than only after the next upgrade — doctor
|
|
1497
|
+
// runs from whichever tree the user invoked, so new code here can diagnose an old cache.
|
|
1498
|
+
if (!existsSync(DB_PATH)) {
|
|
1499
|
+
ok('DB schema: no database yet — nothing to compare');
|
|
1500
|
+
} else if (rootProbes.length === 0) {
|
|
1501
|
+
// The fourth outcome the first cut had and did not print. The `fail` above already tells
|
|
1502
|
+
// the reader no install owns a binding, but a block whose stated design point is "three
|
|
1503
|
+
// outcomes, never two" must not answer a fourth case with silence.
|
|
1504
|
+
dwarn('DB schema: not checked — no install on this machine owns a native binding to read it with');
|
|
1505
|
+
} else {
|
|
1506
|
+
const compat = probeSchemaCompat(shape.runtimeRoots, DB_PATH);
|
|
1507
|
+
const behind = compat.filter((c) => c.status === 'skew');
|
|
1508
|
+
const unknown = compat.filter((c) => c.status === 'unknown');
|
|
1509
|
+
if (behind.length === 0 && unknown.length === 0) {
|
|
1510
|
+
ok(`DB schema: v${compat[0]?.dbVersion} — readable by all ${compat.length} install(s)`);
|
|
1511
|
+
}
|
|
1512
|
+
if (behind.length > 0) {
|
|
1513
|
+
// Dynamic: only a skewed machine pays for it, and it reuses hook-update's isDevMode
|
|
1514
|
+
// rather than re-deriving "is this a checkout", which that file has already had to
|
|
1515
|
+
// correct twice (whole-dir symlink, then per-file drift).
|
|
1516
|
+
let dev = false;
|
|
1517
|
+
try {
|
|
1518
|
+
const { isDevMode } = await import('./hook-update.mjs');
|
|
1519
|
+
dev = isDevMode();
|
|
1520
|
+
} catch {
|
|
1521
|
+
/* unreadable → the initialiser stands: a non-dev install gets the common remedy */
|
|
1522
|
+
}
|
|
1523
|
+
for (const b of behind) {
|
|
1524
|
+
// PER ROOT, inside the loop. Computing one remedy for every skewed tree printed the
|
|
1525
|
+
// machine's global answer beneath a label naming a different tree — on a mixed
|
|
1526
|
+
// managed+plugin install that meant `self-update` under "plugin cache v5.6.0",
|
|
1527
|
+
// which advances nothing. b.root is the tree that is actually behind.
|
|
1528
|
+
const remedy = schemaSkewRemedy({
|
|
1529
|
+
managed: shape.managed,
|
|
1530
|
+
activePluginVersion: shape.activePluginVersion,
|
|
1531
|
+
dev,
|
|
1532
|
+
root: b.root,
|
|
1533
|
+
});
|
|
1534
|
+
// fail, not warn: every write path is dead in this state and only the user can fix it.
|
|
1535
|
+
fail(`DB schema v${b.dbVersion} is newer than ${b.label}, which supports up to v${b.supported}`);
|
|
1536
|
+
for (const c of remedy.commands) log(` ${c}`);
|
|
1537
|
+
if (remedy.note) log(` ${remedy.note}`);
|
|
1538
|
+
issues++;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
for (const u of unknown) {
|
|
1542
|
+
// Deliberately its own outcome. "I could not determine what this install supports"
|
|
1543
|
+
// printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
|
|
1544
|
+
// caught before the tag — a check that says "nothing to check" and "I could not look"
|
|
1545
|
+
// in the same voice ends the reader's search instead of directing it.
|
|
1546
|
+
dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1482
1550
|
try {
|
|
1483
1551
|
await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
1484
1552
|
ok('@modelcontextprotocol/sdk: verified (import OK)');
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// lib/schema-skew.mjs — the DB is NEWER than the code trying to open it.
|
|
2
|
+
//
|
|
3
|
+
// schema.mjs's forward-incompat guard has thrown on this for a long time and the throw is
|
|
4
|
+
// correct: an old binary that re-applied old migrations over a newer layout would corrupt
|
|
5
|
+
// the store. What was missing is everything downstream of the throw.
|
|
6
|
+
//
|
|
7
|
+
// Measured 2026-09-08 on a plugin-mode machine: DB v49, live plugin cache 5.6.0 (supports
|
|
8
|
+
// v48). Every `openDb()` threw, `hook-shared` logged each one, and the day's
|
|
9
|
+
// runtime/hook-errors/*.jsonl held >=648 copies of one sentence and was still growing. The
|
|
10
|
+
// MCP server died before its handshake, so the host showed `-32000 Connection closed`. And
|
|
11
|
+
// hook.mjs's `const db = openDb(); if (!db) return;` made SessionStart return in silence.
|
|
12
|
+
// Nothing the user could see said "your memory is version-skewed".
|
|
13
|
+
//
|
|
14
|
+
// Two design points that are easy to get wrong, both of which this repo has paid for before:
|
|
15
|
+
//
|
|
16
|
+
// • THE REMEDY IS SHAPE-DEPENDENT. The thrown message says
|
|
17
|
+
// `npm i -g claude-mem-lite@latest`. That is right for a managed/npm install and inert
|
|
18
|
+
// for a plugin-cache install — which is the shape that actually hits this, because the
|
|
19
|
+
// cache only advances when Claude Code's marketplace updater advances it, so it lags
|
|
20
|
+
// anything else that opened the DB. A repair that cannot work is worse than silence:
|
|
21
|
+
// the user runs it, sees success, and stops looking.
|
|
22
|
+
//
|
|
23
|
+
// • THREE OUTCOMES, NEVER TWO. "this home can open the DB" and "I could not determine
|
|
24
|
+
// what this home supports" must never print in the same voice. The v6.2.0 round WROTE a
|
|
25
|
+
// doctor check that answered "no hook command needs bash" on the one install shape where
|
|
26
|
+
// they are live, because a missing file read as a zero count — and its pre-ship review
|
|
27
|
+
// caught it before the tag (`f5e1786`), so it never shipped. Read that as the precedent
|
|
28
|
+
// it is: the defect is easy to write and invisible to unit tests. `status: 'unknown'`
|
|
29
|
+
// exists so it cannot be written here.
|
|
30
|
+
//
|
|
31
|
+
// This module is shared by hook-shared.mjs, hook.mjs, install.mjs (doctor) and
|
|
32
|
+
// scripts/launch.mjs, so per the project's own rule it lives in lib/ and is registered in
|
|
33
|
+
// BOTH source-files.mjs and package.json#files.
|
|
34
|
+
//
|
|
35
|
+
// It deliberately does NOT import better-sqlite3 at module scope: two of its consumers run
|
|
36
|
+
// on paths where the native binding may be the thing that is broken, and a classifier that
|
|
37
|
+
// cannot load is a classifier that cannot report. The only DB access here happens inside a
|
|
38
|
+
// child process (probeSchemaCompatInFreshProcess).
|
|
39
|
+
|
|
40
|
+
import { spawnSync } from 'node:child_process';
|
|
41
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
42
|
+
import { join, resolve } from 'node:path';
|
|
43
|
+
import { pathToFileURL } from 'node:url';
|
|
44
|
+
|
|
45
|
+
/** Machine-readable marker set by schema.mjs on the forward-incompat throw. */
|
|
46
|
+
export const SCHEMA_SKEW_CODE = 'CLAUDE_MEM_SCHEMA_TOO_NEW';
|
|
47
|
+
|
|
48
|
+
// PER PROJECT. The first version of this dedup used one marker file for the whole data dir,
|
|
49
|
+
// keyed on a per-project session id — so two projects sharing ~/.claude-mem-lite overwrote
|
|
50
|
+
// each other's key and every fire recorded again. Measured by review: 8 fires across 2
|
|
51
|
+
// projects → 8 records; the same 8 fires in 1 project → 1. The flood this exists to stop was
|
|
52
|
+
// therefore unfixed for exactly the multi-project machine that produced it.
|
|
53
|
+
export const SKEW_MARKER_PREFIX = '.schema-skew-logged-';
|
|
54
|
+
|
|
55
|
+
// Skew persists until the user installs newer code, so "record it once" would be defensible.
|
|
56
|
+
// An hour is the compromise: the log's remaining job is forensic ("when did this start, is it
|
|
57
|
+
// still happening"), and ≤24 lines/day/project answers that at a cost the 727-in-one-day
|
|
58
|
+
// measurement makes look free.
|
|
59
|
+
export const SKEW_RELOG_INTERVAL_MS = 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Should this skew be written to the hook-error log, or has it been recorded recently?
|
|
63
|
+
*
|
|
64
|
+
* TOTAL — every path returns a boolean, nothing escapes. That is a hard requirement, not
|
|
65
|
+
* defensiveness: callers invoke this from inside a DB-open failure handler, and `openDb()`'s
|
|
66
|
+
* contract is to return null and never throw. The first version called `getSessionId()` here,
|
|
67
|
+
* which is not a read — it MINTS and writes a session id — so an unwritable runtime dir
|
|
68
|
+
* (EROFS, ENOSPC, a dismounted CLAUDE_MEM_DIR) made the catch block itself throw. Two-arm
|
|
69
|
+
* proof at the time: HEAD threw ENOTDIR where the previous build returned null.
|
|
70
|
+
*
|
|
71
|
+
* Fails toward RECORDING: an unreadable or unwritable marker must never silence the log.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} runtimeDir
|
|
74
|
+
* @param {string} project Marker scope; anything falsy collapses to one shared file.
|
|
75
|
+
* @param {{dbVersion?: number|null, binaryVersion?: number|null}|null} info
|
|
76
|
+
* @param {{now?: number, intervalMs?: number}} [opts]
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function shouldRecordSkew(
|
|
80
|
+
runtimeDir,
|
|
81
|
+
project,
|
|
82
|
+
info,
|
|
83
|
+
{ now = Date.now(), intervalMs = SKEW_RELOG_INTERVAL_MS } = {},
|
|
84
|
+
) {
|
|
85
|
+
try {
|
|
86
|
+
const scope = String(project || 'unscoped').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
87
|
+
const file = join(runtimeDir, SKEW_MARKER_PREFIX + scope);
|
|
88
|
+
const key = `${info?.dbVersion ?? '?'}:${info?.binaryVersion ?? '?'}`;
|
|
89
|
+
try {
|
|
90
|
+
const prev = JSON.parse(readFileSync(file, 'utf8'));
|
|
91
|
+
// A changed version pair always re-records: a PARTIAL upgrade (the binary moves v48→v49
|
|
92
|
+
// while the DB moves to v50) is new information, not the fault we already logged.
|
|
93
|
+
if (prev.key === key && typeof prev.ts === 'number' && now - prev.ts < intervalMs) return false;
|
|
94
|
+
} catch {
|
|
95
|
+
/* absent, unreadable or corrupt → record */
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
// The dir may not exist yet. hook-shared creates RUNTIME_DIR at module scope, but the
|
|
99
|
+
// `ups` face does not import it — so on a fresh data dir the first marker write failed
|
|
100
|
+
// silently and the SECOND fire recorded again. Measured: 4 ups fires produced 2 records
|
|
101
|
+
// instead of 1, both from the same call site, with the marker present afterwards.
|
|
102
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
103
|
+
writeFileSync(file, JSON.stringify({ key, ts: now }), { mode: 0o600 });
|
|
104
|
+
} catch {
|
|
105
|
+
/* an unwritable marker must not suppress the record */
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The shipped message, which older builds throw with no code field at all. Kept as a
|
|
114
|
+
// fallback classifier so this module can still recognise a skew raised by code that
|
|
115
|
+
// predates SCHEMA_SKEW_CODE — the interesting direction, since skew means old code.
|
|
116
|
+
const SKEW_MESSAGE_RE = /DB schema is v(\d+) but this claude-mem-lite binary supports up to v(\d+)/;
|
|
117
|
+
const SKEW_MESSAGE_LOOSE_RE = /DB schema is v\d+/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* True when `err` means "this DB was written by a newer claude-mem-lite".
|
|
121
|
+
*
|
|
122
|
+
* Accepts anything thrown (Error, string, null) because recordHookError does.
|
|
123
|
+
*
|
|
124
|
+
* @param {unknown} err
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function isSchemaSkewError(err) {
|
|
128
|
+
if (!err) return false;
|
|
129
|
+
if (err.code === SCHEMA_SKEW_CODE) return true;
|
|
130
|
+
return SKEW_MESSAGE_LOOSE_RE.test(String(err.message ?? err ?? ''));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The two version numbers, from the error's own fields when present and from its message
|
|
135
|
+
* otherwise. Null when this is not a skew error, or when neither source carries numbers.
|
|
136
|
+
*
|
|
137
|
+
* @param {unknown} err
|
|
138
|
+
* @returns {{dbVersion: number, binaryVersion: number}|null}
|
|
139
|
+
*/
|
|
140
|
+
export function schemaSkewFromError(err) {
|
|
141
|
+
if (!err) return null;
|
|
142
|
+
if (typeof err.dbVersion === 'number' && typeof err.binaryVersion === 'number') {
|
|
143
|
+
return { dbVersion: err.dbVersion, binaryVersion: err.binaryVersion };
|
|
144
|
+
}
|
|
145
|
+
const m = SKEW_MESSAGE_RE.exec(String(err.message ?? err ?? ''));
|
|
146
|
+
if (!m) return null;
|
|
147
|
+
return { dbVersion: Number(m[1]), binaryVersion: Number(m[2]) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Same directory, tolerating symlinks — a plugin cache root reaches callers both ways. */
|
|
151
|
+
function samePath(a, b) {
|
|
152
|
+
if (!a || !b) return false;
|
|
153
|
+
if (resolve(a) === resolve(b)) return true;
|
|
154
|
+
try {
|
|
155
|
+
return realpathSync(a) === realpathSync(b);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Both halves are needed and the order matters: the local marketplace clone is what Claude
|
|
163
|
+
* Code compares against, so an outdated clone makes `/plugin update` a no-op that reports
|
|
164
|
+
* success. Measured 2026-09-08: the clone sat 22 commits behind while npm and GitHub already
|
|
165
|
+
* carried the version that owned the DB.
|
|
166
|
+
*/
|
|
167
|
+
function pluginRemedy(activePluginVersion, marketplace, plugin) {
|
|
168
|
+
return {
|
|
169
|
+
kind: 'plugin',
|
|
170
|
+
commands: [`/plugin marketplace update ${marketplace}`, `/plugin update ${plugin}@${marketplace}`],
|
|
171
|
+
note: `Run both in Claude Code, then restart it. Plugin cache is at v${activePluginVersion.version}.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which command actually repairs this machine — or, when `root` is given, the tree that is
|
|
177
|
+
* actually behind. Pass `root` whenever you know it; the machine's global shape is the
|
|
178
|
+
* fallback, and on a mixed install it is the wrong answer.
|
|
179
|
+
*
|
|
180
|
+
* @param {{managed?: boolean, activePluginVersion?: {version: string}|null, dev?: boolean, marketplace?: string, plugin?: string}} shape
|
|
181
|
+
* @returns {{kind: 'dev'|'plugin'|'managed'|'unknown', commands: string[], note: string}}
|
|
182
|
+
*/
|
|
183
|
+
export function schemaSkewRemedy({
|
|
184
|
+
managed = false,
|
|
185
|
+
activePluginVersion = null,
|
|
186
|
+
dev = false,
|
|
187
|
+
root = null,
|
|
188
|
+
marketplace = 'sdsrss',
|
|
189
|
+
plugin = 'claude-mem-lite',
|
|
190
|
+
} = {}) {
|
|
191
|
+
// THE ROOT WINS when the caller knows which tree is behind. A machine can hold a managed
|
|
192
|
+
// install AND a plugin cache at once, and `hasManagedCodeInstall` is true for a dev
|
|
193
|
+
// checkout too (existsSync follows symlinks). Deciding from the machine's global shape
|
|
194
|
+
// then printed `claude-mem-lite self-update` under a line reading "the code running here
|
|
195
|
+
// (plugin cache v5.6.0)" — neither command advances a plugin cache. That is verbatim the
|
|
196
|
+
// failure this module's header calls its reason to exist, reproduced end to end by review
|
|
197
|
+
// on the exact version pair from the motivating measurement.
|
|
198
|
+
if (root && activePluginVersion?.root && samePath(root, activePluginVersion.root)) {
|
|
199
|
+
return pluginRemedy(activePluginVersion, marketplace, plugin);
|
|
200
|
+
}
|
|
201
|
+
// Dev next: a checkout's files are symlinked or git-managed, so every other remedy would
|
|
202
|
+
// overwrite the user's working tree.
|
|
203
|
+
if (dev) {
|
|
204
|
+
return {
|
|
205
|
+
kind: 'dev',
|
|
206
|
+
commands: ['git pull'],
|
|
207
|
+
note: 'This is a development checkout — something newer than this working tree opened the DB.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (activePluginVersion && !managed) {
|
|
211
|
+
return pluginRemedy(activePluginVersion, marketplace, plugin);
|
|
212
|
+
}
|
|
213
|
+
if (managed) {
|
|
214
|
+
return {
|
|
215
|
+
kind: 'managed',
|
|
216
|
+
commands: ['claude-mem-lite self-update'],
|
|
217
|
+
note: 'Or reinstall with: npm i -g claude-mem-lite@latest',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Not "nothing to do" — "I could not tell". Name both places that were consulted so the
|
|
221
|
+
// reader knows where to look rather than assuming the check found nothing wrong.
|
|
222
|
+
return {
|
|
223
|
+
kind: 'unknown',
|
|
224
|
+
commands: [],
|
|
225
|
+
note: 'Could not identify this install: no managed code install in ~/.claude-mem-lite and no active plugin cache version. Run `claude-mem-lite doctor` from the install you actually use.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The user-facing block. Kept short on purpose — at SessionStart it shares one stdout
|
|
231
|
+
* envelope with the startup dashboard and the `<claude-mem-context>` block.
|
|
232
|
+
*
|
|
233
|
+
* @param {{dbVersion: number|null, binaryVersion: number|null, remedy: ReturnType<typeof schemaSkewRemedy>, codeHome?: string}} info
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
export function formatSchemaSkewNotice({ dbVersion, binaryVersion, remedy, codeHome }) {
|
|
237
|
+
const where = codeHome ? ` (${codeHome})` : '';
|
|
238
|
+
const lines = [
|
|
239
|
+
'⚠️ [claude-mem-lite] Memory is OFF: this database was written by a newer version.',
|
|
240
|
+
` DB schema v${dbVersion ?? '?'}; the code running here${where} supports up to v${binaryVersion ?? '?'}.`,
|
|
241
|
+
];
|
|
242
|
+
for (const c of remedy.commands) lines.push(` ${c}`);
|
|
243
|
+
if (remedy.note) lines.push(` ${remedy.note}`);
|
|
244
|
+
lines.push(' Until then, saves and recall are disabled. Your stored memories are intact.');
|
|
245
|
+
return lines.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The child-process source for one code home. Exported so a test can pin the contract
|
|
250
|
+
* without spawning, and so the string is reviewable in isolation.
|
|
251
|
+
*
|
|
252
|
+
* Both paths are ASKED, never derived: the supported version comes from importing that
|
|
253
|
+
* home's own schema.mjs, and the DB version from opening the DB with that home's own
|
|
254
|
+
* better-sqlite3. Parsing `export const CURRENT_SCHEMA_VERSION = \d+` out of the file
|
|
255
|
+
* would be the same mistake as naming the native addon's path instead of asking
|
|
256
|
+
* lib/binding.js for it — a literal that goes stale silently.
|
|
257
|
+
*
|
|
258
|
+
* The payload is BRACKETED. Importing another tree's schema.mjs runs that tree's module
|
|
259
|
+
* scope, and anything it prints lands on the same stdout — so a bare `JSON.parse(stdout)`
|
|
260
|
+
* turned "this home is fine" into "could not determine" for any code home that logs on
|
|
261
|
+
* import. Review demonstrated it with a one-line `console.log`. Sentinels cost nothing and
|
|
262
|
+
* make the channel robust to a co-tenant instead of assuming it is empty.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} root
|
|
265
|
+
* @param {string} dbPath
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
export function schemaCompatProbeSource(root, dbPath) {
|
|
269
|
+
const pkg = JSON.stringify(join(root, 'package.json'));
|
|
270
|
+
const schemaUrl = JSON.stringify(pathToFileURL(join(root, 'schema.mjs')).href);
|
|
271
|
+
const db = JSON.stringify(dbPath);
|
|
272
|
+
const open = JSON.stringify(PROBE_BEGIN);
|
|
273
|
+
const close = JSON.stringify(PROBE_END);
|
|
274
|
+
return (
|
|
275
|
+
'(async () => { const out = {};' +
|
|
276
|
+
`try { const m = await import(${schemaUrl});` +
|
|
277
|
+
' out.supported = typeof m.CURRENT_SCHEMA_VERSION === "number" ? m.CURRENT_SCHEMA_VERSION : null; }' +
|
|
278
|
+
' catch (e) { out.supportedError = String((e && e.message) || e); }' +
|
|
279
|
+
'try { const { createRequire } = require("node:module");' +
|
|
280
|
+
` const D = createRequire(${pkg})("better-sqlite3");` +
|
|
281
|
+
` const d = new D(${db}, { readonly: true, fileMustExist: true });` +
|
|
282
|
+
' const r = d.prepare("SELECT version FROM schema_version LIMIT 1").get();' +
|
|
283
|
+
' d.close();' +
|
|
284
|
+
' out.dbVersion = r && typeof r.version === "number" ? r.version : null; }' +
|
|
285
|
+
' catch (e) { out.dbError = String((e && e.message) || e); }' +
|
|
286
|
+
`process.stdout.write(${open} + JSON.stringify(out) + ${close}); })()`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Deliberately unlikely to appear in a module's own logging, and matched with lastIndexOf so
|
|
291
|
+
// a tree that echoes the sentinel itself still loses to the real payload written last.
|
|
292
|
+
const PROBE_BEGIN = '<<claude-mem-schema-probe>>';
|
|
293
|
+
const PROBE_END = '<</claude-mem-schema-probe>>';
|
|
294
|
+
|
|
295
|
+
/** The bracketed payload, or null when the child never got as far as writing one. */
|
|
296
|
+
function extractProbePayload(stdout) {
|
|
297
|
+
const s = String(stdout || '');
|
|
298
|
+
const a = s.lastIndexOf(PROBE_BEGIN);
|
|
299
|
+
if (a < 0) return null;
|
|
300
|
+
const b = s.indexOf(PROBE_END, a);
|
|
301
|
+
if (b < 0) return null;
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(s.slice(a + PROBE_BEGIN.length, b));
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Can THIS code home open THIS database?
|
|
311
|
+
*
|
|
312
|
+
* Out of process for the same reason every other probe here is: importing another tree's
|
|
313
|
+
* schema.mjs and dlopen'ing its better-sqlite3 would poison the calling process, and doctor
|
|
314
|
+
* has to survive answering the question.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} root Code home (holds schema.mjs and node_modules)
|
|
317
|
+
* @param {string} dbPath
|
|
318
|
+
* `spawn` is injectable so the "child produced no parseable stdout" branch can be driven at
|
|
319
|
+
* all. THE ORIGINAL REASON GIVEN HERE WAS WRONG and is corrected rather than quietly
|
|
320
|
+
* dropped: it claimed the branch was reachable only from a native crash and could not be
|
|
321
|
+
* provoked from a test. Review falsified that in one step — a code home whose schema.mjs
|
|
322
|
+
* writes anything to stdout at module scope (a `console.log`, or any import that logs)
|
|
323
|
+
* pollutes the JSON payload and lands here through the real function, exit 0. So the branch
|
|
324
|
+
* is ORDINARY, not exotic: any tree that logs on import degrades a correct verdict into a
|
|
325
|
+
* doctor ⚠. That is why the child now brackets its payload with a sentinel and this function
|
|
326
|
+
* reads only what is inside it — the seam remains for the genuinely unreachable shapes
|
|
327
|
+
* (a native crash leaving both streams empty, a spawn that never starts).
|
|
328
|
+
*
|
|
329
|
+
* @param {{timeoutMs?: number, spawn?: (cmd: string, args: string[], opts: object) => object}} [opts]
|
|
330
|
+
* @returns {{status: 'ok'|'skew'|'unknown', supported?: number|null, dbVersion?: number|null, error?: string}}
|
|
331
|
+
*/
|
|
332
|
+
export function probeSchemaCompatInFreshProcess(
|
|
333
|
+
root,
|
|
334
|
+
dbPath,
|
|
335
|
+
{ timeoutMs = 15_000, spawn = spawnSync } = {},
|
|
336
|
+
) {
|
|
337
|
+
const r = spawn(process.execPath, ['-e', schemaCompatProbeSource(root, dbPath)], {
|
|
338
|
+
stdio: 'pipe',
|
|
339
|
+
encoding: 'utf8',
|
|
340
|
+
timeout: timeoutMs,
|
|
341
|
+
});
|
|
342
|
+
// Before the status check: spawnSync's `timeout` is SIGTERM-then-wait, so a child that
|
|
343
|
+
// survives the signal can still exit 0 while r.error is ETIMEDOUT.
|
|
344
|
+
if (r.error) return { status: 'unknown', error: r.error.message };
|
|
345
|
+
const out = extractProbePayload(r.stdout);
|
|
346
|
+
if (!out) {
|
|
347
|
+
const stderrLine = String(r.stderr || '')
|
|
348
|
+
.split('\n')
|
|
349
|
+
.map((l) => l.trim())
|
|
350
|
+
.find(Boolean);
|
|
351
|
+
return { status: 'unknown', error: stderrLine || `probe exited ${r.status ?? `on signal ${r.signal}`}` };
|
|
352
|
+
}
|
|
353
|
+
const { supported, dbVersion } = out;
|
|
354
|
+
// Either number missing = unknown. Not 'ok': a home whose schema.mjs would not load is
|
|
355
|
+
// not a home we just certified, and a DB we could not read is not a DB we compared against.
|
|
356
|
+
if (typeof supported !== 'number' || typeof dbVersion !== 'number') {
|
|
357
|
+
return {
|
|
358
|
+
status: 'unknown',
|
|
359
|
+
supported: supported ?? null,
|
|
360
|
+
dbVersion: dbVersion ?? null,
|
|
361
|
+
error: out.supportedError || out.dbError || 'probe returned no version',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
return { status: supported < dbVersion ? 'skew' : 'ok', supported, dbVersion };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Probe every code home against one DB, so a report can NAME the one that is behind
|
|
369
|
+
* instead of asserting something global about "the install".
|
|
370
|
+
*
|
|
371
|
+
* @param {Array<{label: string, root: string}>} roots
|
|
372
|
+
* @param {string} dbPath
|
|
373
|
+
* @param {{probe?: (root: string, dbPath: string) => object}} [deps]
|
|
374
|
+
* @returns {Array<{label: string, root: string, status: string, supported?: number|null, dbVersion?: number|null, error?: string}>}
|
|
375
|
+
*/
|
|
376
|
+
export function probeSchemaCompat(roots, dbPath, deps = {}) {
|
|
377
|
+
const probe = deps.probe || ((root, p) => probeSchemaCompatInFreshProcess(root, p));
|
|
378
|
+
return (roots || []).map(({ label, root }) => ({ label, root, ...probe(root, dbPath) }));
|
|
379
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// No MCP SDK or heavy deps — only imports schema.mjs and utils.mjs
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR } from './schema.mjs';
|
|
6
|
+
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
|
|
7
7
|
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
8
8
|
import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
|
|
9
9
|
import { resolveProject } from './project-utils.mjs';
|
|
@@ -79,6 +79,12 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
|
79
79
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
80
80
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
81
81
|
import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
|
|
82
|
+
import {
|
|
83
|
+
isSchemaSkewError,
|
|
84
|
+
schemaSkewFromError,
|
|
85
|
+
schemaSkewRemedy,
|
|
86
|
+
formatSchemaSkewNotice,
|
|
87
|
+
} from './lib/schema-skew.mjs';
|
|
82
88
|
import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
83
89
|
import {
|
|
84
90
|
parseArgs,
|
|
@@ -3670,6 +3676,41 @@ export async function run(argv) {
|
|
|
3670
3676
|
process.exitCode = 1;
|
|
3671
3677
|
return;
|
|
3672
3678
|
}
|
|
3679
|
+
// Schema skew gets the same treatment as the native-binding family above, and for the
|
|
3680
|
+
// same reason: the raw message ends in `npm i -g claude-mem-lite@latest`, which repairs
|
|
3681
|
+
// nothing on a plugin-cache install — the shape that actually hits this. Four surfaces
|
|
3682
|
+
// were wired before this one, and this is the command a user reaches for right after
|
|
3683
|
+
// `doctor` tells them something is wrong.
|
|
3684
|
+
if (isSchemaSkewError(e)) {
|
|
3685
|
+
const skew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
|
|
3686
|
+
let shape = { managed: false, activePluginVersion: null };
|
|
3687
|
+
let dev = false;
|
|
3688
|
+
try {
|
|
3689
|
+
const [shapeMod, updateMod] = await Promise.all([
|
|
3690
|
+
import('./lib/install-shape.mjs'),
|
|
3691
|
+
import('./hook-update.mjs'),
|
|
3692
|
+
]);
|
|
3693
|
+
shape = shapeMod.detectInstallShape({ installDir: CODE_DIR });
|
|
3694
|
+
dev = updateMod.isDevMode();
|
|
3695
|
+
} catch {
|
|
3696
|
+
/* shape unknown → schemaSkewRemedy answers 'unknown', which is its job */
|
|
3697
|
+
}
|
|
3698
|
+
out(
|
|
3699
|
+
formatSchemaSkewNotice({
|
|
3700
|
+
dbVersion: skew.dbVersion,
|
|
3701
|
+
binaryVersion: skew.binaryVersion,
|
|
3702
|
+
remedy: schemaSkewRemedy({
|
|
3703
|
+
managed: shape.managed,
|
|
3704
|
+
activePluginVersion: shape.activePluginVersion,
|
|
3705
|
+
dev,
|
|
3706
|
+
root: process.env.CLAUDE_PLUGIN_ROOT || CODE_DIR,
|
|
3707
|
+
}),
|
|
3708
|
+
}),
|
|
3709
|
+
);
|
|
3710
|
+
out(`[mem] DB path: ${DB_PATH}`);
|
|
3711
|
+
process.exitCode = 1;
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3673
3714
|
out(`[mem] Error: Cannot open database: ${e.message}`);
|
|
3674
3715
|
out(`[mem] DB path: ${DB_PATH}`);
|
|
3675
3716
|
process.exitCode = 1;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.
|
|
9
|
+
"version": "6.3.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"lib/lesson-bridge.mjs",
|
|
103
103
|
"lib/binding-probe.mjs",
|
|
104
104
|
"lib/install-shape.mjs",
|
|
105
|
+
"lib/schema-skew.mjs",
|
|
105
106
|
"lib/hook-stdin.mjs",
|
|
106
107
|
"lib/plugin-key.mjs",
|
|
107
108
|
"lib/hook-stdout.mjs",
|
package/schema.mjs
CHANGED
|
@@ -8,6 +8,10 @@ import { join } from 'path';
|
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
|
|
9
9
|
import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
|
|
10
10
|
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
11
|
+
// Imported, never re-declared: a hand-copied marker string is this repo's twin-drift
|
|
12
|
+
// class, and every consumer of the forward-incompat throw keys on this exact value.
|
|
13
|
+
// schema-skew.mjs imports nothing local, so this closes no cycle.
|
|
14
|
+
import { SCHEMA_SKEW_CODE } from './lib/schema-skew.mjs';
|
|
11
15
|
|
|
12
16
|
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
13
17
|
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
@@ -465,10 +469,21 @@ export function initSchema(db) {
|
|
|
465
469
|
return db;
|
|
466
470
|
}
|
|
467
471
|
if (row.version > CURRENT_SCHEMA_VERSION) {
|
|
468
|
-
|
|
472
|
+
// The MESSAGE is the long-standing contract (tests/schema.test.mjs and
|
|
473
|
+
// tests/wal-recovery.test.mjs both match on it, and older builds throw exactly
|
|
474
|
+
// this), so it is unchanged. The FIELDS are additive: every consumer downstream
|
|
475
|
+
// used to re-derive these numbers by regexing the sentence, and the `npm i -g`
|
|
476
|
+
// remedy baked into it is inert for a plugin-cache install — which is the shape
|
|
477
|
+
// that actually hits this. lib/schema-skew.mjs turns the fields into a
|
|
478
|
+
// shape-correct repair; see its header for the 2026-09-08 measurement.
|
|
479
|
+
const err = new Error(
|
|
469
480
|
`DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
|
|
470
481
|
`A newer version wrote this DB; upgrade claude-mem-lite (npm i -g claude-mem-lite@latest) or point CLAUDE_MEM_DIR to a fresh directory.`,
|
|
471
482
|
);
|
|
483
|
+
err.code = SCHEMA_SKEW_CODE;
|
|
484
|
+
err.dbVersion = row.version;
|
|
485
|
+
err.binaryVersion = CURRENT_SCHEMA_VERSION;
|
|
486
|
+
throw err;
|
|
472
487
|
}
|
|
473
488
|
}
|
|
474
489
|
} catch (e) {
|
package/scripts/launch.mjs
CHANGED
|
@@ -209,26 +209,84 @@ try {
|
|
|
209
209
|
useDevServer = existsSync(devServer) && lstatSync(devServer).isSymbolicLink();
|
|
210
210
|
} catch {}
|
|
211
211
|
|
|
212
|
+
// The MCP server opens the DB while it is being imported, so a forward-incompat store
|
|
213
|
+
// (schema.mjs's "DB schema is vN but this binary supports up to vN-1") throws right here
|
|
214
|
+
// and kills the process before the stdio handshake. All the host can say about that is
|
|
215
|
+
// `-32000 Connection closed`, which names nothing — measured 2026-09-08, a full day of it
|
|
216
|
+
// with the real cause visible only in a JSONL file the user has no reason to open.
|
|
217
|
+
//
|
|
218
|
+
// stderr is the one channel a launcher still has at that point. It reaches the plugin's own
|
|
219
|
+
// log rather than the transcript, so this is a diagnosis for whoever goes looking, not a
|
|
220
|
+
// substitute for the SessionStart notice — which is why both exist.
|
|
221
|
+
async function importServerOrExplain(run, { dev = false } = {}) {
|
|
222
|
+
try {
|
|
223
|
+
await run();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
// The classifier is loaded INSIDE its own try and any failure rethrows the ORIGINAL
|
|
226
|
+
// error. Importing it unconditionally destroyed `e`: this path exists to diagnose an
|
|
227
|
+
// install whose files are missing (issue #15), lib/schema-skew.mjs is a brand-new file,
|
|
228
|
+
// and resolveLaunchEntry can serve the server from dataDir while `../lib/…` still
|
|
229
|
+
// resolves against ROOT. Proven by review — with the module moved aside the process died
|
|
230
|
+
// naming ERR_MODULE_NOT_FOUND for the classifier while the real boot failure never
|
|
231
|
+
// appeared anywhere in the output.
|
|
232
|
+
let skewMod;
|
|
233
|
+
try {
|
|
234
|
+
skewMod = await import('../lib/schema-skew.mjs');
|
|
235
|
+
} catch {
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
if (!skewMod.isSchemaSkewError(e)) throw e;
|
|
239
|
+
let shape = { managed: false, activePluginVersion: null };
|
|
240
|
+
try {
|
|
241
|
+
({ ...shape } = await import('../lib/install-shape.mjs').then((m) =>
|
|
242
|
+
m.detectInstallShape({ installDir: dataDir }),
|
|
243
|
+
));
|
|
244
|
+
} catch {
|
|
245
|
+
/* shape unknown → schemaSkewRemedy answers 'unknown', which is its job */
|
|
246
|
+
}
|
|
247
|
+
const skew = skewMod.schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
skewMod.formatSchemaSkewNotice({
|
|
250
|
+
dbVersion: skew.dbVersion,
|
|
251
|
+
binaryVersion: skew.binaryVersion,
|
|
252
|
+
// `dev` is passed because the useDevServer branch IS the dev install by definition —
|
|
253
|
+
// omitting it told a checkout to `npm i -g` over its own working tree. `root: ROOT`
|
|
254
|
+
// so a mixed managed+plugin machine gets the remedy for the tree that is behind.
|
|
255
|
+
remedy: skewMod.schemaSkewRemedy({
|
|
256
|
+
managed: shape.managed,
|
|
257
|
+
activePluginVersion: shape.activePluginVersion,
|
|
258
|
+
dev,
|
|
259
|
+
root: ROOT,
|
|
260
|
+
}),
|
|
261
|
+
codeHome: ROOT,
|
|
262
|
+
}) + '\n',
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
212
268
|
if (useDevServer) {
|
|
213
|
-
await import(pathToFileURL(devServer).href);
|
|
269
|
+
await importServerOrExplain(() => import(pathToFileURL(devServer).href), { dev: true });
|
|
214
270
|
} else {
|
|
215
271
|
// Preflight: detect incomplete primary install (issue #15) — if relative
|
|
216
272
|
// imports referenced by server.mjs are missing on disk, fall back to the
|
|
217
273
|
// hook-update.mjs-maintained ~/.claude-mem-lite/ copy when healthy, or exit
|
|
218
274
|
// with a clear repair command instead of a Node ERR_MODULE_NOT_FOUND stack.
|
|
219
275
|
const { resolveLaunchEntry } = await import('./launch-preflight.mjs');
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
276
|
+
await importServerOrExplain(async () => {
|
|
277
|
+
try {
|
|
278
|
+
const entry = resolveLaunchEntry({
|
|
279
|
+
primaryRoot: ROOT,
|
|
280
|
+
fallbackRoot: dataDir,
|
|
281
|
+
warn: (msg) => process.stderr.write(msg + '\n'),
|
|
282
|
+
});
|
|
283
|
+
await import(pathToFileURL(entry.path).href);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
if (e.code === 'INSTALL_INCOMPLETE') {
|
|
286
|
+
process.stderr.write(e.message + '\n');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
throw e;
|
|
231
290
|
}
|
|
232
|
-
|
|
233
|
-
}
|
|
291
|
+
});
|
|
234
292
|
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { injectedIdsFileName, mergeInjectedMarker } from '../lib/injected-ids.mjs';
|
|
40
40
|
import { getDeferredByIds } from '../lib/deferred-work.mjs';
|
|
41
41
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
42
|
+
import { isSchemaSkewError, schemaSkewFromError, shouldRecordSkew } from '../lib/schema-skew.mjs';
|
|
42
43
|
|
|
43
44
|
import { DAY_MS } from '../lib/time-constants.mjs';
|
|
44
45
|
import { envNumber } from '../lib/env-number.mjs';
|
|
@@ -773,6 +774,25 @@ async function main() {
|
|
|
773
774
|
// A failed DB open silently kills EVERY prompt-time injection while `stats`
|
|
774
775
|
// reads zero errors (audit 2026-08-14 M-5) — record before the mandatory
|
|
775
776
|
// swallow. Exact blindness class of the 2026-08-13 pre-recall:db-open outage.
|
|
777
|
+
//
|
|
778
|
+
// Schema skew is the one member of that family worth deduplicating: it persists until
|
|
779
|
+
// the user installs newer code, so it repeats on EVERY prompt. This face opens the DB
|
|
780
|
+
// itself rather than through hook-shared's openDb, so it needs the gate explicitly —
|
|
781
|
+
// it contributed 15 of one measured day's 727 identical lines, i.e. the flood was ~98%
|
|
782
|
+
// closed and not closed. Shared implementation, deliberately: a second copy of a
|
|
783
|
+
// dedup rule is this repo's twin-drift class.
|
|
784
|
+
if (isSchemaSkewError(e)) {
|
|
785
|
+
let project = '';
|
|
786
|
+
try {
|
|
787
|
+
project = inferProject();
|
|
788
|
+
} catch {
|
|
789
|
+
/* total — the marker degrades to one shared file, never a throw */
|
|
790
|
+
}
|
|
791
|
+
if (shouldRecordSkew(RUNTIME_DIR, project, schemaSkewFromError(e))) {
|
|
792
|
+
recordHookError('ups:db-open', e, RUNTIME_DIR);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
776
796
|
recordHookError('ups:db-open', e, RUNTIME_DIR);
|
|
777
797
|
return;
|
|
778
798
|
}
|
package/source-files.mjs
CHANGED
|
@@ -131,6 +131,7 @@ export const SOURCE_FILES = [
|
|
|
131
131
|
// Missing from the manifest → an updated install ships a doctor that throws
|
|
132
132
|
// ERR_MODULE_NOT_FOUND on the command users run when something is already wrong.
|
|
133
133
|
'lib/install-shape.mjs',
|
|
134
|
+
'lib/schema-skew.mjs',
|
|
134
135
|
// Single-envelope stdout for hook processes — imported by hook.mjs. Claude Code
|
|
135
136
|
// parses hook stdout as ONE JSON document; missing from the manifest → an updated
|
|
136
137
|
// install throws ERR_MODULE_NOT_FOUND on every hook fire.
|