claude-mem-lite 3.93.1 → 3.95.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.1",
13
+ "version": "3.95.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.1",
3
+ "version": "3.95.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli/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;
@@ -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
+ }
@@ -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
  /**
@@ -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