claude-mem-lite 3.93.0 → 3.94.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": "3.93.0",
13
+ "version": "3.94.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": "3.93.0",
3
+ "version": "3.94.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
@@ -897,8 +897,9 @@ Set by the tool or by the test harness. Setting these by hand is not supported:
897
897
 
898
898
  The last two are worth one more sentence each, because they are the ones a harness reaches
899
899
  for. `CLAUDE_MEM_RUNTIME_DIR` relocates the runtime directory for hook-written state — markers,
900
- cooldowns, hook-error telemetry, the native-binding breakage marker, `metrics/`, episode
901
- buffers. Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
900
+ cooldowns, hook-error telemetry, the native-binding breakage marker, episode buffers.
901
+ (`metrics/` is NOT in that set: it is a sibling of `runtime/` under the data dir and moves
902
+ with `CLAUDE_MEM_DIR` only.) Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
902
903
  split the runtime rather than moving it. Installation-identity state (`install.lock`,
903
904
  `update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
904
905
  installers pointed at different override directories would otherwise each take their own
package/cli/doctor.mjs CHANGED
@@ -76,6 +76,7 @@ export async function cmdDoctor(db, args) {
76
76
  out(` id_mix_other (fixture-style equality, info-only): ${audit.id_mix_other}`);
77
77
  out(` missing_mem_id (sdk_sessions w/ NULL after 5min): ${audit.missing_mem_id}`);
78
78
  out(` orphan_obs (observations w/o matching session): ${audit.orphan_obs}`);
79
+ out(` obs_importance_null (NULL importance, P3-14): ${audit.obs_importance_null}`);
79
80
  if (audit.id_mix_other > 0 && audit.id_mix_uuid_shape === 0) {
80
81
  out('\n Notes:');
81
82
  out(' • id_mix_other > 0 with uuid_shape=0 is typically benign — usually means insertSession({id:\'X\'}) test scaffold or pre-v30 data with non-UUID equal values. Does NOT drive failure.');
@@ -85,6 +86,7 @@ export async function cmdDoctor(db, args) {
85
86
  if (audit.id_mix_uuid_shape > 0) out(' • id_mix_uuid_shape > 0 — production v2.33.1 bug-pattern rows present. Investigate via SQL: SELECT * FROM sdk_sessions WHERE memory_session_id = content_session_id AND length(memory_session_id) = 36;');
86
87
  if (audit.missing_mem_id > 0) out(' • missing_mem_id rows are sessions whose mem-internal ID was never populated — likely SessionStart write that didn\'t reach Stop');
87
88
  if (audit.orphan_obs > 0) out(' • orphan_obs are observations referencing a sdk_sessions row that was deleted (FK CASCADE failed historically before v28)');
89
+ if (audit.obs_importance_null > 0) out(' • obs_importance_null rows read as importance 1 by the CLI/hook maintenance pass (COALESCE) and as "skip" by the MCP idle pass (bare `importance <= 1`), so they decay on one face and not the other. New rows cannot reach this state (lib/observation-write.mjs coerces nullish to 1); these predate that or were written around it. Fix: UPDATE observations SET importance = 1 WHERE importance IS NULL;');
88
90
  }
89
91
  }
90
92
  if (!audit.healthy) process.exitCode = 1;
package/hook-update.mjs CHANGED
@@ -33,7 +33,7 @@ const INSTALL_DIR = CODE_DIR; // ~/.claude-mem-lite/ (code)
33
33
  // DB_DIR), matching hook-shared RUNTIME_DIR and install.mjs doctor's read path.
34
34
  // Equal to INSTALL_DIR unless CLAUDE_MEM_DIR relocates the data dir.
35
35
  const STATE_DIR = DB_DIR;
36
- const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
36
+ const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json'); // runtime-dir:stays-put — installation identity
37
37
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
38
38
  const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
39
39
  // When rate-limited we got NO release data, so re-check sooner than the normal 24h
@@ -641,7 +641,7 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
641
641
  // hooks are best-effort, so losing one fire beats importing a mixed module graph.
642
642
  // Carries pid + ts because the launcher must never be muted permanently by an
643
643
  // updater that was killed mid-swap (it applies the same staleness bound).
644
- const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress');
644
+ const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
645
645
  // Intent journal, written INSIDE the backup dir before each rename. On a hard kill
646
646
  // the backup dir survives (every normal exit deletes it) and this file says exactly
647
647
  // which paths were in flight, so the next entry can finish the rollback at the right
@@ -781,7 +781,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
781
781
  // holding the lock means an install is already in flight — skip rather than
782
782
  // race. Shared path with install.mjs so direct install + repair + auto-update
783
783
  // are mutually exclusive.
784
- const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock'));
784
+ const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
785
785
  if (!release) {
786
786
  debugLog('DEBUG', 'hook-update', 'installExtractedRelease: another install/update is in progress — skipping');
787
787
  return false;
@@ -1115,7 +1115,7 @@ function readState() {
1115
1115
 
1116
1116
  function saveState(state) {
1117
1117
  try {
1118
- const dir = join(STATE_DIR, 'runtime');
1118
+ const dir = join(STATE_DIR, 'runtime'); // runtime-dir:stays-put — mkdir for update-state.json above
1119
1119
  mkdirSync(dir, { recursive: true });
1120
1120
  const tmpFile = STATE_FILE + `.tmp-${process.pid}`;
1121
1121
  writeFileSync(tmpFile, JSON.stringify(state, null, 2));
package/install.mjs CHANGED
@@ -1791,7 +1791,7 @@ async function doctor() {
1791
1791
 
1792
1792
  // Update state
1793
1793
  try {
1794
- const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json');
1794
+ const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json'); // runtime-dir:stays-put — installation identity
1795
1795
  if (existsSync(stateFile)) {
1796
1796
  const state = JSON.parse(readFileSync(stateFile, 'utf8'));
1797
1797
  const parts = [];
@@ -1951,8 +1951,12 @@ async function doctor() {
1951
1951
  try {
1952
1952
  // hook-update + the episode workers write runtime/ + staging under DB_DIR
1953
1953
  // (= MEM_DATA_DIR, env-aware), NOT the homedir code dir — scan there so doctor
1954
- // sees the real residue under relocation.
1955
- const runtimeDir = join(MEM_DATA_DIR, 'runtime');
1954
+ // sees the real residue under relocation. MEM_RUNTIME_DIR rather than
1955
+ // join(MEM_DATA_DIR,'runtime'): `pending-*` / `ep-flush-*` are written through
1956
+ // hook-shared.mjs's override-aware RUNTIME_DIR, and `cleanup()` below deletes them from
1957
+ // MEM_RUNTIME_DIR — v3.93.0 moved the deleter and left this scanner behind, so under the
1958
+ // override doctor reported "none" while the cleanup it recommends removed files.
1959
+ const runtimeDir = MEM_RUNTIME_DIR;
1956
1960
  let staleCount = 0;
1957
1961
  const stalePatterns = ['.update-staging-', '.update-backup-'];
1958
1962
  if (existsSync(MEM_DATA_DIR)) {
@@ -2560,7 +2564,7 @@ function bindingHostDir() {
2560
2564
  // two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
2561
2565
  // and exit 0 (it is doing this very work), never race it.
2562
2566
  async function rebuildBinding() {
2563
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2567
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
2564
2568
  if (!release) {
2565
2569
  // NOT exit 0: skipping is not healing. Callers key their state on the exit
2566
2570
  // code — a false success would let the launcher drop its cooldown and the
@@ -2609,7 +2613,7 @@ async function rebuildBinding() {
2609
2613
  // install/self-heal) holds it → skip rather than race into a torn install. Lock
2610
2614
  // path is shared with hook-update.installExtractedRelease (both env-aware).
2611
2615
  async function runLockedInstall() {
2612
- const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2616
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
2613
2617
  if (!release) {
2614
2618
  console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
2615
2619
  return;
@@ -26,6 +26,7 @@ import { citeRecallPathFor } from './cite-recall-path.mjs';
26
26
  // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
27
27
  // is acyclic.
28
28
  import { citationIdRe } from './citation-tracker.mjs';
29
+ import { envNumber } from './env-number.mjs';
29
30
 
30
31
  const MAX_FILES = 2;
31
32
 
@@ -207,8 +208,19 @@ export const CITE_NUDGE_SILENCE_AFTER = 3;
207
208
  // enough injection volume to judge). Shared by buildCiteRecallNudge (decide to
208
209
  // nag) and nextCiteLowStreak (decide to keep silencing).
209
210
  function ratioGateFires(data, env) {
210
- const threshold = Number(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD) || 0.6;
211
- const minInjected = Number(env.CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED) || 5;
211
+ // `Number(x) || d` was NaN-safe but swallowed an explicit 0, and 0 is meaningful
212
+ // on BOTH knobs: threshold 0 means "never nag on ratio", min-injected 0 means "no
213
+ // volume requirement". Neither was reachable through the env before.
214
+ const threshold = envNumber(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD,
215
+ { name: 'CLAUDE_MEM_CITE_NUDGE_THRESHOLD', defaultValue: 0.6, min: 0, max: 1 });
216
+ // No `integer: true` here or on SILENCE_AFTER, deliberately. Both are consumed with
217
+ // `>=` against an integer counter, so a fractional bound works and `2.5` was a usable
218
+ // setting before this release; rejecting it would silently swap a working value for the
219
+ // default. `max: 1` on THRESHOLD is different and stays: `ratio` is
220
+ // recalled/injected.size and cannot exceed 1, so a bound there is the domain, not a
221
+ // preference. Same rule as lib/relevance-floor.mjs — read the bound off the consumer.
222
+ const minInjected = envNumber(env.CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED,
223
+ { name: 'CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED', defaultValue: 5, min: 0 });
212
224
  return typeof data?.injected === 'number'
213
225
  && typeof data?.ratio === 'number'
214
226
  && data.injected >= minInjected
@@ -234,9 +246,11 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
234
246
  const path = citeRecallPathFor(runtimeDir, project);
235
247
  const raw = readFileSync(path, 'utf8');
236
248
  const data = JSON.parse(raw);
237
- const silenceAfter = env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER !== undefined
238
- ? Number(env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER)
239
- : CITE_NUDGE_SILENCE_AFTER;
249
+ // Garbage here used to become NaN, and `NaN >= silenceAfter` is false so a
250
+ // typo turned the self-silencing OFF, the opposite of every other knob's failure
251
+ // direction and the one a user would never notice. 0 stays valid ("never silence").
252
+ const silenceAfter = envNumber(env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER,
253
+ { name: 'CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER', defaultValue: CITE_NUDGE_SILENCE_AFTER, min: 0 });
240
254
  // silenceAfter > 0 AND the project has ignored the nag that many times running.
241
255
  const silenced = silenceAfter > 0
242
256
  && typeof data.lowStreak === 'number'
@@ -0,0 +1,105 @@
1
+ // Numeric environment overrides with an explicit failure mode.
2
+ //
3
+ // The idiom this replaces — `Number(process.env.X || DEFAULT)` — has no failure
4
+ // mode at all: `Number('abc')` is NaN, and NaN then propagates into whatever the
5
+ // constant feeds. Nothing throws and nothing is logged, so the surface degrades
6
+ // SILENTLY and in a direction that depends on the consumer. Measured on the six
7
+ // UPS knobs (2026-09-04, node probe against the shipped consumers):
8
+ //
9
+ // CLAUDE_MEM_UPS_MAX_RESULTS=abc rows.slice(0, NaN) === []
10
+ // → the whole FTS injection face goes dark
11
+ // CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT=abc `LIMIT ?` bound with NaN
12
+ // → SqliteError: datatype mismatch
13
+ // CLAUDE_MEM_UPS_TOP_MIN=abc `Math.abs(rel) < NaN` is false
14
+ // → the set-level noise floor stops firing
15
+ // CLAUDE_MEM_UPS_OR_BM25_MIN=abc `orFloor > 0` is false
16
+ // → the OR-fallback floor stops firing
17
+ //
18
+ // So one typo either silences the face or disables the gates that keep it quiet,
19
+ // and the two are indistinguishable from outside. `lib/cli-flags.mjs` already
20
+ // solved the same problem for CLI flags (warn once on stderr, fall back to the
21
+ // documented default); this is that contract for the env side.
22
+ //
23
+ // `Number`, not `parseInt`: the floors here are written in scientific notation
24
+ // (1e-5, 5e-6) and `parseInt('1e-5')` is 1 — a 100000x silent misparse, i.e. the
25
+ // exact class of defect this module exists to remove.
26
+
27
+ const DEFAULT_WARN = (msg) => {
28
+ // stderr is safe from every hook face: the host reads a command hook's stdout as
29
+ // its envelope and only surfaces stderr on a failure/blocked path, so a warning
30
+ // here can never corrupt an injection (lib/hook-stdout.mjs).
31
+ try { process.stderr.write(msg); } catch { /* never block on a warning */ }
32
+ };
33
+
34
+ /**
35
+ * Parse a numeric env override, falling back to `defaultValue` with a stderr
36
+ * warning on anything that is not a finite in-range number.
37
+ *
38
+ * Unset and empty-string both mean "not configured" and fall back SILENTLY —
39
+ * `CLAUDE_MEM_X=` is how a shell unsets a value it inherited, not a mistake.
40
+ *
41
+ * An explicit `0` is honoured whenever the range admits it. Which idiom swallowed a 0 is
42
+ * worth stating precisely, because a v3.94.0 draft got it backwards in two places:
43
+ *
44
+ * Number(env.X || D) with X='0' -> 0 — SAFE. `process.env.X` is a STRING and
45
+ * `'0'` is truthy; only `''` is falsy.
46
+ * Number(env.X) || D with X='0' -> D — BROKEN. Parse first, then fall back.
47
+ *
48
+ * So the knobs that were unreachable at 0 are the parse-then-fallback ones,
49
+ * `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` and `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED`
50
+ * (lib/cite-back-hint.mjs), NOT the folded UPS floors.
51
+ *
52
+ * Shape is decided by `Number` + `Number.isFinite` rather than a regex: that
53
+ * rejects trailing garbage ('2abc'), whitespace-only, and Infinity, while
54
+ * accepting the scientific-notation form the callers' own defaults use.
55
+ *
56
+ * @param {string|number|undefined|null} raw Raw env value (pass `env.NAME`, not the name).
57
+ * @param {object} opts
58
+ * @param {string} opts.name Env var name, for the warning text.
59
+ * @param {number} opts.defaultValue Value used when unset or invalid.
60
+ * @param {number} [opts.min=-Infinity] Inclusive lower bound.
61
+ * @param {number} [opts.max=Infinity] Inclusive upper bound.
62
+ * @param {boolean} [opts.integer=false] Reject non-integers (e.g. row caps, SQL LIMITs).
63
+ * @param {(msg: string) => void} [opts.warn] Test seam — defaults to process.stderr.write.
64
+ * @returns {number} A finite number in [min, max], or `defaultValue`.
65
+ */
66
+ export function envNumber(raw, opts) {
67
+ const {
68
+ name, defaultValue, min = -Infinity, max = Infinity, integer = false,
69
+ warn = DEFAULT_WARN,
70
+ } = opts;
71
+
72
+ if (raw === undefined || raw === null) return defaultValue;
73
+ const str = String(raw).trim();
74
+ if (str === '') return defaultValue;
75
+
76
+ const n = Number(str);
77
+ const ok = Number.isFinite(n)
78
+ && (!integer || Number.isInteger(n))
79
+ && n >= min && n <= max;
80
+
81
+ if (!ok) {
82
+ const bound = describeRange(min, max, integer);
83
+ warn(`[mem] Invalid ${name}="${raw}" (${bound}); using default ${defaultValue}\n`);
84
+ return defaultValue;
85
+ }
86
+ return n;
87
+ }
88
+
89
+ /**
90
+ * Human-readable statement of what the value had to be. Split out so the warning
91
+ * text is derived from the SAME bounds the check used — a hand-written message
92
+ * drifts from its predicate on the first bound change.
93
+ *
94
+ * @param {number} min
95
+ * @param {number} max
96
+ * @param {boolean} integer
97
+ * @returns {string}
98
+ */
99
+ function describeRange(min, max, integer) {
100
+ const kind = integer ? 'an integer' : 'a number';
101
+ if (min === -Infinity && max === Infinity) return `must be ${kind}`;
102
+ if (max === Infinity) return `must be ${kind} >= ${min}`;
103
+ if (min === -Infinity) return `must be ${kind} <= ${max}`;
104
+ return `must be ${kind} between ${min} and ${max}`;
105
+ }
@@ -61,7 +61,13 @@ export const TOOL_INPUT_FILE_MAX_BYTES = 8 * 1024 * 1024;
61
61
  * @returns {{filePath: string, sessionId: string|null, toolName: string|null} | null}
62
62
  */
63
63
  export function salvageTruncatedHookEvent(prefix) {
64
- const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
64
+ // Bounded capture. Unbounded, an 8 MB prefix whose `file_path` string is never closed
65
+ // makes V8 exceed its regexp backtrack limit and THROW RangeError — measured at the cap by
66
+ // the v3.93.0 post-release review. The throw escapes the caller's catch into its top-level
67
+ // one, which still exits 0 but writes a `pre-recall:top` telemetry row: the same
68
+ // hook-error noise the caller split `pre-recall:json` away from. 4096 is far above any
69
+ // real path (PATH_MAX is 4096 on Linux, 1024 on macOS), so no reachable payload is lost.
70
+ const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.){0,4096})"/);
65
71
  if (!fp) return null;
66
72
  let filePath;
67
73
  // The captured group is still JSON-escaped (Windows paths arrive as `C:\\x`).
@@ -15,14 +15,79 @@
15
15
  // flushEpisode) and shipped surfaces that emit two envelopes, or an envelope
16
16
  // plus a raw block, on one stdout. Both shapes make JSON.parse throw, so:
17
17
  //
18
- // SessionStart / UserPromptSubmit / UserPromptExpansion the plainText is
19
- // injected verbatim, so the model receives `{"suppressOutput":true,…}` as
18
+ // There are THREE channels, not one, and each event uses a different subset. Verified
19
+ // against the 2.1.260 bundle, 2026-09-04 (minified names are rebuilt every release —
20
+ // match on shape, not on the identifier):
21
+ //
22
+ // • UserPromptSubmit / UserPromptExpansion — plain text becomes `additionalContext`
23
+ // and is injected verbatim, so the model receives `{"suppressOutput":true,…}` as
20
24
  // literal escaped text and suppressOutput is never honoured.
21
- // • every other event the renderer returns [] for plain text, so BOTH
22
- // receipts are dropped in silence.
25
+ // • SessionStartNOT via additionalContext (see channel 1 below), but the runner's
26
+ // status-0 branch also emits a `hook_success` attachment whose `content` is
27
+ // `stdout.trim()`, and the attachment renderer turns that into a real message for
28
+ // SessionStart / UserPromptSubmit / UserPromptExpansion, prefixed
29
+ // `<hookName> hook success: `. So a stray envelope still reaches the model here —
30
+ // with a prefix rather than verbatim.
31
+ // • PreCompact — raw stdout becomes `newCustomInstructions` for the compaction
32
+ // summarizer (channel 2 below).
33
+ // • everything else — plain text is dropped in silence.
23
34
  //
24
35
  // So contributions are queued and written once. Callers keep their own gating
25
36
  // (RECEIPT_EVENTS, significance, etc.); this only owns the writing.
37
+ //
38
+ // ── THE THREE CHANNELS, and what each correction cost ────────────────────────────
39
+ //
40
+ // The original list had TWO bullets and named SessionStart as plain-text-injecting.
41
+ // The v3.94.0 rewrite fixed that half and broke the other: it moved SessionStart into
42
+ // "dropped in silence", which is false via channel 3. Caught by the v3.94.0 pre-tag
43
+ // correctness review. Both errors have the same root — reading one channel and
44
+ // generalising — which is why the channels are now enumerated rather than summarised.
45
+ //
46
+ // 1. **additionalContext.** The stdout classifier (`f8e` in 2.1.260, `Hxi` in 2.1.233)
47
+ // turns plain text into an injectable answer for exactly two events:
48
+ //
49
+ // if (status === 0) { let N = (plainText ?? "").trim();
50
+ // return (event === "UserPromptSubmit" || event === "UserPromptExpansion") && N !== ""
51
+ // ? { answer: { hookSpecificOutput: { hookEventName: event, additionalContext: N } }, … }
52
+ // : { answer: {}, … } }
53
+ //
54
+ // SessionStart's consumer reads `additionalContexts`, which comes off that empty
55
+ // `answer` — so on THIS channel SessionStart really does get nothing. hook.mjs
56
+ // SessionStart uses an envelope, so it does not depend on the finding either way.
57
+ //
58
+ // 3. **hook_success attachment.** Same status-0 branch, independent of the classifier:
59
+ //
60
+ // if (ts.status === 0) { let ls = await bde(ts.stdout.trim(), …);
61
+ // yield { message: cn({ type: "hook_success", …, content: ls, … }), … } }
62
+ //
63
+ // and the renderer:
64
+ //
65
+ // case "hook_success":
66
+ // if (e.hookEvent !== "SessionStart" && e.hookEvent !== "UserPromptSubmit"
67
+ // && e.hookEvent !== "UserPromptExpansion") return [];
68
+ // if (e.content === "") return [];
69
+ // return [Te({ content: Ra(`${e.hookName} hook success: ${e.content}`), isMeta: !0 })];
70
+ //
71
+ // 2. There is a SECOND consumption channel, and it is raw stdout. The hook runner
72
+ // sets `result.output = status === 0 ? stdout : stderr` — the whole stdout, JSON
73
+ // or not, quite apart from the parsed `answer`. `executePreCompactHooks` uses THAT:
74
+ //
75
+ // let v = results.filter(r => r.succeeded && !r.blocked && r.output.trim())
76
+ // .map(r => r.output.trim());
77
+ // return { newCustomInstructions: v.length ? v.join("\n\n") : undefined, … }
78
+ //
79
+ // and `newCustomInstructions` is passed as `customInstructions` into the compaction
80
+ // summarizer. `grep -abo '\.newCustomInstructions'` on 2.1.260 returns SIX read
81
+ // sites; a draft said four, which is what comes of counting the ones a single
82
+ // bounded grep happened to show. So hook-precompact.mjs writing a bare
83
+ // `<claude-mem-context>` block to stdout is not merely allowed — it is the ONLY
84
+ // correct form there. Routing it through this module would deliver the literal
85
+ // envelope JSON to the summarizer as its instructions. tests/precompact-stdout-shape
86
+ // pins that, so the "consistency" refactor cannot happen by accident.
87
+ //
88
+ // The general rule the two corrections share: "the host drops plain text" is a claim
89
+ // about ONE channel. Before believing it for an event, find which field that event's
90
+ // runner actually reads.
26
91
 
27
92
  let parts = [];
28
93
  let queuedEvent = null;
@@ -351,7 +351,24 @@ export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCa
351
351
  UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
352
352
  WHERE id IN (
353
353
  SELECT id FROM observations
354
- WHERE COALESCE(compressed_into, 0) = 0
354
+ -- liveObsFilterSql, not compressed_into alone (P3-13). This statement writes the
355
+ -- sentinel purgeStale hard-deletes, and deleting a RETIRED row destroys its
356
+ -- superseded_by column -- the redirect three functions in the Stop citation loop
357
+ -- follow to credit a #NN naming a corrected memory to its successor
358
+ -- (lib/citation-tracker.mjs redirectSupersededIds). 27 of 31 superseded rows on the
359
+ -- maintainer's DB carry one. An explicit delete still removes a tombstone, exactly
360
+ -- as with the lesson guard below.
361
+ --
362
+ -- ONLY the two PENDING_PURGE writers carry this (here and
363
+ -- search-scoring.runIdleCleanup). The decay arm below, boostAccessed, demotePinned
364
+ -- and cleanupBroken deliberately keep compressed_into alone: the first three move
365
+ -- only importance, which is inert on a row every read path already hides, so
366
+ -- exempting them would be churn plus a forecast change for no behavioural
367
+ -- difference. maintenanceStats' stale count mirrors THIS predicate and moved with it.
368
+ --
369
+ -- NB: no backticks anywhere in this block -- it sits inside a JS template literal,
370
+ -- where one terminates the string. A first draft did that and node --check failed.
371
+ WHERE ${liveObsFilterSql('')}
355
372
  AND COALESCE(importance, 1) = 1
356
373
  AND COALESCE(access_count, 0) = 0
357
374
  AND COALESCE(injection_count, 0) = 0
@@ -618,9 +635,14 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
618
635
  -- lesson_learned guard mirrors decayAndMarkIdle (:188) / cleanupBroken (:153): those
619
636
  -- ops NEVER touch a lesson-bearing row ("lessons never auto-GC"), so the scan preview
620
637
  -- must exclude them too or it over-forecasts "Stale"/"Broken" vs what execute does.
638
+ -- superseded_at IS NULL mirrors the P3-13 guard added to decayAndMarkIdle's
639
+ -- mark-idle pass. It belongs ONLY on the stale count: boostable and pinned forecast
640
+ -- ops that were deliberately left touching tombstones, and forecasting an exemption
641
+ -- they do not have would break this parity in the other direction.
621
642
  COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
622
643
  AND COALESCE(injection_count, 0) = 0
623
644
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
645
+ AND superseded_at IS NULL
624
646
  AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
625
647
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
626
648
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
@@ -45,6 +45,35 @@ const OBS_DEFAULTS = {
45
45
  files_read: '[]', files_modified: '[]', search_aliases: null, importance: 1,
46
46
  };
47
47
 
48
+ /**
49
+ * Column-level value normalization applied by BOTH write cores (insert and update).
50
+ *
51
+ * Today it holds exactly one rule, and it is a real one (audit P3-14). `importance` is
52
+ * `INTEGER DEFAULT 1` but NULLABLE, and the DEFAULT only applies when the column is
53
+ * OMITTED from the INSERT — it never is here, because the column list is fixed. So a
54
+ * caller passing `importance: null`, or `importance: undefined` as an OWN property (which
55
+ * skips the OBS_DEFAULTS lookup above), wrote a NULL. better-sqlite3 binds both to SQL
56
+ * NULL and throws on neither, so nothing upstream would have caught it.
57
+ *
58
+ * A NULL there is not cosmetic: the two maintenance faces disagree about what it means.
59
+ * `maintain-core.decayAndMarkIdle` reads `COALESCE(importance,1) = 1` and queues the row
60
+ * for purge; `search-scoring.runIdleCleanup` reads a bare `importance <= 1`, which is NULL
61
+ * (falsy) and skips it. Aligning those two predicates was rejected — the alignment that
62
+ * closes the gap is the one that makes the MCP face START purging — and a NOT NULL
63
+ * migration means rebuilding a table that carries FTS5 triggers, for a population measured
64
+ * at 0 rows. Making NULL unwritable at the two shared cores costs one function and leaves
65
+ * both read faces exactly as they are. `doctor --session-audit` reports any row that got
66
+ * in by another route.
67
+ *
68
+ * @param {string} col Column name.
69
+ * @param {*} value Value the caller supplied (or the default).
70
+ * @returns {*} Value to bind.
71
+ */
72
+ function normalizeObsValue(col, value) {
73
+ if (col === 'importance' && (value === null || value === undefined)) return OBS_DEFAULTS.importance;
74
+ return value;
75
+ }
76
+
48
77
  /**
49
78
  * Insert one observations row from a {column: value} map and return its id.
50
79
  * Omitted columns fall back to OBS_DEFAULTS (or NULL). The column list lives only
@@ -52,8 +81,8 @@ const OBS_DEFAULTS = {
52
81
  */
53
82
  export function insertObservationRow(db, fields) {
54
83
  const values = OBS_COLUMNS.map(c =>
55
- Object.prototype.hasOwnProperty.call(fields, c) ? fields[c]
56
- : (c in OBS_DEFAULTS ? OBS_DEFAULTS[c] : null)
84
+ normalizeObsValue(c, Object.prototype.hasOwnProperty.call(fields, c) ? fields[c]
85
+ : (c in OBS_DEFAULTS ? OBS_DEFAULTS[c] : null))
57
86
  );
58
87
  const placeholders = OBS_COLUMNS.map(() => '?').join(', ');
59
88
  const result = db
@@ -249,7 +278,12 @@ export function applyObsUpdate(db, id, fields) {
249
278
  for (const col of UPDATABLE_OBS_COLS) {
250
279
  if (fields[col] !== undefined) {
251
280
  updates.push(`${col} = ?`);
252
- params.push(typeof fields[col] === 'string' ? scrubSecrets(fields[col]) : fields[col]);
281
+ // Same normalization as the insert core — an update is the OTHER way a NULL
282
+ // importance gets into the table, and a rule applied on one write path only is the
283
+ // shape this repo keeps paying for. `!== undefined` above lets an explicit null
284
+ // through, which is exactly the case normalizeObsValue catches.
285
+ const v = normalizeObsValue(col, fields[col]);
286
+ params.push(typeof v === 'string' ? scrubSecrets(v) : v);
253
287
  }
254
288
  }
255
289
  if (updates.length === 0) return [];
@@ -15,6 +15,8 @@
15
15
  // cache-busting query string reloads the FACE, not this module, so a load-time
16
16
  // constant here would have frozen at whatever the first import saw.
17
17
 
18
+ import { envNumber } from './env-number.mjs';
19
+
18
20
  // Default reference corpus. Overridable per the historical UPS env name.
19
21
  // Module-private: exported by habit in the first cut, and knip correctly flagged it —
20
22
  // this project treats a new unused export as a defect to fix, not a baseline to carry.
@@ -25,7 +27,15 @@ const DEFAULT_FLOOR_REF_CORPUS = 584;
25
27
  * @returns {number}
26
28
  */
27
29
  function floorRefCorpus() {
28
- return Number(process.env.CLAUDE_MEM_UPS_FLOOR_REF_CORPUS || DEFAULT_FLOOR_REF_CORPUS);
30
+ // min 0 and NOT min 2, which a first cut of this guard chose on the reasoning that
31
+ // maxIdf is 0 below n=2 so the ramp would divide by zero. `corpusFloorScale` already
32
+ // handles that: `ref <= 1` returns 1 and so does `!(refIdf > 0)`. A LOW reference is a
33
+ // supported setting — it is the documented way to pin the ramp OFF, which two cases in
34
+ // tests/user-prompt-search.test.mjs depend on — so min 2 turned a working knob into a
35
+ // silent fallback to 584 and unfired both gates. The only thing being screened here is
36
+ // the NaN that used to reach the comparison and make it silently false.
37
+ return envNumber(process.env.CLAUDE_MEM_UPS_FLOOR_REF_CORPUS,
38
+ { name: 'CLAUDE_MEM_UPS_FLOOR_REF_CORPUS', defaultValue: DEFAULT_FLOOR_REF_CORPUS, min: 0 });
29
39
  }
30
40
 
31
41
  /**
@@ -111,16 +111,26 @@ export function resolveDataDir(raw) {
111
111
  * the native-binding breakage marker, which is the self-heal trigger). The rule that
112
112
  * resolved them, stated so the next sweep does not overshoot:
113
113
  *
114
- * MOVES with the override — state a HOOK writes and another component reads back:
115
- * cross-hook injected-ids markers, skill/pre-recall cooldowns, hook-error telemetry,
116
- * the native-binding breakage marker, `metrics/`, `ep-flush-*` / `pending-*` buffers,
117
- * the shadow-recommendation log.
114
+ * MOVES with the override — state a HOOK writes INTO THE RUNTIME DIR and another
115
+ * component reads back: cross-hook injected-ids markers, skill/pre-recall cooldowns,
116
+ * hook-error telemetry, the launcher's breakage/heal markers, the native-binding
117
+ * breakage marker, `ep-flush-*` / `pending-*` buffers, the shadow-recommendation log.
118
118
  *
119
- * STAYS under the data dir state about the ONE REAL INSTALLATION: `install.lock`,
120
- * `update-state.json`, `swap-in-progress`, and the `.update-staging-*` /
121
- * `.update-backup-*` residue scan. These must NOT follow a per-harness override: the
122
- * lock exists to serialise concurrent installers, and two installers pointed at
123
- * different override directories would each take their own lock and both proceed.
119
+ * STAYS under the data dir, for two DIFFERENT reasons do not collapse them:
120
+ * (a) state about the ONE REAL INSTALLATION — `install.lock`, `update-state.json`,
121
+ * `swap-in-progress`, the `.update-staging-*` / `.update-backup-*` residue scan.
122
+ * These must not follow a per-harness override: the lock exists to serialise
123
+ * concurrent installers, and two installers pointed at different override
124
+ * directories would each take their own lock and both proceed.
125
+ * (b) state that was never under `runtime/` at all — `metrics/` is a SIBLING of it
126
+ * (`lib/metrics.mjs` is `join(dbDir, 'metrics')`) and every caller passes the data
127
+ * dir. A v3.93.0 draft of this list put it in the MOVES column, which is the one
128
+ * error a reader following this docblock would act on: a sweep obeying it would
129
+ * convert `recordMetric(DB_DIR, …)` to a runtime-relative path and re-open exactly
130
+ * what that release closed. What was fixed there was `join(RUNTIME_DIR, '..')` —
131
+ * an inverse derivation of the data dir that stopped equalling `DB_DIR` the moment
132
+ * the override was honoured. Deriving one dir from the other is the defect; the
133
+ * sink itself never moved.
124
134
  *
125
135
  * `tests/runtime-dir-single-home.test.mjs` sweeps for the defect FORM (a shipped module
126
136
  * building `join(…, 'runtime')` itself) with the stays-put sites as a named allowlist, so
@@ -137,6 +147,6 @@ export function resolveRuntimeDir(dataDir, env = process.env) {
137
147
  // turning a previously-working relative path into a throw would break isolation setups
138
148
  // in order to enforce tidiness. It is resolved against cwd instead, so the value is at
139
149
  // least absolute by the time anything writes to it.
140
- if (raw === undefined || raw === null || raw === '') return join(dataDir, 'runtime');
150
+ if (raw === undefined || raw === null || raw === '') return join(dataDir, 'runtime'); // runtime-dir:stays-put — this IS the default branch of the rule
141
151
  return isAbsolute(raw) ? raw : resolve(raw);
142
152
  }
@@ -14,6 +14,10 @@
14
14
  import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch } from '../utils.mjs';
15
15
  import { DEDUP_JACCARD_THRESHOLD } from './dedup-constants.mjs';
16
16
  import { insertObservationRow, insertObservationFiles, insertObservationVector } from './observation-write.mjs';
17
+ // The SAME predicate every read path uses. Imported rather than re-typed: a hand-written
18
+ // `superseded_at IS NULL` here would drift from the read side on the next column change,
19
+ // which is the mechanism behind this repo's recurring superseded-invariant failures.
20
+ import { liveObsFilterSql } from './inject-search-core.mjs';
17
21
  // Imported, not injected: `allowStatuses` below is the POLICY this function exists to hold,
18
22
  // and a caller free to pass its own resolver could reinstate the one-way gate D#195 closed.
19
23
  import { resolveDeferredIds, closeDeferredItems } from './deferred-work.mjs';
@@ -193,11 +197,30 @@ export function saveObservation(db, params) {
193
197
  VALUES (?, ?, ?, ?, ?, 'active')
194
198
  `).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
