claude-mem-lite 6.2.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.2.0",
9
+ "version": "6.4.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.2.0",
3
+ "version": "6.4.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",
@@ -24,6 +24,7 @@
24
24
  "format": "prettier --write \"**/*.{mjs,js}\"",
25
25
  "format:check": "prettier --check \"**/*.{mjs,js}\"",
26
26
  "dead-code": "knip",
27
+ "validate:manifests": "node scripts/validate-plugin-manifests.mjs",
27
28
  "audit:metrics": "node scripts/audit-metrics.mjs --md",
28
29
  "audit:baseline": "node scripts/audit-metrics.mjs --run-tests --md",
29
30
  "test": "vitest run",
@@ -89,6 +90,7 @@
89
90
  "lib/err-sampler.mjs",
90
91
  "lib/hook-telemetry.mjs",
91
92
  "lib/resolve-data-dir.mjs",
93
+ "lib/data-paths.mjs",
92
94
  "lib/export-columns.mjs",
93
95
  "lib/file-intel.mjs",
94
96
  "lib/reread-guard.mjs",
@@ -102,6 +104,7 @@
102
104
  "lib/lesson-bridge.mjs",
103
105
  "lib/binding-probe.mjs",
104
106
  "lib/install-shape.mjs",
107
+ "lib/schema-skew.mjs",
105
108
  "lib/hook-stdin.mjs",
106
109
  "lib/plugin-key.mjs",
107
110
  "lib/hook-stdout.mjs",
package/schema.mjs CHANGED
@@ -7,18 +7,20 @@ import { homedir } from 'os';
7
7
  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
- import { resolveDataDir } from './lib/resolve-data-dir.mjs';
11
-
12
- // DATA location — DB, managed resources, registry DB, runtime/. Honors
13
- // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
14
- export const DB_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
15
- export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
16
- // CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
17
- // here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
18
- // bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
19
- // CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
20
- // DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
21
- export const CODE_DIR = join(homedir(), '.claude-mem-lite');
10
+ // Imported, never re-declared: a hand-copied marker string is this repo's twin-drift
11
+ // class, and every consumer of the forward-incompat throw keys on this exact value.
12
+ // schema-skew.mjs imports nothing local, so this closes no cycle.
13
+ import { SCHEMA_SKEW_CODE } from './lib/schema-skew.mjs';
14
+
15
+ // The three location constants now live in lib/data-paths.mjs — a leaf module with no
16
+ // package imports — and are re-exported here so every existing importer is unchanged.
17
+ // This file statically imports better-sqlite3, so holding a path constant here made the
18
+ // native driver a load-time dependency of anything that wanted one; that is what put the
19
+ // Ed25519-verified repair path out of reach on a tree with no node_modules. Imported AND
20
+ // re-exported (not `export … from`) because schema.mjs uses DB_DIR / DB_PATH itself.
21
+ // See lib/data-paths.mjs and tests/repair-path-no-native-dep.test.mjs.
22
+ import { DB_DIR, DB_PATH, CODE_DIR } from './lib/data-paths.mjs';
23
+ export { DB_DIR, DB_PATH, CODE_DIR };
22
24
 
23
25
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
24
26
  //
@@ -465,10 +467,21 @@ export function initSchema(db) {
465
467
  return db;
466
468
  }
467
469
  if (row.version > CURRENT_SCHEMA_VERSION) {
468
- throw new Error(
470
+ // The MESSAGE is the long-standing contract (tests/schema.test.mjs and
471
+ // tests/wal-recovery.test.mjs both match on it, and older builds throw exactly
472
+ // this), so it is unchanged. The FIELDS are additive: every consumer downstream
473
+ // used to re-derive these numbers by regexing the sentence, and the `npm i -g`
474
+ // remedy baked into it is inert for a plugin-cache install — which is the shape
475
+ // that actually hits this. lib/schema-skew.mjs turns the fields into a
476
+ // shape-correct repair; see its header for the 2026-09-08 measurement.
477
+ const err = new Error(
469
478
  `DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
470
479
  `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
480
  );
481
+ err.code = SCHEMA_SKEW_CODE;
482
+ err.dbVersion = row.version;
483
+ err.binaryVersion = CURRENT_SCHEMA_VERSION;
484
+ throw err;
472
485
  }
473
486
  }
