claude-mem-lite 3.93.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli/doctor.mjs +2 -0
- package/lib/cite-back-hint.mjs +19 -5
- package/lib/env-number.mjs +105 -0
- package/lib/hook-stdout.mjs +69 -4
- package/lib/maintain-core.mjs +23 -1
- package/lib/observation-write.mjs +37 -3
- package/lib/relevance-floor.mjs +11 -1
- package/lib/save-observation.mjs +25 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/schema.mjs +14 -1
- package/scripts/pre-tool-recall.js +10 -4
- package/scripts/user-prompt-search.js +32 -6
- package/search-scoring.mjs +10 -1
- package/server.mjs +22 -8
- package/source-files.mjs +5 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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/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/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
package/lib/hook-stdout.mjs
CHANGED
|
@@ -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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
// •
|
|
22
|
-
//
|
|
25
|
+
// • SessionStart — NOT 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;
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [];
|
package/lib/relevance-floor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/lib/save-observation.mjs
CHANGED
|
@@ -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/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
954
|
+
obs_importance_null: obsImportanceNull,
|
|
955
|
+
healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0
|
|
956
|
+
&& obsImportanceNull === 0,
|
|
944
957
|
};
|
|
945
958
|
}
|
|
946
959
|
|
|
@@ -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
|
-
|
|
180
|
-
|
|
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 =
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
//
|
package/search-scoring.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1574
|
-
//
|
|
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
|
-
|
|
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',
|