195
199
 
196
- // Dedup window: 5-min, top-50 most-recent in project.
200
+ // Dedup window: 5-min, top-50 most-recent LIVE rows in project.
201
+ //
202
+ // `liveObsFilterSql` is load-bearing, not tidiness. Without it this window compared
203
+ // against retired rows and returned one of their ids to the caller as `existingId` —
204
+ // a tombstone no read path can reach, since every one of them filters on this same
205
+ // predicate. Concretely: save something wrong, retire it, save the correction inside
206
+ // five minutes, and the correction was refused as a duplicate OF THE ROW IT WAS
207
+ // CORRECTING (and, per D#201's short-circuit below, its supersession was dropped too).
208
+ // Measured 2026-09-04 on the live DB: 3 of the 31 superseded rows were retired 2.9 /
209
+ // 3.6 / 3.9 minutes after creation, so the window is exactly where this happens.
210
+ //
211
+ // hook-llm.mjs runs its own three-tier dedup over this table without the filter and is
212
+ // NOT the same defect. Two reasons, and they cover different tiers — worth saying, since
213
+ // a draft gave them as one undifferentiated pair:
214
+ // * its 7d/3d LOW-SIGNAL tiers are MEANT to keep matching rows auto-compress retired
215
+ // (that is what stops "Modified package.json" re-accumulating). Does not apply to
216
+ // its Tier 1, which is this same 5-minute window with no live filter.
217
+ // * it returns null rather than handing an id back to a caller, so nothing escapes.
218
+ // THIS is the reason that covers Tier 1: the consequence there is one silently
219
+ // dropped auto-save, not a caller told its correction duplicates a tombstone.
197
220
  const dedupCutoff = now.getTime() - DEDUP_WINDOW_MS;
