claude-mem-lite 5.1.1 → 5.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.1.1",
13
+ "version": "5.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": "5.1.1",
3
+ "version": "5.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/README.md CHANGED
@@ -120,7 +120,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
120
120
  - **Schema auto-migration** -- Idempotent `ALTER TABLE` migrations run on every startup, safely adding new columns and indexes without data loss
121
121
  - **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
122
122
  - **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
123
- - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/clear` or `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap
123
+ - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm does not currently fire** (tracked as R10-P1-1). The measurement, first: on the maintainer's own install `session_handoffs` holds 4 `exit` rows and **0** `clear` rows — the `clear` snapshot has never once been written. The mechanism, as a hypothesis: SessionStart treats a session file left on disk as the marker of a previous session that ended without `Stop` (`hook.mjs:2393-2405`, whose own comment reads "Normal `/exit` deletes the file, so this only triggers for `/clear`, `/compact`, or crash recovery"), and that file is exactly what `Stop` deletes (`hook.mjs:1542`) — which would make the branch unreachable if `Stop` runs at the end of every assistant turn rather than once per session. **That last clause is NOT verified**, and it is the whole question: the fix differs depending on whether Claude Code rotates its session id across `/clear`, so a real `/clear` stdin capture comes before any code change
124
124
  - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
125
125
  - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
126
126
  - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
