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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.82.0",
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.82.0",
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. It collapsed in
318
- * 2026-05 and the cause is NOT identified. That regime break is the single most useful fact
319
- * here, because it is direct evidence for the conclusion rather than against it: the
320
- * reachable input is the episode BOUNDARY, not the threshold and not the field, and the
321
- * boundary demonstrably moved once already.
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
- * Reopening means finding what changed in 2026-05 a far larger change than the rule, with
326
- * no evidence its output was worth it (111 lifetime observations, dormant 133 days, nobody
327
- * noticed). The May break is tracked separately so it is not lost with the closure.
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
- function flushEpisodeWithDb(db, episode, hookEventName) {
220
- // Collect Read file paths tracked by post-tool-use.sh
221
- // Use rename to atomically collect — prevents losing concurrent appends
222
- const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
223
- const readsCollect = readsFile + `.collect-${Date.now()}`;
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
- renameSync(readsFile, readsCollect);
226
- const raw = readFileSync(readsCollect, 'utf8');
227
- const paths = [...new Set(raw.split('\n').filter(Boolean))];
228
- episode.filesRead = paths;
229
- try { unlinkSync(readsCollect); } catch {}
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
- episode.filesRead = episode.filesRead || [];
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) return;
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
- if (injected.size > 0 || keyCtxIds.size > 0) {
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, injected, citedMain, ccSessionId || sessionId);
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. Metering only — `subagent` is in
972
- // NON_ATTACHMENT_SURFACES and never reaches applyCitationDecay.
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
- // Placed LAST on purpose: lib/transcript-scan.mjs memoizes ONE file,
975
- // so reading the sidechain files evicts the parent transcript. Run
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
- const sub = collectSubagentSurface(transcriptPath);
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);
@@ -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>}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.82.0",
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.82.0",
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.82.0",
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,