198
221
  const recent = db.prepare(`
199
222
  SELECT id, title, text FROM observations
200
- WHERE project = ? AND created_at_epoch > ?
223
+ WHERE project = ? AND created_at_epoch > ? AND ${liveObsFilterSql('')}
201
224
  ORDER BY created_at_epoch DESC LIMIT ?
202
225
  `).all(project, dedupCutoff, DEDUP_RECENT_LIMIT);
203
226
 
package/mem-cli.mjs CHANGED
@@ -1326,7 +1326,7 @@ async function cmdStats(db, args) {
1326
1326
  out(` Low-value (imp≤1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1327
1327
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1328
1328
  out(` Compressed: ${compressedCount.c}`);
1329
- out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1329
+ out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(resolveRuntimeDir(DB_DIR), 'hook-errors')}` : ''}`);
1330
1330
  // Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
1331
1331
  // hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
1332
1332
  out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.93.0",
3
+ "version": "3.94.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.93.0",
9
+ "version": "3.94.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.93.0",
3
+ "version": "3.94.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",
@@ -128,6 +128,7 @@
128
128
  "lib/transcript-scan.mjs",
129
129
  "lib/ups-query.mjs",
130
130
  "lib/dedup-constants.mjs",
131
+ "lib/env-number.mjs",
131
132
  "lib/deferred-work.mjs",
132
133
  "lib/upgrade-banner.mjs",
133
134
  "lib/scrub-record.mjs",
package/schema.mjs CHANGED
@@ -935,12 +935,25 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
935
935
  SELECT 1 FROM sdk_sessions s WHERE s.memory_session_id = o.memory_session_id
936
936
  )
937
937
  `).get().c;
