claude-mem-lite 3.79.0 → 3.81.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.79.0",
13
+ "version": "3.81.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.79.0",
3
+ "version": "3.81.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-episode.mjs CHANGED
@@ -260,6 +260,72 @@ const RESEARCH_ENTRY_THRESHOLD = 8;
260
260
  * short — an edit-driven episode that happens to contain Greps is not evidence that
261
261
  * Grep carries research episodes.
262
262
  *
263
+ * READ THE DENOMINATOR BEFORE READING `grepDecisive: 0`. It is a vacuous zero whenever
264
+ * no episode contains a Grep at all, and that is the state measured on 2026-08-25:
265
+ * across 555 instrumented episodes, `sum(readCount) = sum(grepCount) = 0`, and the `Grep`
266
+ * tool was invoked **0 times in the entire 1111-transcript history on this machine**.
267
+ * So "grepDecisive is 0, therefore Grep is safe to skip" is not an inference this counter
268
+ * supports here — it never had the chance to fire. (The decision it was built for is
269
+ * separately moot on that corpus: skipping a tool nobody calls saves 0ms.) Same shape as
270
+ * the FTS5 `rowid = ? AND fts MATCH ?` trap this repo already paid for — a predicate that
271
+ * cannot return true reports the defect as absent.
272
+ *
273
+ * The same measurement shows rules 4 and `buildImmediateObservation`'s `isReviewPattern`
274
+ * are BOTH dormant, for a reason that has nothing to do with Grep: `readCount` counts
275
+ * `Read || Grep`, and `Read` is filtered out at both layers (scripts/post-tool-use.sh
276
+ * records it to `reads-<project>.txt` and exits; SKIP_TOOLS returns early in Node), so the
277
+ * rule's only remaining input is a tool that is never called. Last observation the review
278
+ * branch produced: 133 days ago, 111 lifetime.
279
+ *
280
+ * D#171 proposed the obvious repair — re-point rule 4 at `episode.filesRead`, populated at
281
+ * hook.mjs:228 before this function runs, the same way rule 3 already reads
282
+ * `episode.files`. MEASURED 2026-08-25: IT DOES NOT WORK TODAY, and the reason is dated,
283
+ * not structural. Both halves matter, and the pre-tag review corrected the first draft of
284
+ * each.
285
+ *
286
+ * Unlike the Grep case the denominator is real: `Read` fires 1861 times across the
287
+ * 1114-transcript history (9.1% of all tool calls; the transcripts hold records spanning
288
+ * 2026-08-13..08-25, so that is a live rate, not a lifetime counter). But `filesRead` is a
289
+ * per-FLUSH slice, not a per-episode total — flushEpisodeWithDb renames and consumes
290
+ * `reads-<project>.txt` on every flush. COMPARE THE TWO RATES ON THE SAME WINDOW: the
291
+ * `episode_significance` metric rows cover three ACTIVE days (08-22 / 08-24 / 08-25; 08-23
292
+ * has no file), 607 flushes; the transcripts touched in that window carry 596 Reads. That
293
+ * is 0.98 Reads per episode. The first draft said 0.8 by dividing a 12-day Read rate by a
294
+ * 3-day flush rate — two windows, one ratio, which is the same shape of error v3.80.0
295
+ * recorded as "reading a lifetime counter as an active rate".
296
+ *
297
+ * At ~1 Read per episode a threshold of 8 is out of reach, and the 90-day sample agrees:
298
+ * `files_read` is non-empty on 34 of 1872 rows (1.8%), p50 1 / p95 5 / max 8, exactly ONE
299
+ * row reaching 8 — and that column is a SUPERSET of `episode.filesRead` (hook-llm.mjs
300
+ * merges searched files in), so the true field is smaller. Two caveats on that sample, both
301
+ * from the review: it only covers SIGNIFICANT episodes (saveEpisodeImmediate is gated on
302
+ * `isSignificant`), which is ~8% of flushes and structurally excludes the population rule 4
303
+ * would change; and 90 days starts AFTER the break below.
304
+ *
305
+ * THE RULE WAS NOT ALWAYS UNREACHABLE — do not write "structurally". Non-empty `files_read`
306
+ * by month, with the count reaching the threshold of 8:
307
+ *
308
+ * 2026-02 35/ 78 44.9% >=8: 3 max 11
309
+ * 2026-03 603/1004 60.1% >=8: 49 max 53
310
+ * 2026-04 314/ 621 50.6% >=8: 28 max 33
311
+ * 2026-05 8/ 129 6.2% >=8: 0
312
+ * 2026-06 5/ 754 0.7% >=8: 0
313
+ * 2026-07 8/ 919 0.9% >=8: 1
314
+ * 2026-08 20/ 193 10.4% >=8: 0
315
+ * lifetime reaching >=8: 81
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.
322
+ *
323
+ * D#171 closed as won't-fix-as-specified: the repair it named does not work at the current
324
+ * 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.
328
+ *
263
329
  * @param {object} episode
264
330
  * @returns {{significant: boolean, rule: 1|2|3|4|null, readCount: number,
265
331
  * grepCount: number, grepDecisive: boolean}}
