claude-mem-lite 3.85.0 → 3.86.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/hook-context.mjs +19 -2
- package/hook-memory.mjs +64 -17
- package/hook-update.mjs +52 -1
- package/lib/citation-tracker.mjs +79 -0
- package/lib/injected-ids.mjs +61 -0
- package/lib/stats-quality.mjs +28 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/pre-tool-recall.js +40 -9
- package/scripts/user-prompt-search.js +15 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.86.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.86.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/hook-context.mjs
CHANGED
|
@@ -61,6 +61,23 @@ export function computeAdaptiveWindows(db, project) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// D#192 (filed as D#189, re-scoped after measurement). These two are REACHABILITY bounds, not ranking bounds — the D#172 shape, and
|
|
65
|
+
// the fifth surface it has been found on. Both SELECTs order by pure `created_at_epoch
|
|
66
|
+
// DESC`; the JS below then re-sorts every candidate by `valueDensity`, a composite of
|
|
67
|
+
// typeQuality x impBoost(1.0/1.5/2.0) x lessonBoost(1.0/1.3) / sqrt(cost) whose dynamic
|
|
68
|
+
// range is far wider than the (1,2] that recency contributes. So the key the SQL sorts
|
|
69
|
+
// on barely participates in the final order, and anything past the LIMIT is unreachable
|
|
70
|
+
// however well it scores.
|
|
71
|
+
//
|
|
72
|
+
// Named rather than inline so benchmark/keyctx-pool-replay.mjs can patch a twin and
|
|
73
|
+
// price a change to them. Extracting them changed no value.
|
|
74
|
+
//
|
|
75
|
+
// NOT yet widened: SessionStart injects on every start, so moving these is a
|
|
76
|
+
// user-visible default-behaviour change to a released artifact (L3) and gets its own
|
|
77
|
+
// round. Measured truncation as of 2026-09-01 is in the replay's header.
|
|
78
|
+
export const KEYCTX_POOL_OBS = 50;
|
|
79
|
+
export const KEYCTX_POOL_SESS = 10;
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Select observations and sessions within a token budget using greedy knapsack.
|
|
66
83
|
* Scores candidates by recency * importance, picks highest value-density first.
|
|
@@ -91,7 +108,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
91
108
|
OR (created_at_epoch > ? AND importance >= 3)
|
|
92
109
|
)
|
|
93
110
|
ORDER BY created_at_epoch DESC
|
|
94
|
-
LIMIT
|
|
111
|
+
LIMIT ${KEYCTX_POOL_OBS}
|
|
95
112
|
`).all(project, tier1Ago, tier2Ago, tier3Ago);
|
|
96
113
|
|
|
97
114
|
const sessPool = db.prepare(`
|
|
@@ -99,7 +116,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
99
116
|
FROM session_summaries
|
|
100
117
|
WHERE project = ? AND created_at_epoch > ?
|
|
101
118
|
ORDER BY created_at_epoch DESC
|
|
102
|
-
LIMIT
|
|
119
|
+
LIMIT ${KEYCTX_POOL_SESS}
|
|
103
120
|
`).all(project, now_ms - windows.sessWindow);
|
|
104
121
|
|
|
105
122
|
const selectedObs = [];
|
package/hook-memory.mjs
CHANGED
|
@@ -24,23 +24,65 @@ const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
|
24
24
|
* importance × cross-project × OR × noise × cite). So whatever these numbers are, a row
|
|
25
25
|
* outside the window cannot be picked however high its composite score would have been.
|
|
26
26
|
*
|
|
27
|
-
* The window has to be wide because the composite spread is
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
* The window has to be wide because the composite spread is wide — 281× by the tables,
|
|
28
|
+
* 60.0× as realised over the rows this pool can actually return. Multiplying the extremes
|
|
29
|
+
* of the JS factors
|
|
30
|
+
* (same-project, AND mode): best = 1.5 decision × 1.5 lesson × 1.0 importance × 1.0 noise
|
|
31
|
+
* × 3.0 cite = 6.75; worst = 0.5 change × 1.0 no-lesson × 0.6 importance × 0.2 noise ×
|
|
32
|
+
* 0.4 cite = 0.024, i.e. a **281× DECLARED range**. That is an upper bound off the factor
|
|
33
|
+
* tables, not a measurement: `citeFactor = 0.4` requires `uncited_streak >= 3`, and
|
|
34
|
+
* citation-decay resets the streak at 3 after demoting importance, so the steady state is
|
|
35
|
+
* bounded by [0,2] (scoring-sql.mjs, citeFactorJs docblock). Measured 2026-09-01 over the
|
|
36
|
+
* 2284 rows that clear `liveObsFilterSql` — the one predicate in the WHERE of BOTH SELECTs
|
|
37
|
+
* below — 0 are at streak >= 3, and recomputing the factor per row gives a REALISED range
|
|
38
|
+
* of 0.1125 … 6.750: a **60.0× spread** (81 rows hit the full best case, 0 the full worst).
|
|
39
|
+
* Each leg then narrows further and the spread survives the narrowing, which is why one
|
|
40
|
+
* number is quotable: live+`importance >= 1` n=2249 and +`notLowSignalTitleClause` n=2245
|
|
41
|
+
* both still read 60.00×. The CROSS leg is the exception — its own population
|
|
42
|
+
* (`type IN ('decision','discovery') AND importance >= 2`) is n=444 at **17.31×**, so if
|
|
43
|
+
* you are reasoning about `RERANK_POOL_CROSS_PROJECT` specifically, 60× is the wrong figure.
|
|
33
44
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
45
|
+
* COUNT THAT POPULATION WITH THE POOL'S OWN FILTER. Over the raw `observations` table it
|
|
46
|
+
* reads 0.0780 … 6.750 = 86.5×, and that is the number the first draft of this comment
|
|
47
|
+
* shipped: 1458 of 3742 rows (39.0%) are compressed or superseded, the row supplying the
|
|
48
|
+
* 0.0780 minimum (`id 10239`) carries `compressed_into = 10713`, and no such row can enter
|
|
49
|
+
* the pool, be scored, or be an endpoint of a range describing what the LIMIT cuts. Same
|
|
50
|
+
* error as v3.82.0's raw `importance = 3` count, overstating by 44% instead of a third.
|
|
38
51
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
52
|
+
* Quote whichever population you mean, and say which. Either is wide enough that a row
|
|
53
|
+
* ranked below the window on raw bm25 can outscore the window's contents by a wide margin.
|
|
54
|
+
* (The audit estimated ">10×".)
|
|
55
|
+
*
|
|
56
|
+
* HONEST LIMIT OF THIS FIX: because the spread is wide and bm25 magnitude decays slowly
|
|
57
|
+
* across a top-N window, NO finite pool size proves sufficiency. 30/15 makes the bound
|
|
58
|
+
* loose; it does not remove it. And it is bought, not free — the first draft of this
|
|
59
|
+
* comment claimed "cost stays flat" in the same breath as a parenthetical saying the pool
|
|
60
|
+
* is the expensive term, which is its own refutation. Measured instead:
|
|
61
|
+
* `node benchmark/rerank-pool-replay.mjs --cost` reads **+5% to +16% depending on caliber,
|
|
62
|
+
* and +6% to +10% with this one**. Whole-corpus runs of `--cost` on this machine: 1.058,
|
|
63
|
+
* 1.068, 1.078, 1.080, 1.083, 1.102 — same code, same corpus, pure machine variance, and
|
|
64
|
+
* the absolute ms/prompt moved 3.04 -> 1.80 across the same runs. Other calibers:
|
|
65
|
+
* 1.054–1.065 with the arm order held fixed, 1.063–1.156 with each arm alone in its own
|
|
66
|
+
* process (the closest shape to production).
|
|
67
|
+
*
|
|
68
|
+
* **Quote the range, re-measure, and never quote the absolute ms** — they vary by 2x with
|
|
69
|
+
* load while the ratio holds. The first draft of this comment quoted a flat 1.058x and said
|
|
70
|
+
* it reproduced to three digits; it does not, and every later run came in above it. See
|
|
71
|
+
* `costCompare`'s docblock for which caliber biases which way. Timing the SELECT alone
|
|
72
|
+
* reports ~1.00x and misses the JS scoring that the widened pool feeds — a different
|
|
73
|
+
* question, not a better answer.
|
|
74
|
+
*
|
|
75
|
+
* The bound is REMOVABLE, and deliberately was not removed. Ordering both SELECTs by the
|
|
76
|
+
* composite instead of raw bm25 is close to expressible in SQL, but "every factor already
|
|
77
|
+
* has a clause" overstated it: of the SEVEN factors, three have named clauses
|
|
78
|
+
* (TYPE_QUALITY_CASE / noisePenaltyClause / citeFactorClause); two more — the 1.5× lesson
|
|
79
|
+
* bonus and the `importance >= 2` step — still need one written, because the SQL forms
|
|
80
|
+
* that exist encode different weights and shapes (`1.0 + 0.3·lesson` and
|
|
81
|
+
* `0.5 + 0.5·importance` in search-engine.mjs's FULL_SCORE); and the last two,
|
|
82
|
+
* cross-project and OR, are constant WITHIN EACH SELECT — they differ between the
|
|
83
|
+
* same-project and cross-project legs, so they are not per-CALL constants, but they never
|
|
84
|
+
* vary among the rows any one LIMIT cuts, which is the only thing this argument needs.
|
|
85
|
+
* That would make LIMIT a true ranking bound. It is not done here
|
|
44
86
|
* because `lib/inject-search-core.mjs:23-25` records this surface's "BM25-sort + JS
|
|
45
87
|
* scoring" composition as a deliberate per-surface asymmetry (#8786), and this face is
|
|
46
88
|
* one `benchmark/denoise-ab.mjs` is structurally blind to (its suites drive the
|
|
@@ -48,8 +90,13 @@ const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
|
48
90
|
* project has repeatedly shipped regressions. Widening is monotone and provable;
|
|
49
91
|
* re-ranking needs a ruler that does not exist yet.
|
|
50
92
|
*
|
|
51
|
-
* WHY WIDENING IS SAFE: the old window is a
|
|
52
|
-
* larger LIMIT), so the new candidate set is a superset.
|
|
93
|
+
* WHY WIDENING IS SAFE: in practice the old window is a PREFIX of the new one (same plan,
|
|
94
|
+
* same ORDER BY, larger LIMIT), so the new candidate set is a superset. "Strict" would be
|
|
95
|
+
* overclaiming — `ORDER BY bm25(...)` carries no tiebreaker, and this release's own
|
|
96
|
+
* fixture lesson is that a degenerate corpus makes `bm25()` return 0.000 for every row and
|
|
97
|
+
* ranking fall to rowid. What is measured rather than argued: `rerank-pool-replay.mjs`
|
|
98
|
+
* reports nonEmptyToEmpty = 0 across the whole corpus, i.e. no prompt loses its injection
|
|
99
|
+
* to the widening. `scored` sorts by composite and
|
|
53
100
|
* the threshold filter is monotone in that score, so every row returned is at least as
|
|
54
101
|
* good as the row it displaced. The only non-monotone stage is the term-coverage filter,
|
|
55
102
|
* which is exactly why the pool needs slack rather than just `MAX_MEMORY_INJECTIONS`.
|
package/hook-update.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { httpConnectProxyFor, getViaConnectProxy } from './lib/proxy-fetch.mjs';
|
|
|
21
21
|
import { acquireLock } from './lib/proc-lock.mjs';
|
|
22
22
|
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
23
23
|
import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
|
|
24
|
+
import { detectInstallShape } from './lib/install-shape.mjs';
|
|
24
25
|
|
|
25
26
|
// ── Configuration ──────────────────────────────────────────
|
|
26
27
|
const GITHUB_REPO = 'sdsrss/claude-mem-lite';
|
|
@@ -156,8 +157,47 @@ export function isUpdateCheckDue() {
|
|
|
156
157
|
} catch { return false; }
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
// D#187. `CLAUDE_PLUGIN_ROOT` is set in every hook and MCP process Claude Code
|
|
161
|
+
// spawns, so inside those the env var answers "am I a plugin install?" perfectly —
|
|
162
|
+
// which is why v3.84.1's fix, which reads it, was enough THERE. It is not set in a
|
|
163
|
+
// plain terminal, and a plugin-only user typing `claude-mem-lite update` therefore
|
|
164
|
+
// fell off both plugin paths at once: getCurrentVersion() returned '0.0.0' (so every
|
|
165
|
+
// release compares as newer, forever), and isPluginMode() was false, so allowInstall
|
|
166
|
+
// defaulted to true and downloadAndInstall laid a full managed tree into
|
|
167
|
+
// ~/.claude-mem-lite — silently converting a plugin-only install into the hybrid
|
|
168
|
+
// whose two trees D#184 documents drifting apart.
|
|
169
|
+
//
|
|
170
|
+
// Same root cause as PR #17: process ENVIRONMENT was the only install-shape
|
|
171
|
+
// evidence consulted. detectInstallShape() reads the FILESYSTEM instead, so it
|
|
172
|
+
// answers the same in a hook, in a terminal, and in a subprocess.
|
|
173
|
+
//
|
|
174
|
+
// Memoised per process. The reason first written here was wrong and the pre-tag review
|
|
175
|
+
// traced it out: it claimed the memo keeps installExtractedRelease()'s post-install
|
|
176
|
+
// isPluginMode() call stable across the managed-tree write. That call cannot depend on
|
|
177
|
+
// this fallback at all — every path reaching installExtractedRelease is a plugin process
|
|
178
|
+
// (checkForUpdate cannot reach downloadAndInstall while pluginMode is true, and
|
|
179
|
+
// syncDataDirFromCache is only called from scripts/launch.mjs and
|
|
180
|
+
// scripts/hook-launcher.mjs), so CLAUDE_PLUGIN_ROOT is set and isPluginMode()
|
|
181
|
+
// short-circuits before pluginOnlyInstall() is consulted, before AND after the write.
|
|
182
|
+
// What the memo actually buys: one readdirSync per process instead of one per call, and
|
|
183
|
+
// a stable answer inside a long-lived MCP server whose plugin cache may be pruned or
|
|
184
|
+
// re-populated underneath it while it runs.
|
|
185
|
+
let shapeMemo;
|
|
186
|
+
function installShape() {
|
|
187
|
+
if (shapeMemo === undefined) {
|
|
188
|
+
try { shapeMemo = detectInstallShape({ installDir: INSTALL_DIR }); } catch { shapeMemo = null; }
|
|
189
|
+
}
|
|
190
|
+
return shapeMemo;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when this machine runs the plugin and has NO managed code install to update. */
|
|
194
|
+
function pluginOnlyInstall() {
|
|
195
|
+
const shape = installShape();
|
|
196
|
+
return Boolean(shape && !shape.managed && shape.activePluginVersion);
|
|
197
|
+
}
|
|
198
|
+
|
|
159
199
|
function isPluginMode() {
|
|
160
|
-
return Boolean(process.env.CLAUDE_PLUGIN_ROOT);
|
|
200
|
+
return Boolean(process.env.CLAUDE_PLUGIN_ROOT) || pluginOnlyInstall();
|
|
161
201
|
}
|
|
162
202
|
|
|
163
203
|
// ── Dev Mode Detection ─────────────────────────────────────
|
|
@@ -313,6 +353,17 @@ export function getCurrentVersion() {
|
|
|
313
353
|
if (pkg.version) return pkg.version;
|
|
314
354
|
} catch { /* fall through to the last resort */ }
|
|
315
355
|
}
|
|
356
|
+
// D#187: no env var in a plain terminal, so ask the filesystem which plugin
|
|
357
|
+
// version this machine actually runs. Without this a plugin-only user's
|
|
358
|
+
// `claude-mem-lite update` reads 0.0.0 and every release compares as newer.
|
|
359
|
+
const active = installShape()?.activePluginVersion;
|
|
360
|
+
if (active) {
|
|
361
|
+
try {
|
|
362
|
+
const pkg = JSON.parse(readFileSync(join(active.root, 'package.json'), 'utf8'));
|
|
363
|
+
if (pkg.version) return pkg.version;
|
|
364
|
+
} catch { /* the cache dir name IS the version — use it rather than 0.0.0 */ }
|
|
365
|
+
if (active.version) return active.version;
|
|
366
|
+
}
|
|
316
367
|
return '0.0.0';
|
|
317
368
|
}
|
|
318
369
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -115,6 +115,85 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
|
|
|
115
115
|
return ids;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* D#179 prerequisite measurement: split each cited id into the responses that
|
|
120
|
+
* ACTED while naming it and the responses that only TALKED about it.
|
|
121
|
+
*
|
|
122
|
+
* `applyCitationDecay` promotes on any `#NN` in assistant text, so writing a release
|
|
123
|
+
* note, an audit, or a review that discusses a memory promotes that memory — including,
|
|
124
|
+
* self-referentially, a note observing that the memory is about to be evicted. Nothing
|
|
125
|
+
* downstream distinguishes "changed the code this lesson describes" from "mentioned the
|
|
126
|
+
* lesson's number". This function supplies the discriminator the deferred item asks for
|
|
127
|
+
* so the size of the contamination can be counted before anything is redesigned.
|
|
128
|
+
*
|
|
129
|
+
* The unit is a RESPONSE, keyed by `requestId` — one model turn, whose entries carry
|
|
130
|
+
* thinking / text / tool_use blocks under a shared id. That granularity is the point:
|
|
131
|
+
* a whole user-to-user exchange almost always contains some tool call, so bounding on
|
|
132
|
+
* user messages would classify nearly everything as "applied" and measure nothing.
|
|
133
|
+
*
|
|
134
|
+
* The key falls back `requestId || message.id || uuid`. In practice `message.id` carries
|
|
135
|
+
* every real Claude Code assistant entry that lacks a requestId, and it is still a
|
|
136
|
+
* per-response key, so the uuid arm is close to unreachable in production and exists only
|
|
137
|
+
* so a keyless entry is never merged into a shared bucket. What must NOT happen is any
|
|
138
|
+
* grouping coarser than one response: one tool call anywhere in a coarser bucket would
|
|
139
|
+
* mark every id in it as acted on, and the measurement would report no contamination on
|
|
140
|
+
* any coding session. (The pre-tag review pointed out that the earlier version of this
|
|
141
|
+
* sentence named only the uuid arm, and that the one test for it constructs a shape
|
|
142
|
+
* production never emits.)
|
|
143
|
+
*
|
|
144
|
+
* Returns a Map keyed by id: `{ withTool, textOnly }` response counts. An id is a pure
|
|
145
|
+
* MENTION when `withTool === 0`.
|
|
146
|
+
*
|
|
147
|
+
* NEITHER SIDE IS A BOUND, and an earlier draft of this comment claimed one. The proxy
|
|
148
|
+
* errs in both directions: naming an id in a response that also calls a tool is only
|
|
149
|
+
* co-occurrence, not evidence the lesson was followed (over-counts `withTool`); and an
|
|
150
|
+
* agent that acts in one response and cites the lesson in a later summary response gets
|
|
151
|
+
* classified pure-mention despite having applied it (over-counts `textOnly`). Use the
|
|
152
|
+
* split to size the question, not to settle it.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} transcriptPath
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {boolean} [opts.mainOnly=true] skip sidechain (subagent) records
|
|
157
|
+
* @returns {Map<number, {withTool: number, textOnly: number}>}
|
|
158
|
+
*/
|
|
159
|
+
export function classifyCitationContext(transcriptPath, opts = {}) {
|
|
160
|
+
const { mainOnly = true } = opts;
|
|
161
|
+
// requestId -> { ids:Set<number>, tool:boolean }
|
|
162
|
+
const responses = new Map();
|
|
163
|
+
for (const entry of readTranscriptEntries(transcriptPath)) {
|
|
164
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
165
|
+
if (mainOnly && entry.isSidechain === true) continue;
|
|
166
|
+
const content = entry.message.content;
|
|
167
|
+
if (!Array.isArray(content)) continue;
|
|
168
|
+
const key = entry.requestId || entry.message.id || entry.uuid;
|
|
169
|
+
if (!key) continue;
|
|
170
|
+
let r = responses.get(key);
|
|
171
|
+
if (!r) { r = { ids: new Set(), tool: false }; responses.set(key, r); }
|
|
172
|
+
for (const block of content) {
|
|
173
|
+
if (block.type === 'tool_use') { r.tool = true; continue; }
|
|
174
|
+
// Only `text` blocks count as citing. `thinking` is deliberately excluded:
|
|
175
|
+
// extractCitationsFromTranscript scores text only, and the whole point is to
|
|
176
|
+
// classify the same numerator the decay loop acts on, not a wider one.
|
|
177
|
+
if (block.type !== 'text' || typeof block.text !== 'string') continue;
|
|
178
|
+
CITATION_RE.lastIndex = 0;
|
|
179
|
+
let m;
|
|
180
|
+
while ((m = CITATION_RE.exec(block.text))) {
|
|
181
|
+
const id = Number(m[1]);
|
|
182
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) r.ids.add(id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const out = new Map();
|
|
187
|
+
for (const { ids, tool } of responses.values()) {
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
let e = out.get(id);
|
|
190
|
+
if (!e) { e = { withTool: 0, textOnly: 0 }; out.set(id, e); }
|
|
191
|
+
if (tool) e.withTool++; else e.textOnly++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
118
197
|
/**
|
|
119
198
|
* Compute cite-recall stats for one transcript: how many of the `#NN`
|
|
120
199
|
* references that surfaced in non-assistant content (hook injections, system
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -28,6 +28,67 @@ export function injectedIdsFileName(project, sessionId) {
|
|
|
28
28
|
return `${base}-${safe}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Namespace prefix for an id written into the shared injected-ids marker.
|
|
33
|
+
*
|
|
34
|
+
* D#188. The marker file is a UNION across hooks and across tables, and the
|
|
35
|
+
* convention for keeping those tables apart already existed — user-prompt-search.js
|
|
36
|
+
* writes `P<id>` for user_prompts rows and `D<id>` for deferred rows, with the
|
|
37
|
+
* comment "so obs ids can't collide in the shared injected-ids file". Observations
|
|
38
|
+
* are the incumbent namespace and stay bare. EVENTS were the one table that never
|
|
39
|
+
* got a prefix, even though the line three above pre-tool-recall.js's dedup filter
|
|
40
|
+
* says in as many words that "events share the numeric id space with observations"
|
|
41
|
+
* — the `src` tag added to carry exactly that distinction was not consulted by the
|
|
42
|
+
* dedup predicate sitting next to it.
|
|
43
|
+
*
|
|
44
|
+
* The consequence, measured on the live store (3747 observations, 91.6% of observation
|
|
45
|
+
* ids also existing as an event id, 2026-09-01T19:56Z): a UPS-injected observation #42
|
|
46
|
+
* made event #42 unreachable to the PreToolUse face for the 5-minute window, and vice
|
|
47
|
+
* versa. Replaying every real session's UPS-injected id set against the injectable
|
|
48
|
+
* events of the project the SESSION ran in: **14 collisions across 11 of 60 sessions
|
|
49
|
+
* (18.3%)**, 2026-09-01T20:17Z.
|
|
50
|
+
*
|
|
51
|
+
* WHICH PROJECT, precisely, because the draft got this wrong while arguing about
|
|
52
|
+
* populations. `scripts/pre-tool-recall.js` does `const project = inferProject()` once
|
|
53
|
+
* and feeds that single value to BOTH `crossHookInjectedFile(project, sessionId)` and
|
|
54
|
+
* the events `SELECT ... WHERE project = ?`. So the scoping that matters is the
|
|
55
|
+
* SESSION's project. The draft instead scoped by the injected observation's own
|
|
56
|
+
* `project` column — a different question, since the `ups`/`fyi` faces do inject
|
|
57
|
+
* cross-project rows — and reported 9 in 9 (15.5%), understating it. The pre-tag claims
|
|
58
|
+
* review caught the population, having reconstructed it independently at 12 in 10.
|
|
59
|
+
*
|
|
60
|
+
* The predicate, stated here because no harness is committed for it: seen-set per
|
|
61
|
+
* session = the shipped `extractInjectedBySurface(path).ups`; injectable events =
|
|
62
|
+
* `importance >= 2 AND superseded_at_epoch IS NULL AND file_paths NOT IN (NULL,'[]')`;
|
|
63
|
+
* session project recovered by matching each transcript directory against the DB's own
|
|
64
|
+
* project list (forward map, since flattening `/` and `_` to `-` is not invertible).
|
|
65
|
+
* Dropping the project condition entirely reports 72 in 41 — a population the
|
|
66
|
+
* project-filtered query can never reach.
|
|
67
|
+
*
|
|
68
|
+
* A SECOND consequence was claimed here before v3.86.0 was tagged and is FALSE, so it
|
|
69
|
+
* is recorded rather than deleted: bare event ids do flow into hook.mjs's
|
|
70
|
+
* `pathAInjectedIds`, which is handed to searchRelevantMemories and
|
|
71
|
+
* rankImperativeCandidates as an OBSERVATION exclude list — but they suppress
|
|
72
|
+
* NOTHING there, because `mergeCrossHookInjected` writes every id as a STRING and
|
|
73
|
+
* both consumers test `new Set(excludeIds).has(r.id)` against a NUMBER out of
|
|
74
|
+
* SQLite. Measured: excluding `1` returns nothing, excluding `'1'` returns the row.
|
|
75
|
+
* The pre-tag correctness review found this. What it exposes is a real and separate
|
|
76
|
+
* defect — that exclude list is inert for every id the marker holds as a string,
|
|
77
|
+
* observations included — which is D#193, not this one.
|
|
78
|
+
*
|
|
79
|
+
* Legacy in-flight files (bare ids that were a mix of both tables) keep their old
|
|
80
|
+
* meaning for at most DEDUP_STALE_MS and then rotate; there is deliberately no
|
|
81
|
+
* format version, because a 5-minute window of the PRE-EXISTING behaviour is a
|
|
82
|
+
* smaller cost than a schema every reader has to branch on.
|
|
83
|
+
*
|
|
84
|
+
* @param {number|string} id
|
|
85
|
+
* @param {'obs'|'evt'} [src]
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function injectedIdKey(id, src = 'obs') {
|
|
89
|
+
return src === 'evt' ? `E${id}` : String(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
31
92
|
/**
|
|
32
93
|
* Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
|
|
33
94
|
* ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
|
package/lib/stats-quality.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// collide with CLI's `out()` stdout-write pattern.
|
|
5
5
|
|
|
6
6
|
import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
|
|
7
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
7
8
|
import { truncate } from '../format-utils.mjs';
|
|
8
9
|
import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
|
|
9
10
|
|
|
@@ -23,6 +24,26 @@ export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// D#191: the rule above is a rule about POPULATIONS, and until v3.86.0 it had only
|
|
28
|
+
// been applied to computeNoiseGauge — a pure function that takes `liveTotal` from its
|
|
29
|
+
// caller. Every query below ran on a bare `FROM observations`, so `stats --quality`
|
|
30
|
+
// rendered ratios over all rows (compressed + superseded included) under labels that
|
|
31
|
+
// name no population. Numerator and denominator were paired, so each ratio was
|
|
32
|
+
// internally consistent; what was wrong was the population it described. Measured on
|
|
33
|
+
// the live store 2026-09-01 (3742 rows total, 2284 live): all-time Lesson rate read
|
|
34
|
+
// 59.6% where the live store is 92.9%, and all-time LOW_SIGNAL read 22.9% where the
|
|
35
|
+
// live store is 1.1% — compression retires exactly the low-signal, lesson-less rows,
|
|
36
|
+
// so an all-rows denominator reports a store roughly three decades of quality worse
|
|
37
|
+
// than the one retrieval actually searches.
|
|
38
|
+
//
|
|
39
|
+
// `days` is user-settable (`stats --quality --days N`), so this is NOT confined to the
|
|
40
|
+
// all-time bracket: the wider the window, the more compressed rows it sweeps in. The
|
|
41
|
+
// filter is therefore applied to the window and per-type queries too, not only to the
|
|
42
|
+
// two the review named, and all three now describe one population — the live corpus.
|
|
43
|
+
//
|
|
44
|
+
// Deliberately NOT filtered: `purgeRow`, whose entire subject is compressed rows.
|
|
45
|
+
const LIVE = liveObsFilterSql('');
|
|
46
|
+
|
|
26
47
|
export function computeQualityStats(db, { project, days }) {
|
|
27
48
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
28
49
|
const baseParams = project ? [project] : [];
|
|
@@ -53,7 +74,7 @@ export function computeQualityStats(db, { project, days }) {
|
|
|
53
74
|
COALESCE(SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END), 0) as bugfix_total,
|
|
54
75
|
COALESCE(SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END), 0) as bugfix_unresolved
|
|
55
76
|
FROM observations
|
|
56
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
77
|
+
WHERE created_at_epoch >= ? AND ${LIVE} ${projectFilter}
|
|
57
78
|
`).get(cutoff, ...baseParams);
|
|
58
79
|
|
|
59
80
|
const allTimeRow = db.prepare(`
|
|
@@ -62,7 +83,7 @@ export function computeQualityStats(db, { project, days }) {
|
|
|
62
83
|
COALESCE(SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END), 0) as with_lesson,
|
|
63
84
|
COALESCE(SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END), 0) as low_signal
|
|
64
85
|
FROM observations
|
|
65
|
-
WHERE
|
|
86
|
+
WHERE ${LIVE} ${projectFilter}
|
|
66
87
|
`).get(...baseParams);
|
|
67
88
|
|
|
68
89
|
const typeRows = db.prepare(`
|
|
@@ -72,7 +93,7 @@ export function computeQualityStats(db, { project, days }) {
|
|
|
72
93
|
COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END), 0) as accessed,
|
|
73
94
|
COALESCE(SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END), 0) as with_lesson
|
|
74
95
|
FROM observations
|
|
75
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
96
|
+
WHERE created_at_epoch >= ? AND ${LIVE} ${projectFilter}
|
|
76
97
|
GROUP BY type
|
|
77
98
|
ORDER BY total DESC
|
|
78
99
|
`).all(cutoff, ...baseParams);
|
|
@@ -82,7 +103,7 @@ export function computeQualityStats(db, { project, days }) {
|
|
|
82
103
|
FROM observations
|
|
83
104
|
WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
|
|
84
105
|
AND COALESCE(access_count, 0) > 0
|
|
85
|
-
AND
|
|
106
|
+
AND ${LIVE}
|
|
86
107
|
${projectFilter}
|
|
87
108
|
ORDER BY ac DESC
|
|
88
109
|
LIMIT 5
|
|
@@ -109,7 +130,9 @@ export function formatQualityReport(data) {
|
|
|
109
130
|
const lines = [];
|
|
110
131
|
lines.push(`[mem] Quality snapshot${scope} — window: ${days}d`);
|
|
111
132
|
lines.push('────────────────────────────────────────────────────');
|
|
112
|
-
|
|
133
|
+
// Every ratio below divides live rows by live rows (D#191) — say so once, here,
|
|
134
|
+
// rather than qualifying each label and still leaving the brackets ambiguous.
|
|
135
|
+
lines.push(` Writes (${days}d): ${windowRow.total} live observations (compressed/superseded excluded throughout)`);
|
|
113
136
|
|
|
114
137
|
const lessonPct = pct(windowRow.with_lesson, windowRow.total);
|
|
115
138
|
const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.86.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.86.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.86.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",
|
|
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
|
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
9
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
10
10
|
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
11
|
-
import { injectedIdsFileName } from '../lib/injected-ids.mjs';
|
|
11
|
+
import { injectedIdsFileName, injectedIdKey } from '../lib/injected-ids.mjs';
|
|
12
12
|
import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
|
|
13
13
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
14
14
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
@@ -63,10 +63,28 @@ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtim
|
|
|
63
63
|
import { DEDUP_STALE_MS as CROSS_HOOK_DEDUP_MS } from './prompt-search-utils.mjs';
|
|
64
64
|
// Upper bound on the over-fetch the cross-hook dedup buys itself (ALGO-4). The dedup
|
|
65
65
|
// runs in JS after the SELECTs, so each LIMIT is raised by the seen-set size to keep the
|
|
66
|
-
// dedup a re-ranking rather than a truncation. This cap exists
|
|
67
|
-
// read from a file on disk
|
|
68
|
-
//
|
|
69
|
-
//
|
|
66
|
+
// dedup a re-ranking rather than a truncation. This cap exists for one reason only: the
|
|
67
|
+
// seen-set is read from a file on disk, and a number off disk must never size a query.
|
|
68
|
+
//
|
|
69
|
+
// It is NOT a sufficiency argument, and the first version of this comment claimed one —
|
|
70
|
+
// "bounded by UPS's own per-prompt budget in practice (MAX_RESULTS 3)". That premise is
|
|
71
|
+
// false. `crossHookInjectedFile` is a UNION across hooks and calls inside the staleness
|
|
72
|
+
// window: `mergeCrossHookInjected` unions new ids into the old ones, UPS contributes up
|
|
73
|
+
// to MAX_RESULTS per prompt and this script contributes up to `mergeCap` per trigger, so
|
|
74
|
+
// nothing holds it at 3. Measured on this machine's `runtime/.claude-mem-injected-*`
|
|
75
|
+
// markers (2026-09-01): id-count histogram 1x9, 2x1, 3x2, 16x1 over n=13, and 1x11, 2x1,
|
|
76
|
+
// 3x1, 15x1 over n=14 an hour later. Read that as "3 is not a bound", not as a
|
|
77
|
+
// distribution: it is one developer machine, the tail entry is a single long agent session
|
|
78
|
+
// (on the re-measure the top entry was the measuring session itself), and the count is of
|
|
79
|
+
// ids IN THE FILE while `readCrossHookInjected` returns an EMPTY set for a payload whose
|
|
80
|
+
// `ts` is outside DEDUP_STALE_MS — file size and runtime seen-set size are not the same
|
|
81
|
+
// quantity.
|
|
82
|
+
//
|
|
83
|
+
// The residual failure mode that premise was hiding, derived from the arithmetic and NOT
|
|
84
|
+
// observed in the wild: at a seen-set of 16 the slack still caps at 5, so a Read fetches
|
|
85
|
+
// obsLimit = 6, and if all 6 are in the seen-set the face goes silent again — the exact
|
|
86
|
+
// failure ALGO-4 exists to fix. The cap is right (an unbounded LIMIT is worse), the
|
|
87
|
+
// reassurance was wrong.
|
|
70
88
|
const CROSS_HOOK_DEDUP_SLACK_MAX = 5;
|
|
71
89
|
// v2.33.1: cooldown path is session-scoped so same-file-twice within one
|
|
72
90
|
// session never re-injects (was: global file, 5-min window). Cross-session:
|
|
@@ -465,8 +483,11 @@ try {
|
|
|
465
483
|
// On a Read (obsLimit 1 / eventsLimit 1) one dedup hit silenced the whole face.
|
|
466
484
|
// Read the seen-set FIRST and over-fetch by its size so the dedup removes rows
|
|
467
485
|
// from a pool that still has enough left to fill the cap. Capped at
|
|
468
|
-
// CROSS_HOOK_DEDUP_SLACK_MAX
|
|
469
|
-
//
|
|
486
|
+
// CROSS_HOOK_DEDUP_SLACK_MAX purely because the seen-set is read off disk and a
|
|
487
|
+
// number off disk must not size a query — NOT because the seen-set is small. It is
|
|
488
|
+
// a cross-hook union over the staleness window and was measured at up to 16 ids on
|
|
489
|
+
// this machine, so with the slack saturated a Read can still fetch fewer rows than
|
|
490
|
+
// the seen-set holds and go silent. See the constant's docblock.
|
|
470
491
|
const crossHookSeen = readCrossHookInjected(project, sessionId);
|
|
471
492
|
const dedupSlack = Math.min(crossHookSeen.size, CROSS_HOOK_DEDUP_SLACK_MAX);
|
|
472
493
|
const obsLimit = (isRead ? 1 : 2) + dedupSlack;
|
|
@@ -571,8 +592,12 @@ try {
|
|
|
571
592
|
...rows.map(r => ({ ...r, src: 'obs' })),
|
|
572
593
|
...eventRows.map(r => ({ ...r, src: 'evt' })),
|
|
573
594
|
];
|
|
595
|
+
// D#188: compare on the NAMESPACED key, not the bare number. The `src` tag two
|
|
596
|
+
// comments up exists precisely because the two tables share an id space; the
|
|
597
|
+
// predicate that consumed it did not use it, so an observation injected by UPS
|
|
598
|
+
// silently blocked the same-numbered event and vice versa.
|
|
574
599
|
const dedupedRows = crossHookSeen.size > 0
|
|
575
|
-
? sourcedRows.filter(r => !crossHookSeen.has(
|
|
600
|
+
? sourcedRows.filter(r => !crossHookSeen.has(injectedIdKey(r.id, r.src)))
|
|
576
601
|
: sourcedRows;
|
|
577
602
|
|
|
578
603
|
// Merge: observations first (they carry richer lesson_learned), then events.
|
|
@@ -725,7 +750,13 @@ try {
|
|
|
725
750
|
// file so the next UPS prompt skips them too. Always write, even on
|
|
726
751
|
// empty allRows, so the file's ts stays fresh for the no-op case where
|
|
727
752
|
// we'd otherwise drift outside the dedup window.
|
|
728
|
-
|
|
753
|
+
// D#188: namespaced on write too — otherwise a bare event id here would keep
|
|
754
|
+
// blocking the same-numbered observation on the next UPS prompt. (An earlier
|
|
755
|
+
// version of this comment also claimed it leaked into hook.mjs's
|
|
756
|
+
// pathAInjectedIds; it reaches there, but suppresses nothing, because this
|
|
757
|
+
// function stringifies every id and that consumer compares against a numeric
|
|
758
|
+
// row id. That inertness is a separate live defect — D#193.)
|
|
759
|
+
mergeCrossHookInjected(project, allRows.map(r => injectedIdKey(r.id, r.src)), sessionId);
|
|
729
760
|
} catch (e) {
|
|
730
761
|
// Silent failure — never block editing, but record for self-observation.
|
|
731
762
|
recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
|
|
@@ -815,6 +815,21 @@ async function main() {
|
|
|
815
815
|
// tail of the SAME query is the same reach with one fewer FTS scan, and it cannot
|
|
816
816
|
// regress the head — a flat cap of 2 over the merged set could have, by evicting
|
|
817
817
|
// a third head row that ships today.
|
|
818
|
+
//
|
|
819
|
+
// "Additive" scopes to THIS SET, not to what finally ships. Downstream the merge
|
|
820
|
+
// appends `fileRows` after `ftsRows` (dedup by id) and then slices to MAX_RESULTS, and
|
|
821
|
+
// `deep` rows sort LAST within `ftsRows` (weaker |bm25|, ascending sort) — so a deep
|
|
822
|
+
// row takes a slot ahead of a file-recall row whenever `|head| < MAX_RESULTS` AND
|
|
823
|
+
// `|head| + |deep| + |fileRows| > MAX_RESULTS`, with at least one deep row and one
|
|
824
|
+
// fileRow present. The first condition is not redundant: `mainLimit` is
|
|
825
|
+
// `intent?.limit || MAX_RESULTS`, so head can already be 3 — and at head=3 the fileRow
|
|
826
|
+
// never boarded in the first place, so nothing is displaced. At head=2/deep=1/
|
|
827
|
+
// fileRows=1 the output is [h1, h2, d1] where it was [h1, h2, f1]; at
|
|
828
|
+
// head=1/deep=1/fileRows=1 nothing is displaced. The trade is one
|
|
829
|
+
// "filename matched, presumed deliberate" row for one "weak overall bm25, admitted
|
|
830
|
+
// only on an identifier hit" row. It is a real quality judgement and it is UNMEASURED
|
|
831
|
+
// — denoise-ab is structurally blind to this face. v3.85.0's release note called the
|
|
832
|
+
// whole change "strictly additive"; true of the bypass set, false of the output.
|
|
818
833
|
const bypassFloorOk = (r) => typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor;
|
|
819
834
|
let bypassRows = [];
|
|
820
835
|
if (IDENTIFIER_BYPASS && promptIdentifiers.length > 0) {
|