938
+ // Audit P3-14 backstop. `observations.importance` is INTEGER DEFAULT 1 but NULLABLE, and
939
+ // the two maintenance faces disagree about what NULL means: decayAndMarkIdle reads
940
+ // COALESCE(importance,1)=1 and queues the row for purge, runIdleCleanup reads a bare
941
+ // `importance <= 1` which is NULL and skips it. lib/observation-write.mjs now coerces
942
+ // nullish to 1 on both write cores, so no NEW row can be in that state; this counts the
943
+ // ones that got in another way — an old version, a hand-edited DB, a restored dump.
944
+ // Reported here rather than in its own command because `orphan_obs` above establishes
945
+ // that this audit already covers observation-level integrity, not only sessions.
946
+ const obsImportanceNull = db.prepare(
947
+ 'SELECT COUNT(*) AS c FROM observations WHERE importance IS NULL'
948
+ ).get().c;
938
949
  return {
939
950
  id_mix_uuid_shape: idMixUuidShape,
940
951
  id_mix_other: idMixOther,
941
952
  missing_mem_id: missingMemId,
942
953
  orphan_obs: orphanObs,
943
- healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0,
954
+ obs_importance_null: obsImportanceNull,
955
+ healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0
956
+ && obsImportanceNull === 0,
944
957
  };