@@ -20,9 +20,65 @@ import { readTranscriptEntries } from './transcript-scan.mjs';
20
20
  import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
21
21
 
22
22
  import { DAY_MS } from './time-constants.mjs';
23
+ /**
24
+ * The ONE caliber for an observation id appearing in text. Bounded to 1-7 digits to
25
+ * skip URL fragments, markdown anchors, etc.
26
+ *
27
+ * Exported because the offline benchmarks re-derive production's numbers from the same
28
+ * transcripts, and each had hand-copied its own: `benchmark/cite-recall.mjs` scanned
29
+ * citations with `{2,6}` while its OWN injected denominator used `{1,7}`, and
30
+ * `efficacy-observational.mjs` / `adoption-replay.mjs` had a third and fourth caliber
31
+ * (`{2,6}` / `{2,7}`). A denominator wider than its numerator counts an id as
32
+ * injected-never-cited that the numerator structurally cannot see, which biases the
33
+ * measured cite-rate DOWN — and nothing errors when it happens.
34
+ *
35
+ * Measured live impact at the time this was unified (2026-08-24, 3692 rows, ids 1..10834):
36
+ * exactly ZERO. The only ids outside `{2,6}` are four 1-digit rows, and all four have
37
+ * injection_count = 0, so they never entered a denominator; there are no 7-digit ids.
38
+ * This is a latent-class fix, not a correction to any published number — do not
39
+ * re-attribute past readings to it.
40
+ */
41
+ export const OBS_ID_DIGITS = '\\d{1,7}';
42
+
43
+ /**
44
+ * A fresh global matcher for a bare `#NN` citation.
45
+ *
46
+ * Returned fresh per call rather than shared: a `/g` regex carries `lastIndex`, so one
47
+ * exported instance reused by two scanners silently starts mid-string in whichever one
48
+ * runs second.
49
+ */
50
+ export function citationIdRe() {
51
+ return new RegExp(`#(${OBS_ID_DIGITS})\\b`, 'g');
52
+ }
53
+
54
+ /**
55
+ * Caliber for an id scraped from UNANCHORED text that is then treated as an INJECTED
56
+ * (denominator) set.
57
+ *
58
+ * `citationIdRe()` above is a NUMERATOR caliber. On the numerator side a spurious `#1`
59
+ * costs nothing, because a cited id only counts once it intersects an injected set that
60
+ * WAS anchored — every injected-side extractor in this module matches a row shape
61
+ * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`). On the
62
+ * denominator side nothing anchors it, so a prose `#1` is a false positive by
63
+ * construction — it inflates "injected, never cited" and biases the measured rate DOWN.
64
+ *
65
+ * The v3.80.0 pre-tag review caught this concretely: pointing
66
+ * `benchmark/adoption-replay.mjs` at `citationIdRe()` pulled `#1` and `#2` out of a
67
+ * subagent prompt discussing fixture rows ("with `#1` superseded by `#2` …") straight into
68
+ * `injectedIds`, on a real transcript. Excluding 1-digit ids costs nothing measurable —
69
+ * the four 1-digit rows in the live corpus have `injection_count = 0` — and removes the
70
+ * commonest prose collision.
71
+ *
72
+ * This is a stopgap for a caliber symptom, NOT a fix for the cause. The cause is that
73
+ * adoption-replay scrapes a whole prompt where it should match injected ROWS, the way
74
+ * production does. Do not reach for this anywhere else; anchor instead.
75
+ */
76
+ export function unanchoredInjectedIdRe() {
77
+ return new RegExp('#(\\d{2,7})\\b', 'g');
78
+ }
79
+
23
80
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
24
- // Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
25
- const CITATION_RE = /#(\d{1,7})\b/g;
81
+ const CITATION_RE = citationIdRe();
26
82
 
