claude-mem-lite 5.1.1 → 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.1",
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.1",
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-*
@@ -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.1",
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.1",
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.1",
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/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.`);