@@ -431,7 +431,9 @@ FTS5 indexes: `observations_fts` (title, subtitle, narrative, text, facts, conce
431
431
 
432
432
  ```
433
433
  SessionStart
434
- -> Generate session ID (or save handoff snapshot on /clear)
434
+ -> Generate session ID
435
+ (the /clear|/compact handoff branch here is currently unreachable — R10-P1-1,
436
+ see Cross-session handoff above)
435
437
  -> Mark stale sessions (>24h active) as abandoned
436
438
  -> Clean orphaned/stale lock files
437
439
  -> Query recent observations (24h)
@@ -458,8 +460,9 @@ UserPromptSubmit (two parallel paths)
458
460
 
459
461
  Stop
460
462
  -> Flush final episode buffer
461
- -> Save handoff snapshot (on /exit)
463
+ -> Save handoff snapshot (type 'exit')
462
464
  -> Mark session completed
465
+ -> Delete the session file <- what makes the SessionStart /clear branch unreachable
463
466
  -> Spawn LLM summary worker (poll-based wait)
464
467
  ```
465
468
 
@@ -796,6 +799,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
796
799
  | `MEM_OR_FALLBACK_MAX_TOKENS` | Max query tokens allowed into the OR fallback (∈ [0,50]). | `8` |
797
800
  | `CLAUDE_MEM_CJK_PREC_MIN` | Precision floor for CJK segmentation candidates. | `0.2` |
798
801
  | `CLAUDE_MEM_AUTO_DEEP` | `0` disables automatic deep-search escalation (one Haiku call rewriting a weak query into keyword/concept/HyDE variants). Explicit `deep: true` still works. | _(auto)_ |
802
+ | `CLAUDE_MEM_DEEP_DISCLOSURE` | `off` suppresses the one-line caveat appended to a multi-variant deep result. The caveat exists because deep search fills the page even when the corpus cannot answer — measured at 10 of 10 slots on queries whose answers had been removed (`benchmark/deep-search-holdout.mjs`) — and `deep` is AUTO by default on the MCP surface, i.e. it escalates precisely when the honest answer is "nothing". It does not change retrieval, ranking, or which rows are returned. | _(on)_ |
799
803
  | `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
800
804
  | `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
801
805
  | `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
@@ -823,7 +827,7 @@ what is already stored — only whether new work runs.
823
827
 
824
828
  | Variable | Description | Default |
825
829
  |----------|-------------|---------|
826
- | `CLAUDE_MEM_SKIP_SUMMARY` | Skip the LLM session summary at Stop. | _(runs)_ |
830
+ | `CLAUDE_MEM_SKIP_SUMMARY` | Skip the background LLM session summary at **both** of its spawn sites — `Stop`, and the SessionStart `/clear`-handoff path. Until v5.3.0 only the `Stop` one honoured it. | _(runs)_ |
827
831
  | `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush — observations are still batched, just not summarized. | _(runs)_ |
828
832
  | `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
829
833
  | `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
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/deep-search.mjs CHANGED
@@ -185,6 +185,66 @@ export function resolveDeepMode(explicitDeep, { surface, env = process.env } = {
185
185
  return surface === 'mcp' ? 'auto' : 'normal';
186
186
  }
187
187
 
188
+ /**
189
+ * One-line disclosure for a deep result set, written to the channel the CALLER reads.
190
+ *
191
+ * D#3. benchmark/deep-search-holdout.mjs asks the suite's own queries of a corpus with
192
+ * their relevant_ids deleted, so the correct answer is zero rows and every returned row is
193
+ * a false positive by construction. It reads mean FP@10 = 10.00 across 12/12 queries: deep
194
+ * fills every slot, every time. The single-query baseline returns 1-2 rows on the same
195
+ * negatives — the flood is the UNION across paraphrase variants, which is also where deep's
196
+ * recall win comes from, so this is not a bug to be thresholded away. Three gates were
197
+ * tested against both arms and rejected; suppressing OR-fallback on rewrites takes deep
198
+ * R@10 from 0.7383 to 0.3962, because the vocab-mismatch win IS that fallback. rrfFuseN
199
+ * fuses by RANK, so no magnitude signal survives the merge for a downstream floor to read.
200
+ *
201
+ * The discrimination is not available at this layer, so the honest move is to hand the
202
+ * caller what the caller cannot otherwise see. Two things were missing:
203
+ * 1. `escalated` — that the widening happened BECAUSE the plain search was weak — was
204
+ * announced on stderr only. On the MCP surface stderr never reaches the model, which
205
+ * reads tool results; auto is the default there (resolveDeepMode, surface 'mcp'), so
206
+ * the one caller who most needs the caveat was the one who could not see it.
207
+ * 2. Nothing said a full page can be entirely adjacent rows.
208
+ *
209
+ * Silent in two cases, and both silences are load-bearing:
210
+ * - `variantCount <= 1`: with no usable rewrite, deep IS the baseline, and the existing
211
+ * "== baseline" note already says so. Crying flood there would train callers to ignore
212
+ * the line on the runs where it matters.
213
+ * - `rowCount <= 0`: "rows above may be adjacent" is nonsense with no rows above, and
214
+ * both zero-result faces ALREADY say the rewrite ran and found nothing — so the note
215
+ * would restate the page's own conclusion in more words. Caught in pre-ship review,
216
+ * which is also why `rowCount` is a parameter rather than a check in each face: two
217
+ * surfaces deciding this separately is how they drift.
218
+ *
219
+ * @param {object} [opts]
220
+ * @param {boolean} [opts.escalated] the result came from auto-escalation, not an explicit ask
221
+ * @param {number} [opts.escalatedObsCount] hits the plain search returned before widening
222
+ * @param {number} [opts.variantCount] query variants fused (1 = rewrite produced nothing)
223
+ * @param {number} [opts.rowCount] rows actually shown to the caller
224
+ * @param {object} [opts.env=process.env] opt-out: CLAUDE_MEM_DEEP_DISCLOSURE=off
225
+ * @returns {string} the note, or '' when it should not be shown
226
+ */
227
+ export function deepDisclosureNote({
228
+ escalated = false,
229
+ escalatedObsCount = 0,
230
+ variantCount = 0,
231
+ rowCount = 0,
232
+ env = process.env,
233
+ } = {}) {
234
+ if (String(env.CLAUDE_MEM_DEEP_DISCLOSURE || '').toLowerCase() === 'off') return '';
235
+ if (!(variantCount > 1)) return '';
236
+ if (!(rowCount > 0)) return '';
237
+ const why = escalated
238
+ ? `auto-escalated after the plain search returned ${escalatedObsCount} hit(s)`
239
+ : 'explicitly requested';
240
+ return (
241
+ `[deep search: ${why}. Rows above may be ADJACENT to the query rather than answers to it — ` +
242
+ `on a corpus that cannot answer, the widened query still fills the page (measured: 10 of 10 ` +
243
+ `slots on queries whose answers had been removed). Judge each row by its own text; ` +
244
+ `"nothing here actually answers this" is a valid conclusion.]`
245
+ );
246
+ }
247
+
188
248
  // Echoes hook-llm.mjs MEMORY_INPUT_GUARD (kept inline rather than imported so
189
249
  // this module — and the tests that import it — never pull in hook-llm's
190
250
  // native-heavy chain; see #8729). Same security intent: the query is untrusted.
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/hook.mjs CHANGED
@@ -2038,8 +2038,15 @@ function saveHandoffAndFastSummary(
2038
2038
  .get(prevProject || project, 'clear', handoffScopeId);
2039
2039
  } catch {}
2040
2040
 
2041
- // Generate session summary for previous session (background Haiku — richer version)
2042
- spawnBackground('llm-summary', prevSessionId, prevProject || project);
2041
+ // Generate session summary for previous session (background Haiku — richer version).
2042
+ // Honours CLAUDE_MEM_SKIP_SUMMARY like the handleStop site does. This is the SAME
2043
+ // worker, and the flag's whole purpose (see the comment at its other call site) is
2044
+ // that llm-summary recreates a test's sandbox tree behind its cleanup — timed at
2045
+ // 432ms there. Gating one of two call sites left the flag unable to do the one job
2046
+ // it exists for whenever this branch is reached.
2047
+ if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) {
2048
+ spawnBackground('llm-summary', prevSessionId, prevProject || project);
2049
+ }
2043
2050
 
2044
2051
  // Build fast synchronous summary for immediate context availability.
2045
2052
  // Background llm-summary will produce a richer Haiku version later;
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
@@ -35,7 +35,13 @@ import {
35
35
  BROWSE_TIERS,
36
36
  BROWSE_TIER_LABELS,
37
37
  } from './lib/browse-core.mjs';
38
- import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
38
+ import {
39
+ deepSearch,
40
+ resolveDeepMode,
41
+ shouldEscalateToDeep,
42
+ autoDeepLlmReady,
43
+ deepDisclosureNote,
44
+ } from './deep-search.mjs';
39
45
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
40
46
  import {
41
47
  runMaintainOps,
@@ -45,6 +51,7 @@ import {
45
51
  STALE_AGE_MS,
46
52
  PINNED_INJ_THRESHOLD,
47
53
  resolveDefaultMaintainOps,
54
+ ALL_MAINTAIN_OPS,
48
55
  } from './lib/maintain-core.mjs';
49
56
  // snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
50
57
  // (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
@@ -432,6 +439,19 @@ async function cmdSearch(db, args, { llm } = {}) {
432
439
  : '[mem] Deep search: rerank produced no usable order; kept fused order\n',
433
440
  );
434
441
  }
442
+ // D#3. Same disclosure the MCP surface puts in its payload, on the channel this face
443
+ // already uses for deep notes — a Bash caller reads stderr alongside stdout, so unlike
444
+ // MCP there is no split here. One home for the text (deep-search.mjs) because two faces
445
+ // writing their own wording is this repo's most-paid-for defect class.
446
+ if (isDeep) {
447
+ const disclosure = deepDisclosureNote({
448
+ escalated: res.escalated,
449
+ escalatedObsCount: res.escalatedObsCount,
450
+ variantCount: deepVariants?.length ?? 0,
451
+ rowCount: paged?.length ?? 0,
452
+ });
453
+ if (disclosure) process.stderr.write(`${disclosure}\n`);
454
+ }
435
455
 
436
456
  // "nothing matched" (no offset) vs "this page is empty" (with offset) — the two
437
457
  // CLI messages. preFinalizeCount is the pre-pagination population (post-tier).
@@ -1898,7 +1918,7 @@ function cmdDelete(db, args) {
1898
1918
 
1899
1919
  const confirm = flags.confirm === true || flags.confirm === 'true';
1900
1920
  // Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
1901
- const { rows, lines: previewLines } = previewDeleteRows(db, ids);
1921
+ const { rows, lines: previewLines, missing } = previewDeleteRows(db, ids);
1902
1922
 
1903
1923
  if (rows.length === 0) {
1904
1924
  fail('[mem] No observations found for given IDs');
@@ -1908,6 +1928,7 @@ function cmdDelete(db, args) {
1908
1928
  if (!confirm) {
1909
1929
  out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
1910
1930
  for (const line of previewLines) out(line);
1931
+ if (missing.length > 0) out(`[mem] Note: ID(s) ${missing.join(', ')} not found and will be skipped.`);
1911
1932
  out('[mem] Run with --confirm to execute deletion.');
1912
1933
  return;
1913
1934
  }
@@ -1916,7 +1937,6 @@ function cmdDelete(db, args) {
1916
1937
  // transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
1917
1938
  // mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
1918
1939
  const result = deleteObservations(db, ids);
1919
- const missing = ids.filter((id) => !rows.some((r) => r.id === id));
1920
1940
  const recoveredNote =
1921
1941
  result.recoveredChildren > 0
1922
1942
  ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`
@@ -2498,6 +2518,13 @@ function cmdCompress(db, args) {
2498
2518
 
2499
2519
  // ─── Maintain ────────────────────────────────────────────────────────────────
2500
2520
 
2521
+ // Shared by BOTH maintain branches. It used to be a local const inside `execute`,
2522
+ // which is why `scan` — the preview step — silently accepted `--ops purge-stale`
2523
+ // (hyphen for underscore), printed a full report and exited 0, leaving the typo to
2524
+ // surface only on the run the preview was supposed to de-risk. The list itself now
2525
+ // comes from lib/maintain-core.mjs so this face and the MCP schema cannot drift.
2526
+ const VALID_MAINTAIN_OPS = ALL_MAINTAIN_OPS;
2527
+
2501
2528
  function cmdMaintain(db, args) {
2502
2529
  const { positional, flags } = parseArgs(args);
2503
2530
  const action = positional[0];
@@ -2518,12 +2545,32 @@ function cmdMaintain(db, args) {
2518
2545
  const baseParams = project ? [project] : [];
2519
2546
 
2520
2547
  if (action === 'scan') {
2548
+ // Validate --ops here too, with the same list and the same message `execute`
2549
+ // uses. Catching the typo in the PREVIEW is the whole point: this is the step a
2550
+ // user runs to find out what would happen, and it used to ignore the flag
2551
+ // wholesale. Only validated when present — a plain `maintain scan` is unchanged.
2552
+ if (flags.ops !== undefined) {
2553
+ const scanOps = String(flags.ops)
2554
+ .split(',')
2555
+ .map((s) => s.trim());
2556
+ const invalid = scanOps.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
2557
+ if (invalid.length > 0) {
2558
+ fail(`[mem] Unknown operation(s): ${invalid.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
2559
+ return;
2560
+ }
2561
+ }
2562
+
2521
2563
  const staleAge = Date.now() - STALE_AGE_MS;
2522
2564
  const mctx = { projectFilter, baseParams, staleAge };
2523
2565
  const duplicates = findDuplicates(db, mctx);
2524
2566
  const stats = maintenanceStats(db, mctx);
2525
2567
 
2526
2568
  out(`[mem] Maintenance scan:`);
2569
+ // The ops are valid but scan is not scoped by them — say so instead of letting a
2570
+ // scoped-looking invocation imply a scoped report.
2571
+ if (flags.ops !== undefined) {
2572
+ out(` (--ops is an execute-time filter; this scan reports every category)`);
2573
+ }
2527
2574
  out(` Total active: ${stats.total}`);
2528
2575
  out(` Near-duplicate pairs: ${duplicates.length}`);
2529
2576
  out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
@@ -2567,27 +2614,17 @@ function cmdMaintain(db, args) {
2567
2614
  }
2568
2615
 
2569
2616
  // 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
2617
  // Distinguish flag-absent (use default op set) from flag-present-but-empty
2581
2618
  // (`--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
2619
+ // to the destructive default set and EXECUTED it; route it to the VALID_MAINTAIN_OPS check
2583
2620
  // below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
2584
2621
  // was the literal `cleanup,decay,boost` when this was written; it now comes from
2585
2622
  // DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
2586
2623
  const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
2587
2624
  const ops = opsStr.split(',').map((s) => s.trim());
2588
- const invalidOps = ops.filter((op) => !VALID_OPS.includes(op));
2625
+ const invalidOps = ops.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
2589
2626
  if (invalidOps.length > 0) {
2590
- fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_OPS.join(', ')}`);
2627
+ fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
2591
2628
  return;
2592
2629
  }
2593
2630
  const staleAge = Date.now() - STALE_AGE_MS;
@@ -3182,6 +3219,8 @@ Commands:
3182
3219
  --json Machine-readable output (plain doctor run)
3183
3220
 
3184
3221
  fts-check <check|rebuild> FTS5 index check or rebuild
3222
+ Exit 0 when every index is healthy / rebuilt, 1 otherwise —
3223
+ so "fts-check rebuild && <next step>" is safe to chain.
3185
3224
 
3186
3225
  stats Show memory statistics
3187
3226
  --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.3.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.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": "5.1.1",
3
+ "version": "5.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",
package/server.mjs CHANGED
@@ -10,7 +10,13 @@ import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR } from './schema.mjs';
11
11
  import { reRankWithContext, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
- import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
13
+ import {
14
+ deepSearch,
15
+ resolveDeepMode,
16
+ shouldEscalateToDeep,
17
+ autoDeepLlmReady,
18
+ deepDisclosureNote,
19
+ } from './deep-search.mjs';
14
20
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
21
  import {
16
22
  resolveAnchorToken,
@@ -577,6 +583,18 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
577
583
  if (r.reranked && output.content?.[0]?.type === 'text') {
578
584
  output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
579
585
  }
586
+ // D#3. The stderr line above is for humans and logs; an MCP client reads the tool RESULT,
587
+ // so the escalation fact and the adjacency caveat have to land in the payload or the one
588
+ // caller running deep=auto by default never sees either.
589
+ if (r.isDeep && output.content?.[0]?.type === 'text') {
590
+ const disclosure = deepDisclosureNote({
591
+ escalated: r.escalated,
592
+ escalatedObsCount: r.escalatedObsCount,
593
+ variantCount: r.variants?.length ?? 0,
594
+ rowCount: r.page?.length ?? 0,
595
+ });
596
+ if (disclosure) output.content[0].text += `\n\n${disclosure}`;
597
+ }
580
598
  appendDeferredTrailer(output);
581
599
 
582
600
  // Expose structured fields for tests + the MCP content blob.
@@ -961,7 +979,7 @@ server.registerTool(
961
979
  },
962
980
  safeHandler(async (args) => {
963
981
  // Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
964
- const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
982
+ const { rows, lines: previewLines, missing } = previewDeleteRows(db, args.ids);
965
983
 
966
984
  if (rows.length === 0) {
967
985
  return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
@@ -969,6 +987,8 @@ server.registerTool(
969
987
 
970
988
  if (!args.confirm) {
971
989
  const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
990
+ if (missing.length > 0)
991
+ lines.push(`\nNote: ID(s) ${missing.join(', ')} not found and will be skipped.`);
972
992
  lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
973
993
  return { content: [{ type: 'text', text: lines.join('\n') }] };
974
994
  }
@@ -978,7 +998,6 @@ server.registerTool(
978
998
  // with the CLI `delete` path (was inlined + kept in sync by parity comments).
979
999
  const result = deleteObservations(db, args.ids);
980
1000
 
981
- const missing = args.ids.filter((id) => !rows.some((r) => r.id === id));
982
1001
  const msg = [`Deleted ${result.deleted} observation(s).`];
983
1002
  if (result.recoveredChildren > 0)
984
1003
  msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);