27
83
  /**
28
84
  * Parse a Claude Code transcript .jsonl and extract unique observation IDs
@@ -147,7 +203,7 @@ export function bumpCitationAccess(db, ids, project) {
147
203
  // Matches a pre-tool-recall / error-recall lesson line: ` #NN [type] body...`.
148
204
  // Bounded type list mirrors observations.type CHECK + the events table's allowed
149
205
  // event_type values these surfaces can emit.
150
- const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
206
+ const INJECTED_RE = new RegExp(`#(${OBS_ID_DIGITS})\\s+\\[(bugfix|decision|change|discovery|feature|refactor|lesson)\\]`, 'g');
151
207
  // Line-anchored variant: a genuine injected ROW begins (after its short indent) with
152
208
  // `#NN [type]`. pre-tool-recall AND error-recall inline a lesson_learned body into the
153
209
  // row; a body that quotes another obs ("same as #1234 [decision]") must NOT count as
@@ -217,7 +273,7 @@ function eachHookAttachment(transcriptPath, fn, opts = {}) {
217
273
  // like "see (#999)" doesn't pollute the injected set (would streak-uncite an
218
274
  // obs we never actually displayed as a top-level entry).
219
275
  const UPS_LINE_PREFIX = '- [';
220
- const UPS_ID_RE = /\(#(\d{1,7})\)/g;
276
+ const UPS_ID_RE = new RegExp(`\\(#(${OBS_ID_DIGITS})\\)`, 'g');
221
277
  // Quote-normalized (see normalizeHookCommand): real recorded command is
222
278
  // `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
223
279
  const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
@@ -230,7 +286,7 @@ const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
230
286
  const FYI_HEADER = '[mem] FYI — Related memories';
231
287
  // Anchored at line start so `P#NN` past-question rows (user_prompts, different id
232
288
  // space) and any `#NN` inside lesson text are NOT matched.
233
- const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
289
+ const FYI_LINE_ID_RE = new RegExp(`^#(${OBS_ID_DIGITS})\\s`);
234
290
 
235
291
  /**
236
292
  * The injection FACES memory can reach the model through, as stored in
@@ -491,15 +547,60 @@ export function extractAllInjected(transcriptPath, opts = {}) {
491
547
  * requires each face to be either in the union or listed here — so a face added later
492
548
  * cannot slip out of the denominator by being forgotten, only by being argued for.
493
549
  *
494
- * - `task_imperative` (v3.76): metering it is the point of adding it — D#137/D#150/D#151
495
- * are all blocked on not knowing this face's cite-rate. Widening the denominator at the
496
- * same moment would change what gets demoted in every live install on upgrade, and it
497
- * would do so on the face whose framing is itself the open question: if the imperative
498
- * framing under-performs, the penalty lands on the LESSONS it carried (imperativePick
499
- * selects high-value ones) rather than on the framing. Read the rate first, then decide.
500
- * Removing an entry from this set is the one-line change that widens the denominator.
550
+ * EMPTY since 2026-08-25, by decision rather than by default. `task_imperative` was its
551
+ * only member; the history is kept because the exit criterion is the reusable part.
552
+ *
553
+ * - `task_imperative` (v3.76 -> admitted 2026-08-25): it was excluded on a stated
554
+ * condition — "if the imperative framing under-performs, the penalty lands on the
555
+ * LESSONS it carried (imperativePick selects high-value ones) rather than on the
556
+ * framing" with the instruction to read the rate first. THE RATE WAS READ AND THE
557
+ * CONDITION IS NOT MET. Over the live 1113-transcript corpus: 44.1% (15/34), against
558
+ * pretool 38.3% (521/1362), fyi 10.9%, ups 8.4%, error_recall 6.2%.
559
+ *
560
+ * citation_surface_log alone would NOT have supported this — it held n=8 over 2.1 days,
561
+ * because the FLAG had been parked for weeks while the METER had only run since v3.76.
562
+ * Re-deriving the rate by walking live transcripts with the shipped extractors turned
563
+ * n=8 into n=34. Worth keeping as a habit: when a face's row count looks too small to
564
+ * decide on, check whether the meter is younger than the behaviour before concluding
565
+ * there is no data.
566
+ *
567
+ * Two caveats travel with the number. n=34, so its CI overlaps pretool's: "does not
568
+ * under-perform" is established, "leads" is not. And imperativePick returns a single
569
+ * top-ranked lesson, so this is a top-1 pick measured against faces that inject bulk
570
+ * lists — a confound in this face's favour that no amount of n removes.
571
+ *
572
+ * Measured blast radius, same-corpus one-pass A/B (4-face union vs 5-face union in the
573
+ * SAME walk — never by subtracting two counts taken at different times): 22 new
574
+ * (session,id) rows = +0.90% of the denominator across 17 sessions, 9 of the 22 cited
575
+ * (40.9%).
576
+ *
577
+ * COUNT THE STREAK ON THE RIGHT UNIT. The first version of this note said "zero of them
578
+ * uncited across the >=3 sessions UNCITED_STREAK_THRESHOLD requires, so no demotions" —
579
+ * measured on marginal sessions only, which is NOT the unit applyCitationDecay uses.
580
+ * `uncited_streak` is per-OBSERVATION and is driven by every face at once, so the real
581
+ * question is whether a marginal uncited resolution lands on a row the other four faces
582
+ * have already walked to 2. It does: 13 of the 22 marginal pairs are uncited, and of the
583
+ * 16 distinct observations behind them FIVE sit at uncited_streak = 2 today — four of
584
+ * those (#8847, #8948, #10251, #10527) are ids this flip newly resolves as uncited, i.e.
585
+ * one imperative-only silent session from demotion. #8847 is imp=3 with cited_count=56.
586
+ * The product's own CLI calls that state "Active decay queue (uncited_streak >= 2, next
587
+ * miss -> demote)"; a release note claiming three sessions of margin contradicted it.
588
+ *
589
+ * THE RESIDUAL RISK, unresolved and deliberately shipped: all 22 marginal rows are
590
+ * importance=3, because rankImperativeCandidates orders by importance DESC and takes 50,
591
+ * and in the five largest projects here the imp=3 population alone exceeds that limit
592
+ * (projects--mem 326, code-graph-mcp 121). So a demotion 3->2 EVICTS a lesson from this
593
+ * face's candidate pool rather than merely down-ranking it — a feedback loop the four
594
+ * original denominator faces do not have, because they select on FTS relevance. It is
595
+ * NOT permanent: 3->2 still clears the pool's `>= 2` gate (it only loses the LIMIT 50
596
+ * race), and updatePromote restores importance on the next citation from ANY face, so a
597
+ * row returns the moment something else surfaces and cites it. If lessons start
598
+ * disappearing from imperative picks, this is the first place to look, and the cap
599
+ * belongs in rankImperativeCandidates (raise LIMIT above the imp=3 population, or exempt
600
+ * this face's picks from demotion) rather than back here. Tracked with `subagent` in
601
+ * D#172.
501
602
  */
