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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-episode.mjs +66 -0
- package/lib/citation-tracker.mjs +167 -27
- package/lib/cite-back-hint.mjs +6 -1
- package/lib/hook-stdout.mjs +84 -29
- package/mem-cli.mjs +5 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-recall.js +7 -4
- package/scripts/pre-agent-inject.js +16 -3
- package/scripts/pre-skill-bridge.js +5 -7
- package/scripts/pre-tool-recall.js +31 -27
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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}}
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* framing under-performs, the penalty lands on the
|
|
499
|
-
* selects high-value ones) rather than on the
|
|
500
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
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
|
-
|
|
653
|
-
|
|
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
|
}
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/lib/hook-stdout.mjs
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
// hookSpecificOutput block, and an envelope carrying only a
|
|
104
|
-
// invent an event name to hang one on.
|
|
105
|
-
if (hasContext) {
|
|
106
|
-
envelope.hookSpecificOutput = {
|
|
107
|
-
|
|
108
|
-
|
|
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 {
|
|
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
|
|
2655
|
-
//
|
|
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)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|