claude-mem-lite 3.82.0 → 3.83.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/README.md +3 -1
- package/hook-episode.mjs +66 -8
- package/hook.mjs +228 -19
- package/lib/citation-tracker.mjs +44 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/pre-tool-recall.js +39 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.83.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.83.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -827,7 +827,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
827
827
|
| `CLAUDE_MEM_AUTO_DEEP` | `0` disables automatic deep-search escalation (one Haiku call rewriting a weak query into keyword/concept/HyDE variants). Explicit `deep: true` still works. | _(auto)_ |
|
|
828
828
|
| `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
|
|
829
829
|
| `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
|
|
830
|
-
| `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. | _(off)_ |
|
|
830
|
+
| `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
|
|
831
|
+
| `CLAUDE_MEM_READS_CARRY` | An episode flush collects `reads-<project>.txt` only when it will actually save an observation, so a flush that records nothing no longer discards the Read paths it swept up (42.2% of the paths a flush consumed, measured over 1122 transcripts). `0` restores the pre-v3.83.0 behaviour. | _(on)_ |
|
|
831
832
|
|
|
832
833
|
### Citation tracking and feedback
|
|
833
834
|
|
|
@@ -840,6 +841,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
840
841
|
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.6` |
|
|
841
842
|
| `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
|
|
842
843
|
| `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
|
|
844
|
+
| `CLAUDE_MEM_SUBAGENT_DECAY` | The `subagent` injection face feeds the decay loop: memories handed to a dispatched agent enter the denominator, and the citation that agent makes in its own transcript counts as the numerator. `0` returns the face to metered-but-never-decaying (v3.77–v3.82). | _(on)_ |
|
|
843
845
|
| `CLAUDE_MEM_METRICS` | `1` records feature-injection counters surfaced by `claude-mem-lite stats`. | _(off)_ |
|
|
844
846
|
|
|
845
847
|
### Background work
|
package/hook-episode.mjs
CHANGED
|
@@ -314,17 +314,75 @@ const RESEARCH_ENTRY_THRESHOLD = 8;
|
|
|
314
314
|
* 2026-08 20/ 193 10.4% >=8: 0
|
|
315
315
|
* lifetime reaching >=8: 81
|
|
316
316
|
*
|
|
317
|
-
* For three consecutive months this field fed the threshold at a real rate.
|
|
318
|
-
* 2026-05
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* boundary
|
|
317
|
+
* For three consecutive months this field fed the threshold at a real rate. The break is
|
|
318
|
+
* sharp: 2026-05-08 reads 11%, 05-09 onward reads 0.
|
|
319
|
+
*
|
|
320
|
+
* "THE REACHABLE INPUT IS THE EPISODE BOUNDARY" WAS WRONG — D#174 investigated it and the
|
|
321
|
+
* boundary never moved. One query falsifies the whole family of boundary explanations:
|
|
322
|
+
* measure the SIBLING column the same producer writes. `files_modified` is non-empty on
|
|
323
|
+
* 85-98% of rows every month from 2026-02 through 2026-08, averaging 2.0-2.6 paths, and it
|
|
324
|
+
* does not so much as dip across the break — while `files_read` goes 60% -> 1%. Episodes
|
|
325
|
+
* still carry ~2 edits each; they just stopped carrying reads. A smaller boundary would have
|
|
326
|
+
* taken both columns down together. Consistent with that, nothing in this repo changed at the
|
|
327
|
+
* break: zero commits on 05-08/05-09, scripts/post-tool-use.sh byte-identical from 03-29 to
|
|
328
|
+
* 05-24, hooks/hooks.json byte-identical from 04-22 to 05-10, and EPISODE_BUFFER_SIZE /
|
|
329
|
+
* EPISODE_TIME_GAP_MS / isRelatedToEpisode untouched since 2026-02-11.
|
|
330
|
+
*
|
|
331
|
+
* WHAT ACTUALLY SET THE RATE (probed, not read): a flush consumed reads-<project>.txt
|
|
332
|
+
* unconditionally but only PERSISTED it when the episode was significant
|
|
333
|
+
* (flushEpisodeGroup saves on `isSignificant`, and unlinks the flush file otherwise). So a
|
|
334
|
+
* buffered-but-insignificant flush — a successful `npm test` on its own, say — swallowed every
|
|
335
|
+
* Read accumulated since the previous flush and wrote none of them anywhere. Measured in a
|
|
336
|
+
* sandbox: seed 2 Reads, fire one such flush, and 0 observations are saved, the reads-file is
|
|
337
|
+
* gone, and the NEXT (edit-bearing, significant) observation carries `files_read=[]`.
|
|
338
|
+
*
|
|
339
|
+
* PAST TENSE SINCE v3.83.0: D#178 is FIXED. `flushEpisodeWithDb` now decides significance
|
|
340
|
+
* before it touches the file, and an insignificant flush leaves it in place for the next
|
|
341
|
+
* saving one (`CLAUDE_MEM_READS_CARRY=0` restores the old order). Two numbers in the
|
|
342
|
+
* paragraph above were also wrong and are corrected here rather than left to be re-quoted:
|
|
343
|
+
* the significant share is ~59%, not "~4-8%" — the `episode_significance` meter reads 40.7%
|
|
344
|
+
* INsignificant over n=938 across three active days — and the 92-96% figure D#178 was filed
|
|
345
|
+
* on came from the same slip. What the loss actually was, replayed over 1122 real
|
|
346
|
+
* transcripts through this file's own batcher (`benchmark/episode-flush-replay.mjs`):
|
|
347
|
+
* 42.2% of the Read paths a flush consumed destroyed, 72.7% of significant flushes
|
|
348
|
+
* carrying none. Same measurement pass as CHANGELOG v3.83.0, CLAUDE.md and README — quoting
|
|
349
|
+
* a second pass here would be the stitched-across-runs error one file at a time.
|
|
350
|
+
*
|
|
351
|
+
* The D#171 conclusion below is UNAFFECTED and that is worth stating explicitly, because
|
|
352
|
+
* the fix moves the quantity its arithmetic used. Post-fix the carried distinct set runs
|
|
353
|
+
* median 1, p95 6, max 21 per delivering flush — still nowhere near rule 4's threshold of
|
|
354
|
+
* 8 on a per-EPISODE basis, and rule 4 does not read this field anyway.
|
|
355
|
+
*
|
|
356
|
+
* Note the first version of that probe used `echo hello` as its "insignificant" entry.
|
|
357
|
+
* detectBashSignificance drops it, so the episode had zero entries, flushEpisode
|
|
358
|
+
* early-returned at `entries.length === 0`, the reads were never touched — and the probe
|
|
359
|
+
* confidently reported the opposite conclusion. An insignificant entry must be asserted into
|
|
360
|
+
* the buffer before it proves anything.
|
|
322
361
|
*
|
|
323
362
|
* D#171 closed as won't-fix-as-specified: the repair it named does not work at the current
|
|
324
363
|
* cadence, and re-pointing the rule would move the dormancy to a field nobody suspects.
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
364
|
+
* That closure stands, and D#174 no longer offers a reason to reopen it — the rule's own
|
|
365
|
+
* input (`readCount`, which counts Read/Grep ENTRIES, and Read never reaches Node) is a
|
|
366
|
+
* different quantity from `filesRead` and is untouched by any of the above.
|
|
367
|
+
*
|
|
368
|
+
* EXACTLY ONE claim above is pinned by a test, and deliberately so (D#175). Every number
|
|
369
|
+
* here is a corpus measured at a timestamp — a test over those would be a snapshot that
|
|
370
|
+
* rots and gets edited into greenness. The per-FLUSH claim is different in kind: it is a
|
|
371
|
+
* property of code (the reads-file is renamed aside, then the copy is unlinked), and if
|
|
372
|
+
* someone later makes reads accumulate across flushes, every "out of reach at ~1 Read per
|
|
373
|
+
* episode" sentence above silently becomes false. That is the one this closure rests on,
|
|
374
|
+
* so `tests/feature-sweep-hooks.test.mjs` → "the reads-file is consumed, not accumulated
|
|
375
|
+
* (D#175)" drives two real flushes through the subprocess and asserts the second one starts
|
|
376
|
+
* empty. Rename-becomes-copy and the dropped unlink are separate mutations caught by
|
|
377
|
+
* separate assertions there — one does not cover the other.
|
|
378
|
+
*
|
|
379
|
+
* THAT ALARM DID NOT FIRE FOR D#178, and the reason is worth keeping. Both of its flushes
|
|
380
|
+
* are SIGNIFICANT (each buffers a `.sql` Write), so both take the collect branch under the
|
|
381
|
+
* new order too — the v3.83.0 change walked straight underneath a guard installed one
|
|
382
|
+
* commit earlier to catch exactly "reads accumulate across flushes". Its sibling cases in
|
|
383
|
+
* the same file now cover the insignificant arm, in both flag positions and in the
|
|
384
|
+
* multi-session shape; a per-flush guard whose fixture only ever exercises one arm of the
|
|
385
|
+
* branch it guards is covering the arm nobody was going to change.
|
|
328
386
|
*
|
|
329
387
|
* @param {object} episode
|
|
330
388
|
* @returns {{significant: boolean, rule: 1|2|3|4|null, readCount: number,
|
package/hook.mjs
CHANGED
|
@@ -216,27 +216,124 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
// D#178 safety valve. With CLAUDE_MEM_READS_CARRY on, an insignificant flush leaves
|
|
220
|
+
// `reads-<project>.txt` in place, so a long insignificant streak keeps appending to it.
|
|
221
|
+
//
|
|
222
|
+
// THE CAP COUNTS LINES, NOT DISTINCT PATHS, and that is the whole point. The writer
|
|
223
|
+
// (`scripts/post-tool-use.sh`) appends one line per Read with no dedup, so the file grows
|
|
224
|
+
// by REPEATED lines — a session re-reading the same five files forever. The first draft
|
|
225
|
+
// compared the DISTINCT set against the cap, which is the quantity that stays tiny
|
|
226
|
+
// (measured on the live corpus: median 1, p95 6, max 21 carried paths), so the valve could
|
|
227
|
+
// not fire on the growth mode it exists to bound. That is this repo's recurring
|
|
228
|
+
// "predicate that cannot return true reports the defect as absent" shape, and the pre-tag
|
|
229
|
+
// review caught it here.
|
|
230
|
+
//
|
|
231
|
+
// Still a backstop and not a relevance bound — the same distinction
|
|
232
|
+
// IMPERATIVE_POOL_BACKSTOP documents for its pool.
|
|
233
|
+
const READS_CARRY_MAX_LINES = 20000;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Bound the reads file when an insignificant flush leaves it in place. Rewrites it only
|
|
237
|
+
* when it is over READS_CARRY_MAX_LINES raw lines, keeping the newest distinct paths.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} readsFile
|
|
240
|
+
* @returns {number} distinct paths now held; 0 when there is no reads file (the COMMON
|
|
241
|
+
* case — no Read since the last collect); -1 only when the file exists but could not be
|
|
242
|
+
* read or the trim threw. The three-way split is the point: `episode_reads` is the ruler
|
|
243
|
+
* for this flag, and folding "nothing to hold" together with "could not look" would put
|
|
244
|
+
* the normal case and the broken case on the same value. A first draft returned -1 for
|
|
245
|
+
* both, which made -1 the overwhelmingly common reading and hid the failure inside it.
|
|
246
|
+
*/
|
|
247
|
+
function trimReadsFile(readsFile) {
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
raw = readFileSync(readsFile, 'utf8');
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return e?.code === 'ENOENT' ? 0 : -1;
|
|
253
|
+
}
|
|
224
254
|
try {
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
255
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
256
|
+
const paths = [...new Set(lines)];
|
|
257
|
+
if (lines.length <= READS_CARRY_MAX_LINES) return paths.length;
|
|
258
|
+
const keep = paths.slice(-READS_CARRY_MAX_LINES);
|
|
259
|
+
const tmp = readsFile + `.trim-${process.pid}`;
|
|
260
|
+
writeFileSync(tmp, keep.join('\n') + '\n', { mode: 0o600 });
|
|
261
|
+
renameSync(tmp, readsFile);
|
|
262
|
+
return keep.length;
|
|
230
263
|
} catch {
|
|
231
|
-
|
|
264
|
+
return -1;
|
|
232
265
|
}
|
|
266
|
+
}
|
|
233
267
|
|
|
268
|
+
function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
234
269
|
// Split by CC session so concurrent same-project sessions flush as separate
|
|
235
270
|
// observations. planEpisodeFlush returns [episode] BY REFERENCE for the common
|
|
236
271
|
// single-session (or all-legacy) case → flushEpisodeGroup(episode) is identical
|
|
237
272
|
// to pre-grouping. Two+ interleaved sessions each get their own sub-episode.
|
|
238
273
|
const subs = planEpisodeFlush(episode);
|
|
274
|
+
|
|
275
|
+
// D#178. The reads file used to be consumed right here, unconditionally, BEFORE
|
|
276
|
+
// anything knew whether this flush would persist an observation — and an
|
|
277
|
+
// insignificant flush then dropped every path it had just swept up, leaving the
|
|
278
|
+
// next observation that DID save with files_read = []. Measured over 1122 real
|
|
279
|
+
// transcripts (benchmark/episode-flush-replay.mjs): 42.2% of the reads a flush
|
|
280
|
+
// consumed died that way, and 72.7% of significant flushes carried none at all.
|
|
281
|
+
//
|
|
282
|
+
// The fix is an ORDERING, not a buffer: explainSignificance reads only `entries`
|
|
283
|
+
// and `files`, never `filesRead`, so the verdict is available before the file is
|
|
284
|
+
// touched. An insignificant flush now leaves the file alone and the next
|
|
285
|
+
// significant one collects the union. Ages measured on the same corpus: the reads
|
|
286
|
+
// that survive attach a median 1.7 minutes and p90 10.1 minutes later than they do
|
|
287
|
+
// today, which is the whole cost — a read carried across two insignificant flushes
|
|
288
|
+
// lands on the edit it preceded rather than on nothing.
|
|
289
|
+
//
|
|
290
|
+
// ON by default since v3.83.0. `CLAUDE_MEM_READS_CARRY=0` restores the pre-D#178
|
|
291
|
+
// behavior byte for byte — kept as an off switch because this changes what a released
|
|
292
|
+
// artifact stores, and a defect here is invisible from the outside (the symptom is an
|
|
293
|
+
// absent field, which reads exactly like "there was nothing to record").
|
|
294
|
+
const carryReads = !['0', 'off', 'false', 'no'].includes(
|
|
295
|
+
String(process.env.CLAUDE_MEM_READS_CARRY ?? '').toLowerCase());
|
|
296
|
+
const willPersist = !carryReads || subs.some((s) => episodeHasSignificantContent(s));
|
|
297
|
+
|
|
298
|
+
// Collect Read file paths tracked by post-tool-use.sh
|
|
299
|
+
// Use rename to atomically collect — prevents losing concurrent appends
|
|
300
|
+
const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
|
|
301
|
+
const readsCollect = readsFile + `.collect-${Date.now()}`;
|
|
302
|
+
let readsHeld = 0;
|
|
303
|
+
if (willPersist) {
|
|
304
|
+
try {
|
|
305
|
+
renameSync(readsFile, readsCollect);
|
|
306
|
+
const raw = readFileSync(readsCollect, 'utf8');
|
|
307
|
+
const paths = [...new Set(raw.split('\n').filter(Boolean))];
|
|
308
|
+
episode.filesRead = paths;
|
|
309
|
+
try { unlinkSync(readsCollect); } catch {}
|
|
310
|
+
} catch {
|
|
311
|
+
episode.filesRead = episode.filesRead || [];
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
episode.filesRead = [];
|
|
315
|
+
// Not collecting means the file keeps growing across an insignificant streak, and a
|
|
316
|
+
// project that never flushes significantly would grow it without bound. Trim it.
|
|
317
|
+
//
|
|
318
|
+
// The trim's race window is WIDER than the collect path's, and saying otherwise (the
|
|
319
|
+
// first draft did) is the kind of comfortable claim that stops anyone checking: the
|
|
320
|
+
// collect path is a single atomic `renameSync`, while this is read → dedup → write tmp
|
|
321
|
+
// → rename, and a `>>` append from the bash prefilter landing inside that span is lost
|
|
322
|
+
// to the final rename. Accepted rather than fixed: the trim only runs above
|
|
323
|
+
// READS_CARRY_MAX_LINES, which no observed session approaches, so the exposure is a
|
|
324
|
+
// path or two in a session that has already read 20000 times.
|
|
325
|
+
readsHeld = trimReadsFile(readsFile);
|
|
326
|
+
}
|
|
327
|
+
// planEpisodeFlush now runs BEFORE the collection, so the multi-session branch — the
|
|
328
|
+
// one that builds fresh objects rather than returning [episode] by reference — copied
|
|
329
|
+
// whatever filesRead the buffer happened to carry, not what was just collected. The
|
|
330
|
+
// single-group path is identity and unaffected; this line is what keeps the two paths
|
|
331
|
+
// saying the same thing, and without it concurrent same-project sessions would lose
|
|
332
|
+
// their reads while a solo session kept them.
|
|
333
|
+
for (const sub of subs) if (sub !== episode) sub.filesRead = episode.filesRead;
|
|
334
|
+
|
|
239
335
|
let anySignificant = false;
|
|
336
|
+
let writefail = false;
|
|
240
337
|
for (const sub of subs) {
|
|
241
338
|
const r = flushEpisodeGroup(sub, db);
|
|
242
339
|
if (r === 'writefail') {
|
|
@@ -245,12 +342,39 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
|
245
342
|
// keep the rest. The asymmetry is safe: each group's immediate obs is persisted
|
|
246
343
|
// BEFORE its flush-file write, so re-flushing the whole buffer would re-emit
|
|
247
344
|
// already-saved groups as duplicate observations.
|
|
248
|
-
if (subs.length === 1)
|
|
345
|
+
if (subs.length === 1) { writefail = true; break; }
|
|
249
346
|
continue;
|
|
250
347
|
}
|
|
251
348
|
if (r === 'significant') anySignificant = true;
|
|
252
349
|
}
|
|
253
350
|
|
|
351
|
+
// D#178 instrument, and the ruler for the flag above. With CLAUDE_MEM_READS_CARRY
|
|
352
|
+
// off, a row with `significant: false` and `readsConsumed > 0` is that many Read
|
|
353
|
+
// paths collected and dropped on the floor. With it on, those rows become
|
|
354
|
+
// `readsConsumed: 0, readsHeld: N` — the same event, now recording a deferral
|
|
355
|
+
// instead of a loss, so one query over this sink covers both arms.
|
|
356
|
+
// Emitted HERE and not in flushEpisodeGroup on purpose: planEpisodeFlush copies
|
|
357
|
+
// the SAME filesRead array into every sub, so a per-group counter double-counts
|
|
358
|
+
// the multi-session case, and the destroyed/kept decision is `anySignificant`,
|
|
359
|
+
// which only exists at this level. `writefail` is its own arm because on the SIGNIFICANT
|
|
360
|
+
// path it keeps the episode buffer for a retry the reads file can no longer serve — it
|
|
361
|
+
// was already unlinked, so the retry re-collects nothing. A writefail flush is NOT
|
|
362
|
+
// necessarily one whose significance said collect: `flushEpisodeGroup` writes its flush
|
|
363
|
+
// file outside the significance branch, so an insignificant flush can fail there too —
|
|
364
|
+
// and with the flag on that case is strictly better than before, because the reads file
|
|
365
|
+
// was never touched and the retry still finds it.
|
|
366
|
+
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
367
|
+
recordMetric(join(RUNTIME_DIR, '..'), {
|
|
368
|
+
event: 'episode_reads',
|
|
369
|
+
readsConsumed: (episode.filesRead || []).length,
|
|
370
|
+
readsHeld,
|
|
371
|
+
carry: carryReads,
|
|
372
|
+
significant: anySignificant,
|
|
373
|
+
subs: subs.length,
|
|
374
|
+
writefail,
|
|
375
|
+
});
|
|
376
|
+
if (writefail) return;
|
|
377
|
+
|
|
254
378
|
// Aggregate receipt over the whole episode, gated exactly as before
|
|
255
379
|
// (isSignificant → anySignificant). v2.33.4: Stop rejects hookSpecificOutput.
|
|
256
380
|
if (anySignificant && RECEIPT_EVENTS.has(hookEventName)) {
|
|
@@ -807,6 +931,19 @@ async function handleStop() {
|
|
|
807
931
|
// applyCitationDecay checks separately.
|
|
808
932
|
try {
|
|
809
933
|
if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
|
|
934
|
+
// D#152/D#177: the `subagent` face, collected ONCE, up front, and used twice —
|
|
935
|
+
// by the decay block below (only under CLAUDE_MEM_SUBAGENT_DECAY) and by its own
|
|
936
|
+
// metering call at the tail. It used to be collected at the tail only, with a
|
|
937
|
+
// comment saying the position was load-bearing because lib/transcript-scan.mjs
|
|
938
|
+
// memoizes ONE file and reading the sidechains evicts the parent. That constraint
|
|
939
|
+
// is real but it is not "last" — it is "not BETWEEN two parent scans". Running it
|
|
940
|
+
// FIRST parses the sidechains before anything has memoized the parent, so the
|
|
941
|
+
// parent is then parsed once and stays memoized for every scanner after it:
|
|
942
|
+
// still one parent parse per Stop, the property the tail comment was protecting.
|
|
943
|
+
let sub = { injected: new Set(), cited: new Set(), files: 0 };
|
|
944
|
+
try { sub = collectSubagentSurface(transcriptPath); }
|
|
945
|
+
catch (e) { debugCatch(e, 'handleStop-subagent-collect'); }
|
|
946
|
+
|
|
810
947
|
const ids = extractCitationsFromTranscript(transcriptPath);
|
|
811
948
|
if (ids.size > 0) {
|
|
812
949
|
const n = bumpCitationAccess(db, ids, project);
|
|
@@ -854,7 +991,15 @@ async function handleStop() {
|
|
|
854
991
|
const keyCtxIds = extractInjectedFromKeyContext({
|
|
855
992
|
runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
856
993
|
});
|
|
857
|
-
|
|
994
|
+
// D#177: `sub.injected` counts toward the entry gate when the face is admitted.
|
|
995
|
+
// Without this a session whose ONLY injection was a dispatched agent's prompt
|
|
996
|
+
// would return here with injected.size === 0 and the face would be "in the
|
|
997
|
+
// denominator" in name only — the failure mode where a face is wired at one
|
|
998
|
+
// level and gated out at another, which is how UPS went unmetered for a whole
|
|
999
|
+
// minor version.
|
|
1000
|
+
const subDecayOn = !['0', 'off', 'false', 'no'].includes(
|
|
1001
|
+
String(process.env.CLAUDE_MEM_SUBAGENT_DECAY ?? '').toLowerCase());
|
|
1002
|
+
if (injected.size > 0 || keyCtxIds.size > 0 || (subDecayOn && sub.injected.size > 0)) {
|
|
858
1003
|
// Text-floor gate: skip decay on tool-only Stops. Without this,
|
|
859
1004
|
// a turn that ends on tool_use locks every injected obs as
|
|
860
1005
|
// uncited (last_decided_session_id set), so a later turn that
|
|
@@ -867,17 +1012,71 @@ async function handleStop() {
|
|
|
867
1012
|
} else {
|
|
868
1013
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
869
1014
|
for (const id of citeBackIds) citedMain.add(id);
|
|
1015
|
+
// D#177: admit the `subagent` face to the decay loop. It cannot ride the
|
|
1016
|
+
// normal path because its injection lands in a dispatched agent's PROMPT
|
|
1017
|
+
// and its citation lands in that agent's OWN transcript — so its ids enter
|
|
1018
|
+
// the denominator AND its receiver-attributed cites enter the numerator,
|
|
1019
|
+
// asymmetrically, together. Feeding only the first half would mark every
|
|
1020
|
+
// subagent-only injection uncited by construction (that is why the face was
|
|
1021
|
+
// metered-but-excluded since v3.77); feeding only the second half would
|
|
1022
|
+
// credit the main-thread faces for citations the main thread never made.
|
|
1023
|
+
//
|
|
1024
|
+
// `sub.cited` is already the per-FILE intersection with `sub.injected`
|
|
1025
|
+
// (collectSubagentSurface), so this cannot credit an id the subagent surface
|
|
1026
|
+
// did not itself inject. Measured on the live corpus (1122 transcripts, 34
|
|
1027
|
+
// subagent-bearing sessions): 33 marginal (session,id) pairs enter the
|
|
1028
|
+
// denominator, 21.2% of them cited; 21 distinct observations behind the
|
|
1029
|
+
// uncited ones, FIVE at uncited_streak = 2. Four are 3->2 down-ranks (#8597,
|
|
1030
|
+
// #8847 with cited_count 56, #8948, #10246). At 2026-08-25 18:00Z #10716 was
|
|
1031
|
+
// at importance 2 — one miss from a 2->1 eviction out of
|
|
1032
|
+
// rankImperativeCandidates' own `importance >= 2` pool, the case
|
|
1033
|
+
// IMPERATIVE_POOL_BACKSTOP does not cover, and the reason "down-ranks, not
|
|
1034
|
+
// evictions" is wrong as a blanket claim. That row has since been promoted by
|
|
1035
|
+
// the very session that documented it (D#179: this loop cannot tell writing
|
|
1036
|
+
// `#NN` from applying it), so re-check the CLASS, not the row.
|
|
1037
|
+
// Cross-crediting is 3 pairs of 1181 DISTINCT (session,id) across the five
|
|
1038
|
+
// decay faces inside subagent-bearing sessions (0.25%), or 3 of 2738 the same
|
|
1039
|
+
// way corpus-wide (0.11%) — ids the main thread never cited but a subagent did.
|
|
1040
|
+
//
|
|
1041
|
+
// ON by default since v3.83.0; `CLAUDE_MEM_SUBAGENT_DECAY=0` restores the
|
|
1042
|
+
// metered-but-never-decaying state the face sat in from v3.77 to v3.82.
|
|
1043
|
+
//
|
|
1044
|
+
// The denominator is a COPY, not a mutation of `injected`: the edge
|
|
1045
|
+
// attribution below takes `mainInjectedIds: injected` to keep sidechain-only
|
|
1046
|
+
// injections from accruing file-edge misses (review D#78), and folding the
|
|
1047
|
+
// subagent ids into that set would undo exactly that guard.
|
|
870
1048
|
// The promotion-only half: a Key Context row the agent actually
|
|
871
1049
|
// cited joins the decay set (and takes the promote branch); one
|
|
872
1050
|
// it ignored is never entered, so it cannot streak or demote.
|
|
873
1051
|
for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
|
|
1052
|
+
// BOTH halves of the merge are COPIES, built AFTER the keyctx promotion above
|
|
1053
|
+
// so they carry it too. When the flag is off each IS the original object, so
|
|
1054
|
+
// every consumer below is byte identical to the pre-D#177 path.
|
|
1055
|
+
//
|
|
1056
|
+
// The copies are the whole safety property. `injected` and `citedMain` have
|
|
1057
|
+
// four consumers between them and only `applyCitationDecay` should see the
|
|
1058
|
+
// subagent ids; the first draft of this change mutated `citedMain` in place
|
|
1059
|
+
// and the pre-tag review measured both leaks it caused:
|
|
1060
|
+
// • recordCitationSurfaces (below) scored a `pretool` row the main thread
|
|
1061
|
+
// never cited as a pretool HIT — `pretool.cited_n` 0 -> 1 on a
|
|
1062
|
+
// two-observation probe. That is the caliber CLAUDE.md publishes for the
|
|
1063
|
+
// funnel ("cited as #NN in the session's own MAIN-THREAD text"), so it
|
|
1064
|
+
// would have made citation_surface_log and citation-live-replay.mjs
|
|
1065
|
+
// permanently different rulers — the v3.81.0 cross-agent defect, mirrored.
|
|
1066
|
+
// • resolveEdgeAttribution gates sidechain edges on
|
|
1067
|
+
// `!mainInjected.has(id) && !cited.has(id)`, so a file edge flipped MISS
|
|
1068
|
+
// -> HIT (`miss_streak` 1 -> 0). The comment there defends the DENOMINATOR
|
|
1069
|
+
// half of that gate and says nothing about the numerator, which is exactly
|
|
1070
|
+
// how the leak got past a reading of it.
|
|
1071
|
+
const decayInjected = subDecayOn ? new Set([...injected, ...sub.injected]) : injected;
|
|
1072
|
+
const decayCited = subDecayOn ? new Set([...citedMain, ...sub.cited]) : citedMain;
|
|
874
1073
|
// D#60: the idempotency key must be the CC session UUID, NOT the
|
|
875
1074
|
// project-scoped memory sessionId — concurrent same-project CC
|
|
876
1075
|
// sessions share the latter, so the second session's decay pass
|
|
877
1076
|
// read "already decided" and silently undercounted decay_seen /
|
|
878
1077
|
// streaks / adoption denominators. Fallback keeps legacy
|
|
879
1078
|
// stdin-less invocations on the old key.
|
|
880
|
-
const r = applyCitationDecay(db, project,
|
|
1079
|
+
const r = applyCitationDecay(db, project, decayInjected, decayCited, ccSessionId || sessionId);
|
|
881
1080
|
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
882
1081
|
// R1: persist this session's invocation→cite funnel row. touched =
|
|
883
1082
|
// obs resolved this run (denominator), promoted = obs cited this run
|
|
@@ -968,11 +1167,19 @@ async function handleStop() {
|
|
|
968
1167
|
// construction; folding its cites INTO citedMain would credit the
|
|
969
1168
|
// main-thread faces for citations the main thread never made. The
|
|
970
1169
|
// upsert key is (project, session, surface), so two calls with
|
|
971
|
-
// disjoint face sets do not collide.
|
|
972
|
-
//
|
|
1170
|
+
// disjoint face sets do not collide.
|
|
1171
|
+
//
|
|
1172
|
+
// SINCE v3.83.0 (D#177) this is no longer metering-only: the face DOES reach
|
|
1173
|
+
// applyCitationDecay, through the `decayInjected` / `decayCited` copies above.
|
|
1174
|
+
// The sentence above about folding cites into `citedMain` still holds and is the
|
|
1175
|
+
// reason those are copies — this call, `resolveEdgeAttribution` and the keyctx
|
|
1176
|
+
// promotion all keep the un-widened set. `CLAUDE_MEM_SUBAGENT_DECAY=0` returns
|
|
1177
|
+
// the face to metering-only.
|
|
973
1178
|
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
1179
|
+
// The "placed LAST" note below is now historical: `collectSubagentSurface` runs
|
|
1180
|
+
// at the HEAD of this block (the decay loop needs its result), and `sub` here is
|
|
1181
|
+
// that same object rather than a second call. The parse-count property the note
|
|
1182
|
+
// defends is unchanged — see the comment at the collection site.
|
|
976
1183
|
// earlier, this block costs ONE extra parse of the parent — the memo
|
|
977
1184
|
// re-caches on the first re-read, so it is one, not one per later
|
|
978
1185
|
// scanner — and breaks the "one parse per Stop" property the block
|
|
@@ -985,7 +1192,9 @@ async function handleStop() {
|
|
|
985
1192
|
// must not enter the funnel's session denominator either.
|
|
986
1193
|
try {
|
|
987
1194
|
if (hasMainThreadAssistantText(transcriptPath)) {
|
|
988
|
-
|
|
1195
|
+
// `sub` is the one collected at the top of this block — a second
|
|
1196
|
+
// collectSubagentSurface call here would re-parse every sidechain file and,
|
|
1197
|
+
// worse, could disagree with the set the decay loop above just scored.
|
|
989
1198
|
if (sub.injected.size > 0) {
|
|
990
1199
|
recordCitationSurfaces(db, project, ccSessionId || sessionId,
|
|
991
1200
|
{ subagent: sub.injected }, sub.cited);
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -634,6 +634,10 @@ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DEC
|
|
|
634
634
|
* commensurable with `citedMain` — scoring it there would mark every
|
|
635
635
|
* subagent-only injection uncited by construction. Metered first (this is the
|
|
636
636
|
* whole point of D#152: the face's cite-rate is unknown), decided second.
|
|
637
|
+
* DECIDED 2026-08-25 (D#177, below): it IS in the decay loop now. It stays listed
|
|
638
|
+
* here because this constant answers "which faces leave no attachment", a fact
|
|
639
|
+
* about the transport — and that is exactly why its admission had to be wired at
|
|
640
|
+
* the call site instead of riding the DECAY_DENOMINATOR_SURFACES derivation.
|
|
637
641
|
*
|
|
638
642
|
* D#164 read it (2026-08-25, 30 live sessions): 25.0% (12/48) on the house
|
|
639
643
|
* id-level caliber — above fyi (10.9%) and error_recall (6.2%), both of which
|
|
@@ -659,6 +663,46 @@ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DEC
|
|
|
659
663
|
* one release separate them also means the eviction loop they share is observed
|
|
660
664
|
* on one face before it acts on two. Tracked in D#172.
|
|
661
665
|
*
|
|
666
|
+
* D#177 — ADMITTED (2026-08-25, v3.83.0). The asymmetric merge lives in hook.mjs
|
|
667
|
+
* handleStop, on by default with `CLAUDE_MEM_SUBAGENT_DECAY=0` as the off switch:
|
|
668
|
+
* `sub.injected` widens the denominator and `sub.cited` — already the per-file
|
|
669
|
+
* intersection with what the subagent surface itself injected — widens the numerator,
|
|
670
|
+
* in the same breath. This constant stays as it is either way: it describes which
|
|
671
|
+
* faces leave no ATTACHMENT, which is a fact about the transport, not about the
|
|
672
|
+
* denominator. Admission happens at the call site precisely because it cannot ride
|
|
673
|
+
* the `DECAY_DENOMINATOR_SURFACES` derivation without the cites travelling with it.
|
|
674
|
+
*
|
|
675
|
+
* Re-measured on the 1122-transcript corpus (benchmark/citation-live-replay.mjs):
|
|
676
|
+
* the face reads 25.5% (14/55). Cost of the merge, same walk: 33 marginal (session,id)
|
|
677
|
+
* pairs join the denominator at 21.2% cited; behind the uncited ones sit 21 distinct
|
|
678
|
+
* observations, FIVE at uncited_streak = 2, each one demotion away from moving.
|
|
679
|
+
*
|
|
680
|
+
* FOUR of the five are 3->2 down-ranks (#8597, #8847 with cited_count 56, #8948,
|
|
681
|
+
* #10246). THE FIFTH WAS AN EVICTION and the first draft of this note said there were
|
|
682
|
+
* none: at 2026-08-25 18:00Z, #10716 sat at importance = 2, so its next miss would take
|
|
683
|
+
* it to IMPORTANCE_FLOOR = 1 — under the `COALESCE(importance, 1) >= 2` gate in
|
|
684
|
+
* rankImperativeCandidates, which is the candidate pool of the very face being
|
|
685
|
+
* admitted. IMPERATIVE_POOL_BACKSTOP closed the 3->2 eviction in v3.82.0 and 2->1 was
|
|
686
|
+
* always documented as still evicting; writing "down-ranks, not evictions" required
|
|
687
|
+
* assuming the marginal population was all importance = 3, which one query refutes.
|
|
688
|
+
* Count it before repeating it.
|
|
689
|
+
*
|
|
690
|
+
* THAT ROW IS NO LONGER IN THAT STATE, AND THE REASON IS THIS LOOP (D#179). #10716 now
|
|
691
|
+
* reads importance 3 / uncited_streak 0 / demoted_at cleared, promoted by the session
|
|
692
|
+
* that WROTE THIS PARAGRAPH: `#10716` occurs 21 times in that session's assistant text,
|
|
693
|
+
* extractCitationsFromTranscript scans assistant text for `#NN`, and applyCitationDecay
|
|
694
|
+
* promotes on a hit. Discussing a memory is indistinguishable from applying it here.
|
|
695
|
+
* Anything in this file that quotes live decay STATE is therefore perturbed by being
|
|
696
|
+
* written down; quote it with a timestamp, and prefer the structural claim (the marginal
|
|
697
|
+
* population is not all importance = 3) to the row that demonstrated it.
|
|
698
|
+
*
|
|
699
|
+
* Cross-crediting — a main-face id the main thread never cited but a subagent did — is
|
|
700
|
+
* 3 pairs. The denominator giving 0.25% is DISTINCT (session,id) across the five decay
|
|
701
|
+
* faces inside subagent-bearing sessions (1181); 0.11% is that same caliber corpus-wide
|
|
702
|
+
* (2738). The per-face sums for the same populations are 1241 and 2878 — say which one
|
|
703
|
+
* you mean, because quoting one ratio against the other denominator is the exact error
|
|
704
|
+
* this sentence exists to prevent.
|
|
705
|
+
*
|
|
662
706
|
* A member here that the Stop path stops feeding becomes an all-zero face, not
|
|
663
707
|
* a silently-demoting one — which is the failure mode worth keeping.
|
|
664
708
|
* @type {ReadonlyArray<string>}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.83.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.83.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.83.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",
|
|
@@ -105,6 +105,45 @@ const EDGE_DECAY_K = Math.max(1, Number.isNaN(EDGE_DECAY_K_RAW) ? 3 : EDGE_DECAY
|
|
|
105
105
|
// environment-scoped observations (tooling/CI/network gotchas that apply in
|
|
106
106
|
// ANY project) stop firing on FILE-triggered recall; they stay reachable via
|
|
107
107
|
// search / UPS / error-recall. NULL scope (legacy / manual rows) always passes.
|
|
108
|
+
//
|
|
109
|
+
// DO NOT TURN THIS ON, AND DO NOT REBUILD IT AS A DOWN-WEIGHT (D#153, closed
|
|
110
|
+
// 2026-08-25). Its premise is that environment-scoped rows are the low-relevance
|
|
111
|
+
// class on THIS face. Measured on the live corpus with the shipped extractors —
|
|
112
|
+
// `node benchmark/citation-live-replay.mjs --by-scope`, 1122 transcripts — the
|
|
113
|
+
// `pretool` face (this one) cites:
|
|
114
|
+
// environment 47.5% (67/141) CI [39.5, 55.7]
|
|
115
|
+
// project 44.3% (293/661) CI [40.6, 48.1]
|
|
116
|
+
// file 40.0% (2/5) · (gone) 37.1% (185/498) · module 36.5% (31/85)
|
|
117
|
+
// (null) 34.9% (45/129) · face overall 41.0% (623/1519)
|
|
118
|
+
// environment is the best-citing scope bucket on the face the filter gates. The
|
|
119
|
+
// CIs for environment and project OVERLAP, so "environment leads" is NOT
|
|
120
|
+
// established; what is refuted is "environment is the class to suppress".
|
|
121
|
+
// Its interval sits above module and null, which is the comparison that matters
|
|
122
|
+
// for a lever whose whole premise is that this bucket is the weak one.
|
|
123
|
+
//
|
|
124
|
+
// The earlier redesign sketch (multiplicative demotion at the TYPE_QUALITY layer
|
|
125
|
+
// instead of a WHERE-clause exclusion) fixes the failure mode that was measured
|
|
126
|
+
// in 2026-08 — 173 recall groups going empty — but it would still down-rank this
|
|
127
|
+
// bucket, so it was NOT built. Kept as an off switch rather than deleted: the
|
|
128
|
+
// column and the label are used elsewhere, and a flag nobody flips costs nothing
|
|
129
|
+
// as long as the reason not to flip it is written down, which is this paragraph.
|
|
130
|
+
//
|
|
131
|
+
// NOT corroborated by #10720, though an earlier draft of this note said so. That
|
|
132
|
+
// observation is a scope LABEL distribution (364 labelled DB rows: project 184 /
|
|
133
|
+
// environment 114 / module 50 / file 16), which establishes the filter's blast
|
|
134
|
+
// radius — environment is 31.3% of labels, not the near-no-op it looked like at 8
|
|
135
|
+
// rows — and says nothing about relevance. The refutation rests on the by-scope
|
|
136
|
+
// replay alone.
|
|
137
|
+
//
|
|
138
|
+
// Caliber caveat, so the numbers are re-derivable rather than quotable: the six
|
|
139
|
+
// buckets sum to exactly 1519, the face's own pair count, because 498 ids whose
|
|
140
|
+
// observation has since left the table are reported as `(gone)` rather than
|
|
141
|
+
// dropped. Those are an OLD cohort (max id 8880 against a corpus reaching 10850)
|
|
142
|
+
// from id bands where the surviving population is ~81% `(null)` and ~2.5%
|
|
143
|
+
// environment, so the missing bucket cannot plausibly be environment-heavy;
|
|
144
|
+
// environment and project pairs sit in the same bands, so the head-to-head is not
|
|
145
|
+
// era-confounded. The `(null)` bucket and the face-overall figure ARE, being
|
|
146
|
+
// dominated by legacy rows.
|
|
108
147
|
const SCOPE_FILTER_ON = ['1', 'on', 'true', 'yes'].includes(
|
|
109
148
|
String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase());
|
|
110
149
|
const FILE_INTEL_MIN_TOKENS = Math.max(1,
|