502
- const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
603
+ const DECAY_EXCLUDED_SURFACES = new Set();
503
604
 
504
605
  /** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
505
606
  export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
@@ -522,6 +623,28 @@ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DEC
522
623
  * subagent-only injection uncited by construction. Metered first (this is the
523
624
  * whole point of D#152: the face's cite-rate is unknown), decided second.
524
625
  *
626
+ * D#164 read it (2026-08-25, 30 live sessions): 25.0% (12/48) on the house
627
+ * id-level caliber — above fyi (10.9%) and error_recall (6.2%), both of which
628
+ * ARE in the denominator, so "it performs badly" is not available as a reason
629
+ * to keep it out. The two costs that ARE measured: (1) admitting it means
630
+ * feeding its receiver-attributed cites alongside `citedMain`, which would flip
631
+ * 3 main-face (session,id) pairs from demote to promote on a cite the main thread
632
+ * never made — 3 of the 1064 such pairs inside the 30 subagent-bearing sessions
633
+ * sampled (0.28%), or 3 of 1935 (0.16%) if you widen to every subagent-bearing
634
+ * session in the corpus; state which denominator you mean, the phrase "main-face
635
+ * ids" alone does not pin it; (2) the 33 rows it would newly add cite at 15.2% and are
636
+ * 94% importance=3 — same LIMIT-50 eviction loop described under
637
+ * `task_imperative` above, since both faces share selectImperativeLesson. Three
638
+ * of those 33 would actually demote over that corpus.
639
+ *
640
+ * Its sibling was admitted on 2026-08-25; this one deliberately was NOT, and the
641
+ * difference is not the rate. task_imperative needed one line and no change to
642
+ * `citedMain`; this face needs the receiver-attributed cites merged in
643
+ * asymmetrically, and its numerator only became trustworthy on 2026-08-25 (see
644
+ * collectSubagentSurface — it credited cross-agent citations until then). Letting
645
+ * one release separate them also means the eviction loop they share is observed
646
+ * on one face before it acts on two. Tracked in D#172.
647
+ *
525
648
  * A member here that the Stop path stops feeding becomes an all-zero face, not
526
649
  * a silently-demoting one — which is the failure mode worth keeping.
527
650
  * @type {ReadonlyArray<string>}
@@ -569,7 +692,7 @@ export function unionSurfaces(bySurface) {
569
692
  const SUBAGENT_INJECT_MARKER = /surfaced by your operator's claude-mem-lite/;
570
693
  // Row-anchored to the `#NN — ` tag so a #NN quoted inside the lesson body does NOT enter
571
694
  // the injected set — same discipline as INJECTED_ROW_RE for the attachment surfaces.
572
- const SUBAGENT_INJECT_ID_RE = /^\s{0,4}#(\d{1,7})\s+—/;
695
+ const SUBAGENT_INJECT_ID_RE = new RegExp(`^\\s{0,4}#(${OBS_ID_DIGITS})\\s+—`);
573
696
 
574
697
  /**
575
698
  * Extract observation ids injected into a subagent's PROMPT by pre-agent-inject.js
@@ -628,20 +751,32 @@ export function findSubagentTranscripts(transcriptPath) {
628
751
  * also why the Stop path records this face in its own recordCitationSurfaces
629
752
  * call: one call carries one `cited` set for every face in it.
630
753
  *
631
- * UNIT — read this before reading the rate it produces. Both sets are unioned
632
- * across ALL of the session's sidechain files, so the resulting rate is
633
- * "fraction of injected ids that appear anywhere in any sidechain of this
634
- * session", NOT per-dispatch adoption. An id handed to agent A and cited by
635
- * agent B counts as a hit, and an id handed to three agents and cited by one
636
- * counts as one full hit rather than a third. Observed in real data (review of
637
- * v3.77.0): 1 of this project's 7 historical hits is exactly that shape. The
638
- * number is therefore biased HIGH against per-dispatch adoption which matters
639
- * because D#152's instruction is to read this rate and then decide whether the
640
- * face enters the decay denominator (D#164). Per-dispatch attribution would
641
- * need per-file accounting, deliberately not built yet.
754
+ * UNIT — read this before reading the rate it produces (D#164 settled the first
755
+ * half of it; the second half is still a live caveat).
756
+ *
757
+ * `injected` is unioned by id across the session's sidechain files, which is the
758
+ * house caliber: every other face also counts an obs once per session no matter
759
+ * how many times it was injected. `cited` is NOT unioned an id is credited only
760
+ * when the agent that RECEIVED it is the agent that cited it. This face is the
761
+ * only one where injection and citation can land in different contexts, so the
762
+ * union form silently counted "agent A was handed it, agent B mentioned it" as
763
+ * adoption. Measured over 30 live sessions before the attribution was added:
764
+ * 13/48 unioned vs 12/48 receiver-attributed.
765
+ *
766
+ * What is still biased HIGH: an id handed to three agents and cited by one counts
767
+ * as one full hit rather than a third, because the id-level denominator collapses
768
+ * the three dispatches into one. On the same corpus that is 48 ids over 82
769
+ * (dispatch, id) PAIRS — not 82 files: the sessions hold ~268 sidechain transcripts and
770
+ * most carry no injection — i.e. 25.0% id-level against 14.6% per-dispatch. Opportunity-level
771
+ * accounting would need a denominator shape citation_surface_log does not have
772
+ * (its key is (project, session, surface)), and changing that would put this face
773
+ * on a different ruler from the other six — so the id-level number is the one
774
+ * that is comparable to pretool/ups/fyi, and the per-dispatch number is the one
775
+ * to quote when asking "does a dispatched agent use what it was handed".
642
776
  *
643
777
  * @param {string|null|undefined} transcriptPath main-thread transcript (.jsonl)
644
778
  * @returns {{injected: Set<number>, cited: Set<number>, files: number}}
779
+ * `cited` is always a subset of `injected`.
645
780
  */
