claude-mem-lite 5.1.0 → 5.2.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.1.0",
13
+ "version": "5.2.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": "5.1.0",
3
+ "version": "5.2.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/cli/fts-check.mjs CHANGED
@@ -18,6 +18,16 @@ export function cmdFtsCheck(db, args) {
18
18
  return;
19
19
  }
20
20
 
21
+ // Exit-code contract, matching `memdir-audit` — the other diagnostic in
22
+ // CLI_COMMANDS, whose help documents "Exit 0 if every file is compliant, 1
23
+ // otherwise". Both actions used to print the failure and exit 0, so
24
+ // `fts-check rebuild && echo repaired` announced a repair that had not
25
+ // happened, and an agent reading only the status code was told the index was
26
+ // healthy. `doctor` points users at this command precisely when it is not.
27
+ //
28
+ // Findings stay on STDOUT via out() rather than moving to fail()'s stderr:
29
+ // they are the report the user asked for, not an error trace, and the details
30
+ // are worth piping. Only process.exitCode changes.
21
31
  if (action === 'check') {
22
32
  const result = checkFTSIntegrity(db);
23
33
  if (result.healthy) {
@@ -25,6 +35,7 @@ export function cmdFtsCheck(db, args) {
25
35
  } else {
26
36
  out(`[mem] FTS5 issues found:`);
27
37
  for (const d of result.details) out(` ${d}`);
38
+ process.exitCode = 1;
28
39
  }
29
40
  return;
30
41
  }
@@ -32,7 +43,10 @@ export function cmdFtsCheck(db, args) {
32
43
  if (action === 'rebuild') {
33
44
  const result = rebuildFTS(db);
34
45
  if (result.errors.length > 0) {
46
+ // Partial success is a failure of the requested operation: the caller asked
47
+ // for a rebuild and at least one index still is not rebuilt.
35
48
  out(`[mem] Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`);
49
+ process.exitCode = 1;
36
50
  } else {
37
51
  out(`[mem] Successfully rebuilt: ${result.rebuilt.join(', ')}`);
38
52
  }
package/hook-shared.mjs CHANGED
@@ -37,7 +37,7 @@ export {
37
37
  CONTINUE_KEYWORDS,
38
38
  } from './lib/handoff-constants.mjs';
39
39
 
40
- import { DAY_MS } from './lib/time-constants.mjs';
40
+ import { DAY_MS, ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
41
41
  // ─── Constants ────────────────────────────────────────────────────────────────
42
42
 
43
43
  // P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
@@ -82,12 +82,11 @@ export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
82
82
  // <memory-context> injection on quiet/adopted projects where nothing renders).
83
83
  export const KEY_CONTEXT_LIMIT = 10;
84
84
 
85
- // Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts.
86
- // handleLLMEpisode's worst-case round-trip is ~60s (delay + LLM call + DB
87
- // write); 1h leaves a wide safety margin against deleting an in-flight file.
88
- // Older orphans are crashed workers or pre-shutdown buffers that no live
89
- // caller will ever pick up, so sweeping them on SessionStart is safe.
90
- export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
85
+ // Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts. Defined in
86
+ // lib/time-constants.mjs (the zero-import leaf) because install.mjs's manual `cleanup`
87
+ // needs the same window and may only import from lib/; re-exported here so this module's
88
+ // existing importers are unchanged.
89
+ export { ORPHAN_EPISODE_AGE_MS };
91
90
 
92
91
  // `reads-<project>.txt` (bash fast-path Read tracker) is consumed by flushEpisode's
93
92
  // rename-collect on the next edit-flush, NOT by a background worker — so a project
package/install.mjs CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
72
72
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
73
73
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
74
+ import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
74
75
  import { acquireLock } from './lib/proc-lock.mjs';
75
76
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
76
77
  import { isMemHook, launcherEntryPath } from './lib/hook-prune.mjs';
@@ -2207,11 +2208,35 @@ function cleanup() {
2207
2208
  // and holding install.lock across it would block a self-heal for no reason.
2208
2209
  if (updateLock) updateLock();
2209
2210
 
2210
- // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
2211
+ // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override).
2212
+ //
2213
+ // AGE-GATED, same window the automatic sweep uses (ORPHAN_EPISODE_AGE_MS, 1h). An
2214
+ // `ep-flush-<ts>-<id>.json` is the episode handed to the summarizer, not residue: the
2215
+ // round-trip is ~60s, and deleting one mid-flight discards that episode's observations
2216
+ // silently while printing "✓ Removed". This block had no age gate at all, which made a
2217
+ // documented maintenance command — the one `doctor` tells users to run — destructive
2218
+ // against live work. Matches the P2-10 fix above (in-flight update residue) and the
2219
+ // fixture sweep below, whose comment already states the principle: a MANUAL cleanup is
2220
+ // the conservative one.
2211
2221
  const runtimeDir = MEM_RUNTIME_DIR;
2212
2222
  if (existsSync(runtimeDir)) {
2223
+ const epCutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
2224
+ let inFlight = 0;
2213
2225
  for (const f of readdirSync(runtimeDir)) {
2214
2226
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
2227
+ // Unreadable mtime → treat as in-flight and skip. Failing safe here costs one
2228
+ // stale file until the next sweep; failing open costs an episode.
2229
+ let mtimeMs;
2230
+ try {
2231
+ mtimeMs = statSync(join(runtimeDir, f)).mtimeMs;
2232
+ } catch {
2233
+ inFlight++;
2234
+ continue;
2235
+ }
2236
+ if (mtimeMs > epCutoff) {
2237
+ inFlight++;
2238
+ continue;
2239
+ }
2215
2240
  if (dryRun) {
2216
2241
  ok(`Would remove: runtime/${f}`);
2217
2242
  removed++;
@@ -2226,6 +2251,11 @@ function cleanup() {
2226
2251
  }
2227
2252
  }
2228
2253
  }
2254
+ if (inFlight > 0) {
2255
+ log(
2256
+ ` Kept ${inFlight} episode file(s) newer than 1h — possibly in flight, they sweep automatically once stale.`,
2257
+ );
2258
+ }
2229
2259
  }
2230
2260
 
2231
2261
  // Reap leaked test-fixture sandboxes from temp (mem-e2e-* / mem-audit-* / cite-*
@@ -2545,6 +2575,12 @@ async function rebuildBinding() {
2545
2575
  const verify = await ensureBetterSqlite3Working(root);
2546
2576
  if (verify.ok) {
2547
2577
  ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} — ${label} (${root})`);
2578
+ if (verify.quarantined) {
2579
+ // Say it out loud: the heal renamed a file inside the user's node_modules because
2580
+ // the shipped prebuild was present and would not load. A silent move inside a
2581
+ // dependency is the kind of thing that reads as corruption six months later.
2582
+ log(` the shipped prebuild would not load — moved aside to ${verify.quarantined}.unusable`);
2583
+ }
2548
2584
  } else {
2549
2585
  fail(`better-sqlite3 binding still unusable in ${label}: ${verify.error}`);
2550
2586
  log(`Try manually: ${nativeBindingRepairHint(root)}`);
@@ -7,6 +7,7 @@
7
7
  // sufficient — the binding can be present-but-stale after a Node upgrade.
8
8
 
9
9
  import { execSync, spawnSync } from 'node:child_process';
10
+ import { existsSync, renameSync } from 'node:fs';
10
11
  import { createRequire } from 'node:module';
11
12
  import { join } from 'node:path';
12
13
 
@@ -58,7 +59,16 @@ export const NATIVE_BINDING_SOURCE_BUILD_CMD = 'npm run --prefix node_modules/be
58
59
  export function nativeBindingRepairHint(root) {
59
60
  // Quoted: INSTALL_DIR / a plugin-cache root can contain spaces, and an unquoted `cd`
60
61
  // hands the user a command that fails on exactly the machines least able to debug it.
61
- return `cd "${root}" && ${NATIVE_BINDING_REBUILD_CMD} && ${NATIVE_BINDING_SOURCE_BUILD_CMD}`;
62
+ const npmPair = `cd "${root}" && ${NATIVE_BINDING_REBUILD_CMD} && ${NATIVE_BINDING_SOURCE_BUILD_CMD}`;
63
+ // The pair above heals a platform better-sqlite3 ships NO prebuild for. It cannot heal a
64
+ // prebuild that is present and will not load — 13 selects `prebuilds/<target>.node` on
65
+ // existence alone and prefers it over `build/`, so the addon those two commands compile
66
+ // stays shadowed. Only ensureBetterSqlite3Working moves the dead prebuild out of the way
67
+ // first, and `rebuild-binding` is how a human reaches it — so it goes FIRST, because a user
68
+ // runs the first command they are given and stops looking when it reports success.
69
+ const cli = join(root, 'cli.mjs');
70
+ if (!existsSync(cli)) return npmPair;
71
+ return `node "${cli}" rebuild-binding (or, without the CLI: ${npmPair})`;
62
72
  }
63
73
 
64
74
  // Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
@@ -193,6 +203,45 @@ export function probeBindingInFreshProcess(installDir, { timeoutMs = 30_000 } =
193
203
  };
194
204
  }
195
205
 
206
+ // Suffix for a prebuilt addon that is present and will not load. Renamed, never deleted:
207
+ // the file is evidence for whoever debugs the machine, and `npm install` puts a fresh
208
+ // prebuild back at the original name regardless.
209
+ const UNUSABLE_PREBUILD_SUFFIX = '.unusable';
210
+
211
+ /**
212
+ * The prebuilt addon better-sqlite3 would choose under `installDir`, or null.
213
+ *
214
+ * ASKED OF THE DEPENDENCY, never computed here. Which file gets loaded depends on
215
+ * platform, arch and a musl probe, and this repo has already paid twice for guessing it:
216
+ * `tests/install-bsqlite-probe.test.mjs` matched a v12 filename that v13 does not produce
217
+ * (its main assertion went vacuously true), and both sandbox phases corrupted
218
+ * `build/Release/better_sqlite3.node` for a year of releases while the resolver loaded a
219
+ * prebuild. `lib/binding.js` exports the selector itself, so use it.
220
+ *
221
+ * Out of process, like every other probe here: this runs on a path that is about to dlopen
222
+ * the result, and `getPrebuildPath()` is existence-only today but is not ours to promise.
223
+ * `lib/binding.js` is absent from the package's `exports` map, hence the file path.
224
+ *
225
+ * @param {string} installDir Directory whose node_modules holds better-sqlite3
226
+ * @returns {string|null}
227
+ */
228
+ function resolvedPrebuildPath(installDir) {
229
+ const bindingJs = join(installDir, 'node_modules', 'better-sqlite3', 'lib', 'binding.js');
230
+ if (!existsSync(bindingJs)) return null;
231
+ const r = spawnSync(
232
+ process.execPath,
233
+ [
234
+ '-e',
235
+ `const b=require(${JSON.stringify(bindingJs)});` +
236
+ `process.stdout.write((b.getPrebuildPath&&b.getPrebuildPath())||'')`,
237
+ ],
238
+ { stdio: 'pipe', encoding: 'utf8', timeout: 10_000 },
239
+ );
240
+ if (r.error || r.status !== 0) return null;
241
+ const p = String(r.stdout || '').trim();
242
+ return p && existsSync(p) ? p : null;
243
+ }
244
+
196
245
  /**
197
246
  * Verify better-sqlite3 binding works in `installDir`; if not, run
198
247
  * `npm rebuild better-sqlite3` and re-probe. Returns
@@ -295,15 +344,52 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
295
344
  : () => exec(NATIVE_BINDING_SOURCE_BUILD_CMD, { cwd: installDir, stdio: 'pipe' }));
296
345
  if (!sourceBuild) return { ok: false, error: second.error || first.error };
297
346
 
347
+ // A prebuild that is present and unloadable SHADOWS everything the source build is about
348
+ // to produce: better-sqlite3 13 picks `prebuilds/<target>.node` on existence alone and
349
+ // prefers it over `build/`. Measured 2026-09-06 with a control — corrupt prebuild +
350
+ // healthy build/Release → `wrong ELF class`; the same tree with the prebuild moved aside →
351
+ // opens; with neither → fails. Until this, `rebuild-binding` (the foreground repair doctor
352
+ // prints, deliberately given no time budget) exited 1 on that shape and the manual command
353
+ // it offered instead could not fix it either. Reproduced end-to-end in
354
+ // tests/sandbox/phaseB-npm.mjs before the fix.
355
+ //
356
+ // Deliberately inside this branch and after the npm step: quarantining without a compile to
357
+ // follow it turns "broken addon" into "no addon", which is strictly worse — so the
358
+ // time-budgeted SessionStart path (sourceBuild: false) never reaches this.
359
+ const shadowingPrebuild = resolvedPrebuildPath(installDir);
360
+ let quarantined = null;
361
+ if (shadowingPrebuild) {
362
+ try {
363
+ renameSync(shadowingPrebuild, shadowingPrebuild + UNUSABLE_PREBUILD_SUFFIX);
364
+ quarantined = shadowingPrebuild;
365
+ } catch {
366
+ // A read-only or otherwise unwritable tree. The compile below is still worth trying:
367
+ // on a platform with no prebuild it is the whole fix, and failing here would turn a
368
+ // recoverable case into a hard stop.
369
+ }
370
+ }
371
+ /** Put the tree back exactly as found — an install we could not repair must not lose a file. */
372
+ const restorePrebuild = () => {
373
+ if (!quarantined) return;
374
+ try {
375
+ renameSync(quarantined + UNUSABLE_PREBUILD_SUFFIX, quarantined);
376
+ } catch {
377
+ // Nothing better to do; the error the caller gets already says the heal failed.
378
+ }
379
+ };
380
+
298
381
  try {
299
382
  await sourceBuild();
300
383
  } catch (e) {
384
+ restorePrebuild();
301
385
  return { ok: false, error: `source build failed: ${e.message}` };
302
386
  }
303
387
 
304
388
  const third = await verify();
305
- if (third.ok) return { ok: true, action: 'compiled' };
389
+ if (third.ok)
390
+ return quarantined ? { ok: true, action: 'compiled', quarantined } : { ok: true, action: 'compiled' };
306
391
 
392
+ restorePrebuild();
307
393
  return { ok: false, error: third.error || second.error || first.error };
308
394
  }
309
395
 
@@ -101,5 +101,13 @@ export function previewDeleteRows(db, ids) {
101
101
  const lines = rows.map(
102
102
  (r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`,
103
103
  );
104
- return { rows, lines };
104
+ // Requested ids with no row. Both faces used to derive this themselves and only
105
+ // in their CONFIRM branch, so the PREVIEW — whose entire job is to show what is
106
+ // about to happen — silently omitted them: `delete 42,43,44` listed two rows and
107
+ // the user learned 43 never existed only after confirming. Computed here so the
108
+ // CLI and mem_delete report the same set from one place. Order follows the
109
+ // caller's id order, which is the order they typed.
110
+ const found = new Set(rows.map((r) => r.id));
111
+ const missing = ids.filter((id) => !found.has(id));
112
+ return { rows, lines, missing };
105
113
  }
@@ -65,6 +65,27 @@ export const PINNED_INJ_THRESHOLD = 8;
65
65
  // Hence: order matters, and `demote_pinned` MUST come after `boost`.
66
66
  export const DEFAULT_MAINTAIN_OPS = Object.freeze(['cleanup', 'decay', 'boost', 'demote_pinned']);
67
67
 
68
+ // Every op `runMaintainOps` dispatches on — the SET, not a run order (the order a run
69
+ // uses is DEFAULT_MAINTAIN_OPS' above, or the caller's array).
70
+ //
71
+ // Lives here because two faces validate against it and they are not allowed to drift:
72
+ // the CLI (`mem-cli.mjs`, which rejects an unknown `--ops` value) and the MCP tool
73
+ // schema (`tool-schemas.mjs`, a Zod `z.enum` the model reads). The MCP copy stays
74
+ // hand-written on purpose — that enum is LLM-visible metadata, so reordering it to
75
+ // derive from this array would be a behavioural change to tool routing for no gain —
76
+ // and `tests/maintain-ops-invariant.test.mjs` diffs the two SETS instead, the same way
77
+ // tests/audit-silent-20260814.test.mjs diffs the two hook sets.
78
+ export const ALL_MAINTAIN_OPS = Object.freeze([
79
+ 'cleanup',
80
+ 'decay',
81
+ 'boost',
82
+ 'demote_pinned',
83
+ 'dedup',
84
+ 'purge_stale',
85
+ 'rebuild_vectors',
86
+ 'vacuum',
87
+ ]);
88
+
68
89
  // Opt-out for the v3.76.0 default change. Scoped to the DEFAULT set ONLY — an
69
90
  // explicit `--ops demote_pinned` / `operations:["demote_pinned"]` still runs. An
70
91
  // accepted value that silently means something else is worse than an unsupported
@@ -19,3 +19,20 @@ const MINUTE_MS = 60 * SECOND_MS;
19
19
  const HOUR_MS = 60 * MINUTE_MS;
20
20
 
21
21
  export const DAY_MS = 24 * HOUR_MS;
22
+
23
+ /**
24
+ * How long an `ep-flush-*` / `pending-*` runtime file must sit untouched before any
25
+ * sweeper may treat it as an orphan rather than as work in flight.
26
+ *
27
+ * handleLLMEpisode's worst-case round-trip is ~60s (delay + LLM call + DB write), so 1h
28
+ * is a wide safety margin. Anything older is a crashed worker or a pre-shutdown buffer
29
+ * that no live caller will pick up.
30
+ *
31
+ * It lives HERE, in the zero-import leaf, because it now has two consumers that cannot
32
+ * share a module: the automatic sweep in `hook-shared.mjs` (which re-exports it, so its
33
+ * own importers are unchanged) and `install.mjs`'s manual `cleanup`, which may only
34
+ * import from `lib/`. That split is exactly how the two drifted: `cleanup` deleted every
35
+ * `ep-flush-*` with NO age gate at all, discarding a seconds-old in-flight episode while
36
+ * printing "✓ Removed" — and `doctor` recommends running `cleanup`.
37
+ */
38
+ export const ORPHAN_EPISODE_AGE_MS = HOUR_MS;
package/mem-cli.mjs CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  STALE_AGE_MS,
46
46
  PINNED_INJ_THRESHOLD,
47
47
  resolveDefaultMaintainOps,
48
+ ALL_MAINTAIN_OPS,
48
49
  } from './lib/maintain-core.mjs';
49
50
  // snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
50
51
  // (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
@@ -1898,7 +1899,7 @@ function cmdDelete(db, args) {
1898
1899
 
1899
1900
  const confirm = flags.confirm === true || flags.confirm === 'true';
1900
1901
  // Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
1901
- const { rows, lines: previewLines } = previewDeleteRows(db, ids);
1902
+ const { rows, lines: previewLines, missing } = previewDeleteRows(db, ids);
1902
1903
 
1903
1904
  if (rows.length === 0) {
1904
1905
  fail('[mem] No observations found for given IDs');
@@ -1908,6 +1909,7 @@ function cmdDelete(db, args) {
1908
1909
  if (!confirm) {
1909
1910
  out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
1910
1911
  for (const line of previewLines) out(line);
1912
+ if (missing.length > 0) out(`[mem] Note: ID(s) ${missing.join(', ')} not found and will be skipped.`);
1911
1913
  out('[mem] Run with --confirm to execute deletion.');
1912
1914
  return;
1913
1915
  }
@@ -1916,7 +1918,6 @@ function cmdDelete(db, args) {
1916
1918
  // transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
1917
1919
  // mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
1918
1920
  const result = deleteObservations(db, ids);
1919
- const missing = ids.filter((id) => !rows.some((r) => r.id === id));
1920
1921
  const recoveredNote =
1921
1922
  result.recoveredChildren > 0
1922
1923
  ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`
@@ -2498,6 +2499,13 @@ function cmdCompress(db, args) {
2498
2499
 
2499
2500
  // ─── Maintain ────────────────────────────────────────────────────────────────
2500
2501
 
2502
+ // Shared by BOTH maintain branches. It used to be a local const inside `execute`,
2503
+ // which is why `scan` — the preview step — silently accepted `--ops purge-stale`
2504
+ // (hyphen for underscore), printed a full report and exited 0, leaving the typo to
2505
+ // surface only on the run the preview was supposed to de-risk. The list itself now
2506
+ // comes from lib/maintain-core.mjs so this face and the MCP schema cannot drift.
2507
+ const VALID_MAINTAIN_OPS = ALL_MAINTAIN_OPS;
2508
+
2501
2509
  function cmdMaintain(db, args) {
2502
2510
  const { positional, flags } = parseArgs(args);
2503
2511
  const action = positional[0];
@@ -2518,12 +2526,32 @@ function cmdMaintain(db, args) {
2518
2526
  const baseParams = project ? [project] : [];
2519
2527
 
2520
2528
  if (action === 'scan') {
2529
+ // Validate --ops here too, with the same list and the same message `execute`
2530
+ // uses. Catching the typo in the PREVIEW is the whole point: this is the step a
2531
+ // user runs to find out what would happen, and it used to ignore the flag
2532
+ // wholesale. Only validated when present — a plain `maintain scan` is unchanged.
2533
+ if (flags.ops !== undefined) {
2534
+ const scanOps = String(flags.ops)
2535
+ .split(',')
2536
+ .map((s) => s.trim());
2537
+ const invalid = scanOps.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
2538
+ if (invalid.length > 0) {
2539
+ fail(`[mem] Unknown operation(s): ${invalid.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
2540
+ return;
2541
+ }
2542
+ }
2543
+
2521
2544
  const staleAge = Date.now() - STALE_AGE_MS;
2522
2545
  const mctx = { projectFilter, baseParams, staleAge };
2523
2546
  const duplicates = findDuplicates(db, mctx);
2524
2547
  const stats = maintenanceStats(db, mctx);
2525
2548
 
2526
2549
  out(`[mem] Maintenance scan:`);
2550
+ // The ops are valid but scan is not scoped by them — say so instead of letting a
2551
+ // scoped-looking invocation imply a scoped report.
2552
+ if (flags.ops !== undefined) {
2553
+ out(` (--ops is an execute-time filter; this scan reports every category)`);
2554
+ }
2527
2555
  out(` Total active: ${stats.total}`);
2528
2556
  out(` Near-duplicate pairs: ${duplicates.length}`);
2529
2557
  out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
@@ -2567,27 +2595,17 @@ function cmdMaintain(db, args) {
2567
2595
  }
2568
2596
 
2569
2597
  // Execute
2570
- const VALID_OPS = [
2571
- 'cleanup',
2572
- 'decay',
2573
- 'boost',
2574
- 'demote_pinned',
2575
- 'dedup',
2576
- 'purge_stale',
2577
- 'rebuild_vectors',
2578
- 'vacuum',
2579
- ];
2580
2598
  // Distinguish flag-absent (use default op set) from flag-present-but-empty
2581
2599
  // (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
2582
- // to the destructive default set and EXECUTED it; route it to the VALID_OPS check
2600
+ // to the destructive default set and EXECUTED it; route it to the VALID_MAINTAIN_OPS check
2583
2601
  // below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
2584
2602
  // was the literal `cleanup,decay,boost` when this was written; it now comes from
2585
2603
  // DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
2586
2604
  const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
2587
2605
  const ops = opsStr.split(',').map((s) => s.trim());
2588
- const invalidOps = ops.filter((op) => !VALID_OPS.includes(op));
2606
+ const invalidOps = ops.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
2589
2607
  if (invalidOps.length > 0) {
2590
- fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_OPS.join(', ')}`);
2608
+ fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
2591
2609
  return;
2592
2610
  }
2593
2611
  const staleAge = Date.now() - STALE_AGE_MS;
@@ -3182,6 +3200,8 @@ Commands:
3182
3200
  --json Machine-readable output (plain doctor run)
3183
3201
 
3184
3202
  fts-check <check|rebuild> FTS5 index check or rebuild
3203
+ Exit 0 when every index is healthy / rebuilt, 1 otherwise —
3204
+ so "fts-check rebuild && <next step>" is safe to chain.
3185
3205
 
3186
3206
  stats Show memory statistics
3187
3207
  --project P Filter by project
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "5.1.0",
9
+ "version": "5.2.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": "5.1.0",
3
+ "version": "5.2.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",
package/scripts/setup.sh CHANGED
@@ -190,7 +190,20 @@ if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
190
190
  # Both commands, `&&` not `||`: `npm rebuild` exits 0 without compiling on
191
191
  # better-sqlite3 13 (no install script to run), so it never signals failure and
192
192
  # an `||` chain would never reach the source build. A20260906-R8-P1-1.
193
- mark_deps_broken "better-sqlite3 binding probe/rebuild failed (npm >= 12 blocks compile scripts by default)" "npm rebuild better-sqlite3 --dangerously-allow-all-scripts && npm run --prefix node_modules/better-sqlite3 build-release"
193
+ #
194
+ # …and the pair alone is still not enough. It heals a platform 13 ships no prebuild
195
+ # for; it cannot heal a prebuild that is PRESENT and will not load, because 13 prefers
196
+ # `prebuilds/<target>.node` over anything the source build produces. This string is what
197
+ # the SessionStart dashboard prints as `Repair:`, so it leads with the CLI, which is the
198
+ # only path that moves the dead prebuild aside first (lib/binding-probe.mjs). Mirrors
199
+ # nativeBindingRepairHint(); this file may not import lib/, hence the duplication —
200
+ # tests/audit-r8-binding-repair-hint.test.mjs pins the set of files allowed to do that.
201
+ # mark_deps_broken already prefixes `cd <root> && `, so these are root-relative.
202
+ NB_REPAIR="npm rebuild better-sqlite3 --dangerously-allow-all-scripts && npm run --prefix node_modules/better-sqlite3 build-release"
203
+ if [[ -f "$ROOT/cli.mjs" ]]; then
204
+ NB_REPAIR="node cli.mjs rebuild-binding (or, without the CLI: $NB_REPAIR)"
205
+ fi
206
+ mark_deps_broken "better-sqlite3 binding probe/rebuild failed (npm >= 12 blocks compile scripts by default)" "$NB_REPAIR"
194
207
  fi
195
208
  fi
196
209
 
package/server.mjs CHANGED
@@ -961,7 +961,7 @@ server.registerTool(
961
961
  },
962
962
  safeHandler(async (args) => {
963
963
  // Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
964
- const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
964
+ const { rows, lines: previewLines, missing } = previewDeleteRows(db, args.ids);
965
965
 
966
966
  if (rows.length === 0) {
967
967
  return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
@@ -969,6 +969,8 @@ server.registerTool(
969
969
 
970
970
  if (!args.confirm) {
971
971
  const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
972
+ if (missing.length > 0)
973
+ lines.push(`\nNote: ID(s) ${missing.join(', ')} not found and will be skipped.`);
972
974
  lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
973
975
  return { content: [{ type: 'text', text: lines.join('\n') }] };
974
976
  }
@@ -978,7 +980,6 @@ server.registerTool(
978
980
  // with the CLI `delete` path (was inlined + kept in sync by parity comments).
979
981
  const result = deleteObservations(db, args.ids);
980
982
 
981
- const missing = args.ids.filter((id) => !rows.some((r) => r.id === id));
982
983
  const msg = [`Deleted ${result.deleted} observation(s).`];
983
984
  if (result.recoveredChildren > 0)
984
985
  msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);