474
487
  } catch (e) {
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from 'node:child_process';
31
31
  import { dirname, join, isAbsolute } from 'node:path';
32
32
  import { fileURLToPath, pathToFileURL } from 'node:url';
33
33
  import { homedir } from 'node:os';
34
+ import { createHash } from 'node:crypto';
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
  const INSTALL_DIR = join(__dirname, '..');
@@ -65,7 +66,16 @@ const DATA_DIR = MEM_DIR && isAbsolute(MEM_DIR) ? MEM_DIR : join(homedir(), '.cl
65
66
  // `tests/runtime-dir-single-home.test.mjs` asserts this file still carries the rule.
66
67
  const RUNTIME_DIR = join(DATA_DIR, 'runtime'); // runtime-dir:stays-put — serves swap-in-progress only; HOOK_RUNTIME_DIR carries the hook markers
67
68
  const HOOK_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
68
- const HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-lastheal');
69
+ // Per CODE HOME, not per machine. The runtime dir is data-dir-relative and therefore SHARED
70
+ // by every install shape on this box — a plugin cache version, a managed ~/.claude-mem-lite,
71
+ // a dev checkout. With one global name, a failed heal attempt for root A silenced root B's
72
+ // heal for the next six hours, and the two are repaired by different commands. The suffix is
73
+ // derived from INSTALL_DIR, which is what `cli.mjs repair` below actually acts on.
74
+ //
75
+ // node:crypto only — the pure-`node:` charter above still holds. No migration: a pre-6.4.0
76
+ // unsuffixed marker is simply ignored, which costs at most one extra heal attempt once.
77
+ const INSTALL_KEY = createHash('sha256').update(INSTALL_DIR).digest('hex').slice(0, 12);
78
+ const HEAL_MARKER = join(HOOK_RUNTIME_DIR, `hook-launcher-lastheal-${INSTALL_KEY}`);
69
79
  const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
70
80
  // Observable breakage state: written when the launcher degrades a broken install
71
81
  // to exit 0, cleared once the install is confirmed healthy. `doctor` reads it so
@@ -89,6 +99,14 @@ const BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-broken');
89
99
  // SESSION-START only — never on the per-tool hot path, where an npm run would
90
100
  // stall the user's edit.
91
101
  // Marker dir: HOOK_RUNTIME_DIR (see its definition above for why it is override-aware).
102
+ // BOTH stay machine-wide, unlike HEAL_MARKER above, and the difference is the SUBJECT of the
103
+ // repair. HEAL_MARKER gates `cli.mjs repair`, which fixes THIS install dir. This pair gates
104
+ // the native-binding rebuild, and `install.mjs rebuildBinding()` iterates
105
+ // `shape.runtimeRoots` — "Every code home on this machine, not just the one this file sits
106
+ // in", as its own comment puts it, which is also what install.mjs tells the user. Keying it
107
+ // per code home would let N homes each spawn npm within one 6h window for a repair that
108
+ // already covered all of them. A first cut of this change did exactly that; pre-ship review
109
+ // caught that the justifying comment was false for the command it gates.
92
110
  const NB_BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-broken');
93
111
  const NB_HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-lastheal');
94
112
  // Literal, not imported: the pure-`node:` charter above forbids importing lib/
@@ -111,10 +129,13 @@ const CLI_REPAIR = `node ${join(INSTALL_DIR, 'cli.mjs')} repair`;
111
129
 
112
130
  // Last-resort recovery string for users whose `cli.mjs repair` path
113
131
  // itself failed (install.mjs missing / repair errored / retry still drifting).
114
- // Duplicated in install.mjs::repair() catch; both are reachable when local
115
- // scripts are broken, so neither can import a shared constant.
132
+ // Duplicated from install.mjs::MANUAL_TARBALL_FALLBACK — the pure-`node:` charter above
133
+ // forbids importing it, and this path is reachable exactly when local scripts are broken.
134
+ // Pinned to that constant by tests/manual-fallback-sync.test.mjs, which also fails if a
135
+ // fifth surface starts hardcoding its own. Resolves the latest RELEASE tag rather than
136
+ // `/tarball` (the default branch, i.e. unreleased WIP).
116
137
  const TARBALL_FALLBACK =
117
- 'T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install';
138
+ 'T=$(mktemp -d) && U=$(curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/releases/latest | grep -o \'"tarball_url"[^,]*\' | cut -d\'"\' -f4) && curl -sL "$U" | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install';
118
139
 
119
140
  const [, , entryArg, ...rest] = process.argv;
120
141
  if (!entryArg) {