945
958
  }
946
959
 
@@ -102,7 +102,7 @@ if (first.ok) process.exit(0);
102
102
  // .node mid-compile.
103
103
  let lockPath;
104
104
  try {
105
- lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
105
+ lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
106
106
  } catch (e) {
107
107
  // resolveDataDir THROWS on a non-absolute CLAUDE_MEM_DIR. Unhandled, that
108
108
  // prints an 8-line rejection stack onto SessionStart stderr; one line is enough.
@@ -39,13 +39,36 @@ const INSTALL_DIR = join(__dirname, '..');
39
39
  // non-absolute → default. Data-writing paths import that module and throw instead.
40
40
  const MEM_DIR = process.env.CLAUDE_MEM_DIR;
41
41
  const DATA_DIR = MEM_DIR && isAbsolute(MEM_DIR) ? MEM_DIR : join(homedir(), '.claude-mem-lite');
42
- const RUNTIME_DIR = join(DATA_DIR, 'runtime');
43
- const HEAL_MARKER = join(RUNTIME_DIR, 'hook-launcher-lastheal');
42
+ // TWO runtime dirs, because this file writes BOTH classes of state and v3.93.0 shipped a
43
+ // split by moving only doctor's READ side. `RUNTIME_DIR` is data-dir-relative and serves
44
+ // `swap-in-progress`, which is installation identity and must NOT follow a per-harness
45
+ // override (two installers pointed at different override dirs would each see no swap in
46
+ // progress). `HOOK_RUNTIME_DIR` is override-aware and serves every marker another component
47
+ // reads back — the launcher's own breakage/heal markers, which `install.mjs doctor` reads,
48
+ // and the native-binding pair below. That second constant already existed here as
49
+ // `NB_RUNTIME_DIR` and was applied to only half this file's markers.
50
+ //
51
+ // Inline `||`, not `lib/resolve-data-dir.mjs::resolveRuntimeDir`, and this is the single
52
+ // declared exception in the tree: the launcher runs BEFORE the native binding is known to
53
+ // work, so it imports only `node:` builtins on purpose — even a leaf lib module is a
54
+ // resolution it declines to make on the path whose job is surviving a broken install. The
55
+ // standalone hook scripts it mirrors (pre-tool-recall / pre-skill-bridge) honour the same
56
+ // variable and write 78 of every 79 of these markers, so reading a different dir would mean
57
+ // never healing. `resolveRuntimeDir` is the canonical rule (audit 2026-09-02 P1-14) and this
58
+ // is NOT identical to it: the resolver additionally makes a RELATIVE override absolute
59
+ // (`isAbsolute(raw) ? raw : resolve(raw)`), while this hands the relative value straight to
60
+ // `fs`, which resolves it against cwd at call time rather than at module load. They agree on
61
+ // unset, empty and absolute — the three cases that reach a real install. Keep the DEFAULTING
62
+ // behaviour in step; do not read "identical" into the difference.
63
+ // `tests/runtime-dir-single-home.test.mjs` asserts this file still carries the rule.
64
+ const RUNTIME_DIR = join(DATA_DIR, 'runtime'); // runtime-dir:stays-put — serves swap-in-progress only; HOOK_RUNTIME_DIR carries the hook markers
65
+ const HOOK_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
66
+ const HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-lastheal');
44
67
  const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
45
68
  // Observable breakage state: written when the launcher degrades a broken install
46
69
  // to exit 0, cleared once the install is confirmed healthy. `doctor` reads it so
47
70
  // the intentional silence (no stack trace per fire) stays detectable. (#4/#8)
48
- const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
71
+ const BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'hook-launcher-broken');
49
72
 