646
781
  export function collectSubagentSurface(transcriptPath) {
647
782
  const injected = new Set();
@@ -649,8 +784,13 @@ export function collectSubagentSurface(transcriptPath) {
649
784
  let files = 0;
650
785
  for (const p of findSubagentTranscripts(transcriptPath)) {
651
786
  files++;
652
- for (const id of extractInjectedFromSubagentPrompt(p)) injected.add(id);
653
- for (const id of extractCitationsFromTranscript(p)) cited.add(id);
787
+ // Per-file intersection, not two unions: the pairing is the whole point.
788
+ const seen = extractInjectedFromSubagentPrompt(p);
789
+ const said = extractCitationsFromTranscript(p);
790
+ for (const id of seen) {
791
+ injected.add(id);
792
+ if (said.has(id)) cited.add(id);
793
+ }
654
794
  }
655
795
  return { injected, cited, files };
656
796
  }
@@ -15,6 +15,9 @@ import { basename, join } from 'path';
15
15
  import { readFileSync } from 'fs';
16
16
  import { readTranscriptEntries } from './transcript-scan.mjs';
17
17
  import { EDIT_TOOLS } from '../utils.mjs';
18
+ // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
19
+ // is acyclic.
20
+ import { citationIdRe } from './citation-tracker.mjs';
18
21
 
19
22
  const MAX_FILES = 2;
20
23
 
@@ -269,7 +272,9 @@ export function loadCiteBackForEpisode(episode, runtimeDir) {
269
272
  // #NN. The Stop handler unions these into the cited set passed to
270
273
  // applyCitationDecay (lib/citation-tracker.mjs), so acting on a lesson promotes
271
274
  // it and lifts the project's adoption rate. Returns an empty set on missing path.
272
- const CITE_BACK_ID_RE = /#(\d{1,7})\b/g;
275
+ // The ids collected here are unioned into the SAME cited set applyCitationDecay reads,
276
+ // so this caliber must be the extractor's own — imported, not a sixth hand-copy.
277
+ const CITE_BACK_ID_RE = citationIdRe();
273
278
 
274
279
  export function extractCiteBackSignals(transcriptPath) {
275
280
  const ids = new Set();
@@ -27,6 +27,40 @@
27
27
  let parts = [];
28
28
  let queuedEvent = null;
29
29
  let systemParts = [];
30
+ let queuedInput = null;
31
+
32
+ /** Emit the noisy drop notice. stderr is safe: the host never parses it as the envelope. */
33
+ function warnDrop(deps, msg) {
34
+ const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
35
+ warn(msg);
36
+ }
37
+
38
+ /**
39
+ * Claim this process's single hookEventName, or refuse the contribution.
40
+ *
41
+ * Mixed event names cannot be merged — Claude Code throws when
42
+ * hookSpecificOutput.hookEventName does not match the event it dispatched.
43
+ * In practice one process serves one event; keep the first and drop the
44
+ * stragglers rather than emit an envelope the host rejects outright.
45
+ *
46
+ * The drop is NOISY on purpose. It is unreachable today (all call sites are
47
+ * event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
48
+ * so a future caller that omits the argument would both mis-tag its receipt and
49
+ * have it swallowed without a trace. Silently vanishing work is this repo's
50
+ * most-repeated defect class.
51
+ *
52
+ * @returns {boolean} true when the caller may proceed.
53
+ */
54
+ function claimEvent(hookEventName, what, deps) {
55
+ if (queuedEvent && queuedEvent !== hookEventName) {
56
+ warnDrop(deps, `[claude-mem-lite] hook-stdout: dropped a ${hookEventName} ${what} — this process `
57
+ + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
+ + 'This is a wiring bug: the contribution is lost.\n');
59
+ return false;
60
+ }
61
+ queuedEvent = hookEventName;
62
+ return true;
63
+ }
30
64
 
31
65
  /**
32
66
  * Queue a contribution to this process's single stdout envelope.
@@ -40,26 +74,40 @@ export function queueHookContext(hookEventName, text, deps = {}) {
40
74
  if (!hookEventName) return;
41
75
  const body = String(text ?? '').trim();
42
76
  if (!body) return;
43
- // Mixed event names cannot be merged — Claude Code throws when
44
- // hookSpecificOutput.hookEventName does not match the event it dispatched.
45
- // In practice one process serves one event; keep the first and drop the
46
- // stragglers rather than emit an envelope the host rejects outright.
47
- //
48
- // The drop is NOISY on purpose. It is unreachable today (all call sites are
49
- // event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
50
- // so a future caller that omits the argument would both mis-tag its receipt and
51
- // have it swallowed without a trace. Silently vanishing work is this repo's
52
- // most-repeated defect class; stderr is safe here because the host never parses it
53
- // as the envelope.
54
- if (queuedEvent && queuedEvent !== hookEventName) {
55
- const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
56
- warn(`[claude-mem-lite] hook-stdout: dropped a ${hookEventName} contribution this process `
57
- + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
- + 'This is a wiring bug: the contribution is lost.\n');
77
+ if (!claimEvent(hookEventName, 'contribution', deps)) return;
78
+ parts.push(body);
79
+ }
80
+
81
+ /**
82
+ * Queue a `hookSpecificOutput.updatedInput` a REPLACEMENT of the tool's input,
83
+ * not a contribution to it. PreToolUse is the only event whose schema carries one
84
+ * (2.1.241 bundle: `{hookEventName: "PreToolUse", permissionDecision?,
85
+ * permissionDecisionReason?, updatedInput?, additionalContext?}`), and that same
86
+ * schema is why this belongs here rather than in its own writer: a mutation and a
87
+ * context line may ride ONE envelope, so a hook that grew both would otherwise
88
+ * emit two documents and lose both (the v3.70.0 degradation this module exists for).
89
+ *
90
+ * FIRST writer wins, and a second is dropped noisily. Unlike additionalContext
91
+ * there is no merge: two callers each hand over a whole tool_input, so last-wins
92
+ * would silently discard the earlier mutation — the same vanishing-work shape
93
+ * claimEvent guards against.
94
+ *
95
+ * @param {string} hookEventName Event name for hookSpecificOutput.
96
+ * @param {object} input Replacement tool_input; non-objects and null are ignored.
97
+ * @param {{warn?: (msg: string) => void}} [deps]
98
+ * @returns {void}
99
+ */
100
+ export function queueHookUpdatedInput(hookEventName, input, deps = {}) {
101
+ if (!hookEventName) return;
102
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return;
103
+ if (!claimEvent(hookEventName, 'updatedInput', deps)) return;
104
+ if (queuedInput) {
105
+ warnDrop(deps, '[claude-mem-lite] hook-stdout: dropped a second updatedInput — one envelope '
106
+ + 'replaces the tool input exactly once, and merging two whole inputs is not defined. '
107
+ + 'This is a wiring bug: the second mutation is lost.\n');
59
108
  return;
60
109
  }
61
- queuedEvent = hookEventName;
62
- parts.push(body);
110
+ queuedInput = input;
63
111
  }
64
112
 
65
113
  /**
@@ -93,24 +141,25 @@ export function queueHookSystemMessage(text) {
93
141
  * @returns {boolean} true when an envelope was written.
94
142
  */
95
143
  export function flushHookStdout(deps = {}) {
96
- const hasContext = queuedEvent && parts.length > 0;
144
+ const hasContext = Boolean(queuedEvent) && parts.length > 0;
145
+ const hasInput = Boolean(queuedEvent) && queuedInput !== null;
97
146
  const hasSystem = systemParts.length > 0;
98
- if (!hasContext && !hasSystem) return false;
147
+ if (!hasContext && !hasInput && !hasSystem) return false;
99
148
  const write = deps.write || ((s) => process.stdout.write(s));
100
149
  const envelope = { suppressOutput: true };
101
150
  if (hasSystem) envelope.systemMessage = systemParts.join('\n');
102
- // Omitted entirely when there is no model-facing context: Stop's schema REJECTS a
103
- // hookSpecificOutput block, and an envelope carrying only a user notice must not
104
- // invent an event name to hang one on.
105
- if (hasContext) {
106
- envelope.hookSpecificOutput = {
107
- hookEventName: queuedEvent,
108
- additionalContext: parts.join('\n\n'),
109
- };
151
+ // Omitted entirely when there is nothing addressed to the host's per-event block:
152
+ // Stop's schema REJECTS a hookSpecificOutput block, and an envelope carrying only a
153
+ // user notice must not invent an event name to hang one on.
154
+ if (hasContext || hasInput) {
155
+ envelope.hookSpecificOutput = { hookEventName: queuedEvent };
156
+ if (hasInput) envelope.hookSpecificOutput.updatedInput = queuedInput;
157
+ if (hasContext) envelope.hookSpecificOutput.additionalContext = parts.join('\n\n');
110
158
  }
111
159
  parts = [];
112
160
  queuedEvent = null;
113
161
  systemParts = [];
162
+ queuedInput = null;
114
163
  write(JSON.stringify(envelope) + '\n');
115
164
  return true;
116
165
  }
@@ -120,9 +169,15 @@ export function resetHookStdout() {
120
169
  parts = [];
121
170
  queuedEvent = null;
122
171
  systemParts = [];
172
+ queuedInput = null;
123
173
  }
124
174
 
125
175
  /** Test seam: what is queued right now. */
126
176
  export function peekHookStdout() {
127
- return { hookEventName: queuedEvent, parts: [...parts], systemParts: [...systemParts] };
177
+ return {
178
+ hookEventName: queuedEvent,
179
+ parts: [...parts],
180
+ systemParts: [...systemParts],
181
+ updatedInput: queuedInput,
182
+ };
128
183
  }
package/mem-cli.mjs CHANGED
@@ -2651,9 +2651,11 @@ function cmdCitationStats(db, args) {
2651
2651
  for (const s of surfaceFunnel.surfaces) {
2652
2652
  const pct = (s.rate * 100).toFixed(1) + '%';
2653
2653
  // Which faces actually move importance is NOT readable from the rates —
2654
- // and an annotated keyctx beside a bare task_imperative reads as "that
2655
- // one does feed decay", which is false. Derived from the exported sets so
2656
- // the note cannot drift from the behaviour it describes.
2654
+ // and an annotated keyctx beside a bare `subagent` reads as "that one
2655
+ // does feed decay", which is false. Derived from the exported sets so
2656
+ // the note cannot drift from the behaviour it describes. (The example
2657
+ // used to name task_imperative; it joined the denominator on 2026-08-25
2658
+ // once its rate was read, leaving `subagent` as the bare non-decay face.)
2657
2659
  const note = s.surface === 'keyctx'
2658
2660
  ? ' (promotion-only: never demotes)'
2659
2661
  : (DECAY_DENOMINATOR_SURFACES.includes(s.surface)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.79.0",
3
+ "version": "3.81.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.79.0",
9
+ "version": "3.81.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.79.0",
3
+ "version": "3.81.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",
@@ -17,6 +17,11 @@ import { existsSync, readFileSync } from 'fs';
17
17
  import { basename, join } from 'path';
18
18
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
19
19
  import { recordHookError } from '../lib/hook-telemetry.mjs';
20
+ // D#154: every envelope on this stdout goes through the one writer. This script has a
21
+ // single emit today, so the change buys nothing on its own — it buys that a SECOND
22
+ // emit added later merges instead of producing two JSON documents, which the host
23
+ // parses as neither (lib/hook-stdout.mjs). Import-free module over no runtime deps.
24
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
20
25
 
21
26
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
22
27
 
@@ -69,10 +74,8 @@ async function main() {
69
74
  for (const d of dropped.slice(0, 3)) {
70
75
  lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
71
76
  }
72
- process.stdout.write(JSON.stringify({
73
- suppressOutput: true,
74
- hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
75
- }));
77
+ queueHookContext('PostToolUse', lines.join('\n'));
78
+ flushHookStdout();
76
79
  }
77
80
 
78
81
  // No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
@@ -66,15 +66,28 @@ async function main() {
66
66
  const { ensureDb } = await import('../schema.mjs');
67
67
  const { inferProject } = await import('../utils.mjs');
68
68
  const { buildSubagentInjection } = await import('../hook-memory.mjs');
69
+ // D#154: single envelope writer. Deferred to this line, not hoisted to a static
70
+ // import, because the file's stated contract is that the default-off path costs one
71
+ // env check and nothing else — the deferral filed this as "shared module vs
72
+ // import-free fast path, pick one", but the script already resolves that conflict
73
+ // three lines up: dynamic import on the enabled path only. The fast path above is
74
+ // untouched.
75
+ const { queueHookUpdatedInput, flushHookStdout } = await import('../lib/hook-stdout.mjs');
69
76
 
70
77
  let db;
71
78
  try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
72
79
  try {
73
80
  const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
74
81
  if (updatedInput) {
75
- process.stdout.write(JSON.stringify({
76
- hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput },
77
- }));
82
+ // Behaviour delta vs the hand-written envelope this replaced: it now carries
83
+ // top-level `suppressOutput: true`. Verified display-only in the 2.1.241 bundle —
84
+ // the field is documented "Hide stdout from transcript (default: false)" and is
85
+ // read at exactly one place, the transcript-render branch
86
+ // (`if (a6(he) && !he.suppressOutput && …)`); the updatedInput mutation is taken
87
+ // from the parsed hookSpecificOutput regardless. Hiding it is also the right
88
+ // audience call: this payload is the whole prompt echoed back, not a message.
89
+ queueHookUpdatedInput('PreToolUse', updatedInput);
90
+ flushHookStdout();
78
91
  }
79
92
  } catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
80
93
  try { db.close(); } catch { /* */ }
@@ -11,6 +11,9 @@ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
11
11
  // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
12
  // inside its "lightweight standalone" budget (no heavy transitive deps).
13
13
  import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
14
+ // D#154: single envelope writer. Also import-free (no runtime deps), so it stays
15
+ // inside this script's "lightweight standalone" budget.
16
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
14
17
 
15
18
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
16
19
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
@@ -110,13 +113,8 @@ try {
110
113
  } else {
111
114
  additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
112
115
  }
113
- process.stdout.write(JSON.stringify({
114
- suppressOutput: true,
115
- hookSpecificOutput: {
116
- hookEventName: 'PreToolUse',
117
- additionalContext,
118
- },
119
- }));
116
+ queueHookContext('PreToolUse', additionalContext);
117
+ flushHookStdout();
120
118
  } catch (e) {
121
119
  // Silent failure — never block Skill tool, but record for self-observation.
122
120
  recordHookError('skill-bridge:query', e, RUNTIME_DIR, { skillName });
@@ -19,6 +19,25 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
19
19
  import { recordMetric } from '../lib/metrics.mjs';
20
20
  import { presentIdents } from '../lib/lesson-idents.mjs';
21
21
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
22
+ // D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
23
+ // repeated-read guard, lesson block) and they stay one document because each branch
24
+ // process.exit()s before reaching the next.
25
+ //
26
+ // Be precise about what routing them through the queue does and does not buy, because an
27
+ // earlier version of this comment claimed "a second write is now impossible by
28
+ // construction" and that is FALSE (pre-tag review, v3.80.0): each site flushes
29
+ // IMMEDIATELY after queueing, and the flush resets the queue — so queue→flush→queue→flush
30
+ // emits two documents exactly like two raw writes would. Merging is a property of
31
+ // DEFERRING the flush (what hook.mjs does with a single flush at the end of its dispatch),
32
+ // not of using the queue.
33
+ //
34
+ // What it does buy: one construction site instead of three, so the "only the writer
35
+ // assembles an envelope" invariant is checkable (tests/hook-script-stdout-contract.test.mjs),
36
+ // and the merge is AVAILABLE to anyone who later defers the flush. The mutual exclusion
37
+ // itself is still control flow — the process.exit(0) below.
38
+ //
39
+ // Import-free module, no runtime deps — nothing added to this script's load cost.
40
+ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
22
41
  // Recall queries the SAVE-path project, so this MUST produce the same string as the
23
42
  // save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
24
43
  // drifted once (missing the process.env.PWD fallback, so a symlinked project dir
@@ -305,16 +324,11 @@ try {
305
324
  const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
306
325
  if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
307
326
  const idList = seenIds.map(id => `#${id}`).join(', ');
308
- process.stdout.write(JSON.stringify({
309
- suppressOutput: true,
310
- hookSpecificOutput: {
311
- hookEventName: 'PreToolUse',
312
- additionalContext: [
313
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
314
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
315
- ].join('\n'),
316
- },
317
- }));
327
+ queueHookContext('PreToolUse', [
328
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
329
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
330
+ ].join('\n'));
331
+ flushHookStdout();
318
332
  cooldown[filePath] = { ...entry, mode: 'edit' };
319
333
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
320
334
  } else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
@@ -322,16 +336,11 @@ try {
322
336
  // nudge to reuse what's already in context. Read-only; never throws.
323
337
  const meta = readFileMeta(filePath);
324
338
  if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
325
- process.stdout.write(JSON.stringify({
326
- suppressOutput: true,
327
- hookSpecificOutput: {
328
- hookEventName: 'PreToolUse',
329
- additionalContext: [
330
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
331
- buildRereadWarning(basename(filePath), entry.reread.tokens),
332
- ].join('\n'),
333
- },
334
- }));
339
+ queueHookContext('PreToolUse', [
340
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
341
+ buildRereadWarning(basename(filePath), entry.reread.tokens),
342
+ ].join('\n'));
343
+ flushHookStdout();
335
344
  recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
336
345
  }
337
346
  }
@@ -605,13 +614,8 @@ try {
605
614
  }
606
615
 
607
616
  if (lines.length > 0) {
608
- process.stdout.write(JSON.stringify({
609
- suppressOutput: true,
610
- hookSpecificOutput: {
611
- hookEventName: 'PreToolUse',
612
- additionalContext: lines.join('\n'),
613
- },
614
- }));
617
+ queueHookContext('PreToolUse', lines.join('\n'));
618
+ flushHookStdout();
615
619
  }
616
620
  // Cooldown applies on ALL branches (including silent-Read) so subsequent
617
621
  // calls on the same file in the same session don't re-query — preserving