50
73
  // ── Native-binding (ABI) self-heal ──────────────────────────────────────────
51
74
  // A stale better_sqlite3.node after a Node upgrade does NOT throw at import time
@@ -63,22 +86,9 @@ const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
63
86
  // lib/hook-telemetry.mjs and lib/native-binding-hint.mjs); this heals from it at
64
87
  // SESSION-START only — never on the per-tool hot path, where an npm run would
65
88
  // stall the user's edit.
66
- // Marker dir mirrors the standalone hook scripts (pre-tool-recall /
67
- // pre-skill-bridge), which honor CLAUDE_MEM_RUNTIME_DIR — they write 78 of every
68
- // 79 of these markers, so reading a different dir would mean never healing.
69
- // The last hand-written copy of this rule, and it stays: this launcher runs BEFORE the
70
- // native binding is known to work, so it imports only `node:` builtins on purpose — even
71
- // `lib/resolve-data-dir.mjs` is a module resolution it declines to make on the path whose
72
- // job is to survive a broken install. `lib/resolve-data-dir.mjs::resolveRuntimeDir` is the
73
- // canonical rule (audit 2026-09-02 P1-14). It is NOT identical to it, and saying so was
74
- // wrong: the resolver additionally makes a RELATIVE override absolute (`isAbsolute(raw) ?
75
- // raw : resolve(raw)`), while this expression hands the relative value straight to `fs`,
76
- // which resolves it against cwd at call time instead of at module load. They agree on
77
- // unset, on empty and on an absolute override — the three cases that reach a real install.
78
- // Keep the DEFAULTING behaviour in step; do not read "identical" into the difference.
79
- const NB_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
80
- const NB_BROKEN_MARKER = join(NB_RUNTIME_DIR, 'native-binding-broken');
81
- const NB_HEAL_MARKER = join(NB_RUNTIME_DIR, 'native-binding-lastheal');
89
+ // Marker dir: HOOK_RUNTIME_DIR (see its definition above for why it is override-aware).
90
+ const NB_BROKEN_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-broken');
91
+ const NB_HEAL_MARKER = join(HOOK_RUNTIME_DIR, 'native-binding-lastheal');
82
92
  // Literal, not imported: the pure-`node:` charter above forbids importing lib/
83
93
  // here (this file must survive a broken install). Kept in sync with
84
94
  // lib/binding-probe.mjs::NATIVE_BINDING_REBUILD_CMD, which is the single home
@@ -225,7 +235,7 @@ function recentHealAttempt() {
225
235
 
226
236
  function recordHealAttempt() {
227
237
  try {
228
- mkdirSync(RUNTIME_DIR, { recursive: true });
238
+ mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
229
239
  writeFileSync(HEAL_MARKER, String(Date.now()));
230
240
  } catch { /* best-effort */ }
231
241
  }
@@ -239,7 +249,7 @@ function clearHealMarker() {
239
249
 
240
250
  function recordBreakage(reason) {
241
251
  try {
242
- mkdirSync(RUNTIME_DIR, { recursive: true });
252
+ mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
243
253
  writeFileSync(BROKEN_MARKER, JSON.stringify({ reason, ts: Date.now() }));
244
254
  } catch { /* best-effort */ }
245
255
  }
@@ -316,7 +326,7 @@ function healNativeBindingIfBroken() {
316
326
  if (Date.now() - statSync(NB_HEAL_MARKER).mtimeMs < HEAL_COOLDOWN_MS) return;
317
327
  } catch { /* no marker → not on cooldown */ }
318
328
  try {
319
- mkdirSync(NB_RUNTIME_DIR, { recursive: true });
329
+ mkdirSync(HOOK_RUNTIME_DIR, { recursive: true });
320
330
  writeFileSync(NB_HEAL_MARKER, String(Date.now()));
321
331
  } catch { /* best-effort */ }
322
332
 
@@ -76,7 +76,7 @@ try {
76
76
  // proceeds; a broken one defers to the peer instead of racing it).
77
77
  const { acquireLock } = await import('../lib/proc-lock.mjs');
78
78
  const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
79
- const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
79
+ const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
80
80
  let release = null;
81
81
  for (let i = 0; i < 20 && !(release = acquireLock(lockPath)); i++) {
82
82
  await new Promise((r) => setTimeout(r, 500));
@@ -39,6 +39,7 @@ import { neutralizeContextDelimiters } from '../format-utils.mjs';
39
39
  //
40
40
  // Import-free module, no runtime deps — nothing added to this script's load cost.
41
41
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
42
+ import { envNumber } from '../lib/env-number.mjs';
42
43
  // P1-9: one bounded stdin reader. Import-free, like hook-stdout.mjs beside it.
43
44
  import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES, salvageTruncatedHookEvent } from '../lib/hook-stdin.mjs';
44
45
  // Recall queries the SAVE-path project, so this MUST produce the same string as the
@@ -176,16 +177,21 @@ const EDGE_DECAY_K = Math.max(1, Number.isNaN(EDGE_DECAY_K_RAW) ? 3 : EDGE_DECAY
176
177
  // dominated by legacy rows.
177
178
  const SCOPE_FILTER_ON = ['1', 'on', 'true', 'yes'].includes(
178
179
  String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase());
179
- const FILE_INTEL_MIN_TOKENS = Math.max(1,
180
- parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
180
+ // `min: 1` replaces the old `Math.max(1, parseInt(…) || 800)`. The wrapper made the
181
+ // clamp look like the whole story, but the `|| 800` inside it swallowed an explicit 0 —
182
+ // `CLAUDE_MEM_FILE_INTEL_MIN_TOKENS=0` (a user asking for no floor) landed on 800, not on
183
+ // 1. Same class as the UPS knobs; caught by the widened tree sweep in
184
+ // tests/env-number.test.mjs once it learned the trailing-default shape.
185
+ const FILE_INTEL_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS,
186
+ { name: 'CLAUDE_MEM_FILE_INTEL_MIN_TOKENS', defaultValue: 800, min: 1, integer: true });
181
187
  // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
182
188
  // it already read this session and the file is unchanged (mtime), nudge it to
183
189
  // reuse context instead of re-slurping. Read-only; only fires above the floor and
184
190
  // never on offset/limit paging. Default ON; CLAUDE_MEM_REREAD_GUARD=0 disables.
185
191
  const REREAD_GUARD_OFF = ['0', 'off', 'false', 'no'].includes(
186
192
  String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase());
187
- const REREAD_MIN_TOKENS = Math.max(1,
188
- parseInt(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS, 10) || 600);
193
+ const REREAD_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS,
194
+ { name: 'CLAUDE_MEM_REREAD_MIN_TOKENS', defaultValue: 600, min: 1, integer: true });
189
195
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
190
196
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
191
197
  // which is enough to keep RUNTIME_DIR from growing unbounded.
@@ -23,6 +23,7 @@ import { recommendSkill } from '../registry-recommend.mjs';
23
23
  import { recordHookError } from '../lib/hook-telemetry.mjs';
24
24
 
25
25
  import { DAY_MS } from '../lib/time-constants.mjs';
26
+ import { envNumber } from '../lib/env-number.mjs';
26
27
  // ─── Constants ──────────────────────────────────────────────────────────────
27
28
 
28
29
  // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
@@ -48,7 +49,12 @@ const injectedIdsFileFor = (sessionId) =>
48
49
  // useRecent intent path is unaffected (it uses intent.limit=5 directly,
49
50
  // gated by explicit "before/previously/记得" prompts where breadth is the
50
51
  // point). Env override for projects that want broader recall or to A/B.
51
- const MAX_RESULTS = Number(process.env.CLAUDE_MEM_UPS_MAX_RESULTS || 3);
52
+ // Integer, min 0. This value reaches `rows.slice(0, MAX_RESULTS)`, and 0 there means
53
+ // "inject nothing" — a legitimate way to turn this face off, so it is accepted rather
54
+ // than warned back up to 3 (falling back would INJECT for a user who asked for silence).
55
+ // What is screened is NaN, which produced the same silence from a typo, unasked.
56
+ const MAX_RESULTS = envNumber(process.env.CLAUDE_MEM_UPS_MAX_RESULTS,
57
+ { name: 'CLAUDE_MEM_UPS_MAX_RESULTS', defaultValue: 3, min: 0, integer: true });
52
58
  const LOOKBACK_MS = 60 * DAY_MS; // 60 days
53
59
 
54
60
  // v2.56.x: Past-similar-questions fallback row cap. Cut from 3 → 1 after
@@ -57,7 +63,11 @@ const LOOKBACK_MS = 60 * DAY_MS; // 60 days
57
63
  // Unlike the obs FTS path (TOP_REL_FLOOR + BM25 gates), prompt-fallback has no
58
64
  // quality gate — only BM25 ordering — so additional rows inflate noise without
59
65
  // improving signal. Env-overridable for projects that want broader prompt recall.
60
- const PROMPT_FALLBACK_LIMIT = Number(process.env.CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT || 1);
66
+ // Integer, min 0: bound directly into a SQL `LIMIT ?`, where better-sqlite3 rejects a
67
+ // non-integer outright (`SqliteError: datatype mismatch`). `LIMIT 0` is valid and means
68
+ // "disable the prompt-fallback path", so 0 stays a usable setting.
69
+ const PROMPT_FALLBACK_LIMIT = envNumber(process.env.CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT,
70
+ { name: 'CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT', defaultValue: 1, min: 0, integer: true });
61
71
  // Over-fetch factor for that cap. searchByUserPrompts filters rows in JS (cjkPrecisionOk)
62
72
  // AFTER the SQL LIMIT, so the LIMIT bounds reachability, not just output width — see the
63
73
  // comment at the query. These size the pool only; the function still returns at most
@@ -77,7 +87,10 @@ const PROMPT_FALLBACK_POOL_MAX = 25;
77
87
  // acts as a NULL-rel guard, not a real noise filter. The primary noise gate
78
88
  // is TOP_REL_FLOOR below, which drops the whole FTS set when the best match
79
89
  // is weak.
80
- const BM25_MIN_SCORE = Number(process.env.CLAUDE_MEM_UPS_BM25_MIN || 1e-5);
90
+ // min 0, non-integer: a magnitude floor compared with `Math.abs(relevance) >= …`.
91
+ // NaN here makes that comparison always false, i.e. it drops every row.
92
+ const BM25_MIN_SCORE = envNumber(process.env.CLAUDE_MEM_UPS_BM25_MIN,
93
+ { name: 'CLAUDE_MEM_UPS_BM25_MIN', defaultValue: 1e-5, min: 0 });
81
94
  // CJK-weighted minimum length for the prompt. Catches medium-short Latin
82
95
  // prompts ("run tests", "fix bug now") that survive `shouldSkip`'s weaker 8-unit
83
96
  // floor but carry too few tokens to justify an FTS lookup.
@@ -92,7 +105,8 @@ const PROMPT_MIN_LENGTH = 15;
92
105
  // memory at least once, relax gates so short follow-ups still get recall.
93
106
  // Detection: injected-ids marker count > 0 within DEDUP_STALE_MS window.
94
107
  const FOLLOWUP_PROMPT_MIN_LENGTH = 8;
95
- const FOLLOWUP_BM25_MIN_SCORE = Number(process.env.CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP || 5e-6);
108
+ const FOLLOWUP_BM25_MIN_SCORE = envNumber(process.env.CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP,
109
+ { name: 'CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP', defaultValue: 5e-6, min: 0 });
96
110
 
97
111
  // v2.34.3: top-|rel| sanity gate. BM25_MIN_SCORE filters per-row; this floor
98
112
  // gates the entire FTS set. Noise prompts ("today's date", "current time")
@@ -113,7 +127,18 @@ const FOLLOWUP_BM25_MIN_SCORE = Number(process.env.CLAUDE_MEM_UPS_BM25_MIN_FOLLO
113
127
  // through, but the top-|rel| gap is an absolute distribution separator —
114
128
  // lowering it in follow-up mode re-admits the 37..48 noise band that the
115
129
  // gate exists to drop.
116
- const TOP_REL_FLOOR = Number(process.env.CLAUDE_MEM_UPS_TOP_MIN || 50);
130
+ // min 0, because 0 is a REAL value here: the documented seed-mode switch that kills both
131
+ // absolute floors (see OR_TOP_BM25_FLOOR below).
132
+ //
133
+ // It has ALWAYS worked, and a first draft of this comment claimed otherwise on a false
134
+ // premise worth recording: `process.env.X` is always a STRING, and `'0'` is truthy — only
135
+ // `''` is falsy. So `Number(env || 50)` with `CLAUDE_MEM_UPS_TOP_MIN='0'` was already 0,
136
+ // which is why `tests/user-prompt-search.test.mjs` has been green with `'0'` as runScript's
137
+ // default. The idiom that genuinely swallows a 0 is the OTHER one — `Number(env.X) || D`,
138
+ // parse first then fall back — which is what lib/cite-back-hint.mjs used. Caught by the
139
+ // v3.94.0 pre-tag correctness review. What changed here is NaN screening, nothing else.
140
+ const TOP_REL_FLOOR = envNumber(process.env.CLAUDE_MEM_UPS_TOP_MIN,
141
+ { name: 'CLAUDE_MEM_UPS_TOP_MIN', defaultValue: 50, min: 0 });
117
142
 
118
143
  // v2.43.x: OR-fallback raw BM25 magnitude floor. The composite TOP_REL_FLOOR
119
144
  // above gates on `bm25 × importance × type_quality × decay × noise_penalty`.
@@ -141,7 +166,8 @@ const TOP_REL_FLOOR = Number(process.env.CLAUDE_MEM_UPS_TOP_MIN || 50);
141
166
  // we piggy-back on it rather than introducing a second override env.
142
167
  const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
143
168
  ? 0
144
- : Number(process.env.CLAUDE_MEM_UPS_OR_BM25_MIN || 30);
169
+ : envNumber(process.env.CLAUDE_MEM_UPS_OR_BM25_MIN,
170
+ { name: 'CLAUDE_MEM_UPS_OR_BM25_MIN', defaultValue: 30, min: 0 });
145
171
 
146
172
  // ─── Corpus-size normalization of the absolute floors (v3.61.0) ─────────────
147
173
  //
@@ -316,7 +316,16 @@ export function runIdleCleanup(db) {
316
316
  -- the same guard-on-one-path shape the docblock below claims was consolidated.
317
317
  AND COALESCE(injection_count, 0) = 0
318
318
  AND type IN (${types})
319
- AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
319
+ -- liveObsFilterSql, not compressed_into alone (P3-13), and the THIRD clause this
320
+ -- MCP twin has had to be brought level on (lesson guard, then injection_count in
321
+ -- audit P0-4, now this). A retired row's superseded_by column is the redirect the
322
+ -- Stop citation loop follows to credit a #NN naming a corrected memory; purgeStale
323
+ -- hard-deletes what this marks, and that destroys it. Same predicate, same
324
+ -- reasoning, as decayAndMarkIdle's mark-idle pass -- the two are twins and drift
325
+ -- here has cost data twice. The COMPRESSED_AUTO pass below deliberately does NOT
326
+ -- get it: -1 is not deletable by any path, so a tombstone reaching it loses nothing.
327
+ -- (No backticks: inside a JS template literal.)
328
+ AND created_at_epoch < ? AND ${liveObsFilterSql('')}
320
329
  -- Never auto-mark a lesson-bearing row for purge. This idle path is the
321
330
  -- MCP-server sibling of maintain-core.decayAndMarkIdle and must carry the
322
331
  -- SAME "lessons never auto-GC" guard; without it a lesson demoted to imp≤1
package/server.mjs CHANGED
@@ -1570,8 +1570,12 @@ server.registerTool(
1570
1570
  // ─── Tool: mem_export ────────────────────────────────────────────────────────
1571
1571
 
1572
1572
  // In-process test seam (mirrors handleRecentForTest, #8743): threads an injected db
1573
- // through the SAME body the registered handler runs. NOTE: a `project` arg is still
1574
- // resolved via resolveProject() against the MODULE db, not the injected one.
1573
+ // through the SAME body the registered handler runs INCLUDING the project resolution,
1574
+ // which uses `runExport`'s own `db` parameter. (A note here used to say the resolution ran
1575
+ // against the module db instead; it was false before this release too. Corrected while
1576
+ // rewriting the block below, since a stale note three lines above a rewrite is the one a
1577
+ // reader trusts.) This seam bypasses the zod layer, which is why `runExport` screens a
1578
+ // non-string `project` itself rather than relying on `memExportSchema`.
1575
1579
  export async function handleExportForTest(db, args) {
1576
1580
  return runExport(db, args);
1577
1581
  }
@@ -1592,14 +1596,24 @@ async function runExport(db, args) {
1592
1596
  toEpoch = new Date(d).getTime();
1593
1597
  if (isNaN(toEpoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
1594
1598
  }
1599
+ // Same policy as the two date checks: refuse rather than silently drop a filter.
1600
+ //
1601
+ // This replaces a `_resolveProjectShared(db, x) || x` fallback that could not do what
1602
+ // its comment claimed. `resolveProject` returns a falsy value on exactly one input —
1603
+ // a truthy NON-STRING, which it deliberately maps to null so that `true.includes('--')`
1604
+ // stops crashing every project-filtered command at the root helper. Through MCP that
1605
+ // input cannot arrive (`memExportSchema.project` is `z.string().optional()`, validated
1606
+ // before the handler); through `handleExportForTest` it can. So the fallback's only
1607
+ // reachable effect was to hand the non-string straight back, undoing that guard and
1608
+ // trading a wide export for a bind error. Neither is the documented behaviour, and the
1609
+ // other project-resolving sites (server.mjs:326/465, and every cmd* in mem-cli.mjs)
1610
+ // carry no such fallback — this one was the outlier.
1611
+ if (args.project !== undefined && args.project !== null && typeof args.project !== 'string') {
1612
+ throw new Error(`Invalid project: expected a string, got ${typeof args.project}`);
1613
+ }
1595
1614
  const { params, where } = buildExportWhere({
1596
1615
  includeCompressed: Boolean(args.include_compressed),
1597
- // `|| args.project`: buildExportWhere gates the predicate on TRUTHINESS, so a falsy
1598
- // resolution would DROP the project filter and export the whole store. The code this
1599
- // replaced pushed `project = ?` unconditionally, so an unresolvable name yielded zero
1600
- // rows — which is the safe direction, and the comment above is specifically about not
1601
- // letting a dropped filter widen the export.
1602
- project: args.project ? (_resolveProjectShared(db, args.project) || args.project) : null,
1616
+ project: args.project ? _resolveProjectShared(db, args.project) : null,
1603
1617
  type: args.type || null,
1604
1618
  fromEpoch, toEpoch,
1605
1619
  });
package/source-files.mjs CHANGED
@@ -250,6 +250,11 @@ export const SOURCE_FILES = [
250
250
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
251
251
  // missing it from the manifest would break those paths on auto-update.
252
252
  'lib/dedup-constants.mjs',
253
+ // Numeric env-override parsing with an explicit failure mode. Statically imported
254
+ // by scripts/user-prompt-search.js (a HOOK entry point — a missing manifest entry
255
+ // kills the UserPromptSubmit face outright on auto-update), lib/relevance-floor.mjs
256
+ // and lib/cite-back-hint.mjs.
257
+ 'lib/env-number.mjs',
253
258
  // v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
254
259
  // server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
255
260
  'lib/deferred-work.mjs',