claude-mem-lite 3.78.0 → 3.80.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.78.0",
13
+ "version": "3.80.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.78.0",
3
+ "version": "3.80.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
@@ -816,6 +816,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
816
816
  | `CLAUDE_MEM_UPS_TOP_MIN` | Minimum score for the top hit; `0` disables (useful on tiny test corpora). | `50` |
817
817
  | `CLAUDE_MEM_UPS_FLOOR_REF_CORPUS` | Reference corpus size the score floors are normalized against, so a fresh install with few rows is not silently gated to zero injections. Shared by every floor-bearing surface, including error-recall below. | `584` |
818
818
  | `CLAUDE_MEM_ERROR_RECALL_BM25_MIN` | Relevance floor for the error-recall surface (memories injected after a failed Bash command). **Off by default.** Setting it to `10.5` (the calibrated value) makes the surface stay silent when its best-matching memory is not actually about the failure — the whole set is dropped, never trimmed row-by-row. **It is a real trade, not a free win:** measured on a live database at that threshold, injections fall ~37% and ~39% of firings go silent, concentrated in projects with few memories. Off by default because nothing shows the dropped rows were noise. Explore with `node benchmark/error-recall-suite.mjs --sweep`. | `0` (off) |
819
+ | `CLAUDE_MEM_ERROR_RECALL_RERANK` | `off` restores the flat keyword ordering of the error-recall surface. **On by default**, and unlike the floor above it removes nothing: memories that share only the failed command's vocabulary are demoted below memories that mention the failure itself, and when a project has none of the latter the result is unchanged. Measured on a live database over 52 real failing commands × 15 projects: the lead memory matched no error term in 42.3% of firings before, 21.5% after, with the injected row count identical. | _(on)_ |
820
+ | `CLAUDE_MEM_ERROR_RECALL_ON_FAILURE` | `off` stops the plugin from recalling memories when a Bash command **fails at the host level**. On by default. Claude Code delivers failed tool calls to a separate `PostToolUseFailure` hook event, so before this the surface only ever saw commands that exited `0` while printing error-ish text — a genuinely failing build recalled nothing. Denials from your own guardrails (sandbox, policy hooks, declined permission prompts) and commands you interrupted are never recalled for. | _(on)_ |
819
821
  | `CLAUDE_MEM_UPS_IDENTIFIER_BYPASS` | `0` disables the bypass that lets an exact identifier match skip the score floors. | _(on)_ |
820
822
  | `CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT` | How many past-prompt rows the fallback arm may return. | `1` |
821
823
  | `MEM_COVERAGE_THRESHOLD` | Fraction of query terms a memory must cover to qualify (∈ [0,1]). | `0.4` |
package/bash-utils.mjs CHANGED
@@ -120,6 +120,58 @@ const ERROR_STOP_WORDS = new Set([
120
120
  const ERROR_LINE_RE = /error|fail|exception|cannot|not found|undefined|null/i;
121
121
  const ERROR_RECALL_MAX_TERMS = 6;
122
122
 
123
+ /**
124
+ * The token that NAMES the failure: an exception class (`ModuleNotFoundError`,
125
+ * `JSONDecodeError`), an errno-style code (`ENOENT`, `EACCES`), a signal (`SIGSEGV`),
126
+ * or Rust's `panicked`.
127
+ *
128
+ * WHY THIS EXISTS (D#167). The line scan below takes the FIRST 3 matching lines and the
129
+ * first 5 tokens of each, and a real failure puts its banner first and its name last:
130
+ *
131
+ * Traceback (most recent call last): <- matches, contributes `traceback most recent`
132
+ * File "<string>", line 3, in <module>
133
+ * ModuleNotFoundError: No module named 'x' <- the only line that says WHAT broke
134
+ *
135
+ * Measured over 52 real failing commands pulled from 1110 transcripts: 28 of them name
136
+ * their failure this way, and in 25 of those 28 (89.3%) THE NAME NEVER REACHED THE
137
+ * QUERY. The six-term budget went to the banner (`traceback,most,recent`) and to path
138
+ * fragments from the command (`mnt,data_ssd,dev`). Downstream, 39.2% of injected rows
139
+ * (764/1947 over 8 projects x 52 shapes on the live DB) matched no error term at all —
140
+ * they were admitted on command vocabulary alone — and for 42.3% of firing cases that
141
+ * was true of the TOP-1 row, whose lesson_learned is inlined into the model's context.
142
+ *
143
+ * This is deliberately a POSITIVE pattern for the signal, not a stop-list for the noise:
144
+ * a stop-list of boilerplate ("traceback", "most", "recent", "call", "last", …) grows
145
+ * once per runtime forever, which is the enumeration the D#136 docblock below warns
146
+ * against. Matching the shape of an exception name instead covers runtimes nobody has
147
+ * seen yet, and when nothing matches, extraction is byte-identical to before.
148
+ *
149
+ * Note `E[A-Z]{3,}` also matches the literal `ERROR`; it is dropped by ERROR_STOP_WORDS
150
+ * on the next line, and that interaction is load-bearing rather than incidental.
151
+ */
152
+ const ERROR_NAMER_RE = /\b(?:[A-Z][A-Za-z]*(?:Error|Exception)|E[A-Z]{3,}|SIG[A-Z]{2,}|panicked)\b/g;
153
+
154
+ /**
155
+ * How many names may jump the queue. Each one displaces a scanned term (the 6-term cap
156
+ * is unchanged), so this trades tail tokens for the identifier.
157
+ *
158
+ * ONE, and that is measured rather than argued. Swept on the live DB over the same 52
159
+ * shapes x 15 projects, reading the share of injected rows that match no error term and
160
+ * the share of cases whose top row does:
161
+ *
162
+ * MAX=1 434 rows (22.4%) 154 cases (21.5%)
163
+ * MAX=2 442 rows (22.8%) 157 cases (22.0%)
164
+ * MAX=3 445 rows (22.9%) 157 cases (22.0%)
165
+ *
166
+ * The second name never pays: 96.4% of shapes already have their failure named by the
167
+ * first, so slots 2 and 3 buy a duplicate or a second-order name while still evicting a
168
+ * scanned term — and the evicted tail is often the most specific token in the list (see
169
+ * the golden case in tests/error-recall-gate.test.mjs, where a filename is lost). This
170
+ * shipped at 2 on the reasoning that a chained Python traceback has two real names; the
171
+ * sweep says that reasoning does not survive contact with the sample.
172
+ */
173
+ const ERROR_NAMER_MAX = 1;
174
+
123
175
  /**
124
176
  * Split a failed command + its output into command-derived and error-derived terms.
125
177
  * Shared by extractErrorKeywords (merged view, unchanged contract) and
@@ -137,6 +189,19 @@ function collectErrorTerms(cmd, response) {
137
189
  if (!ERROR_STOP_WORDS.has(lw) && !seen.has(lw)) { seen.add(lw); cmdWords.push(lw); }
138
190
  }
139
191
  const errWords = [];
192
+ // The failure's NAME goes in first — see ERROR_NAMER_RE for the measurement that put
193
+ // it here. Prepending rather than re-ordering the LINE scan is deliberate: sorting
194
+ // namer-bearing lines to the front also promotes their verbose neighbours, which costs
195
+ // real terms (on npm's ENOENT output it evicts `syscall` in favour of `such`/`file`
196
+ // from the long "no such file or directory" line). Prepending only ever displaces the
197
+ // TAIL of what the scan would have produced.
198
+ for (const m of String(response || '').match(ERROR_NAMER_RE) || []) {
199
+ if (errWords.length >= ERROR_NAMER_MAX) break;
200
+ const lt = m.toLowerCase();
201
+ if (ERROR_STOP_WORDS.has(lt) || seen.has(lt)) continue;
202
+ seen.add(lt);
203
+ errWords.push(lt);
204
+ }
140
205
  // The line filter is the TRIGGER's pattern list OR'd with the prose one. Anything
141
206
  // that made detectBashSignificance call this a hard error is, by construction, also
142
207
  // something we will extract terms from — which closes the "trigger fired, extractor
@@ -163,6 +228,40 @@ function collectErrorTerms(cmd, response) {
163
228
  return { cmdWords, errWords };
164
229
  }
165
230
 
231
+ /**
232
+ * THE CAP TRUNCATES BY POSITION, AND THAT WAS TESTED AGAINST THE ALTERNATIVE (D#169).
233
+ *
234
+ * The alternative looked obviously right. Tokens are scanned line by line, prose comes
235
+ * before identifiers within a line, so `AssertionError: expected observation-write.mjs
236
+ * to be defined` yields `assertionerror, expected, observation-write.mjs` — and whatever
237
+ * the cap removes comes off that end. On a real shape, `npx vitest run
238
+ * tests/scope-label.test.mjs` failing with an AssertionError kept `fail, tests` and
239
+ * dropped `scope-label.test.mjs`: a filename traded for a word in thousands of memories.
240
+ * Keeping identifier-shaped tokens (`[._-]`) first should fix that.
241
+ *
242
+ * Measured on the live DB, same 58 real shapes x 15 projects either way:
243
+ *
244
+ * cmd-only rows cmd-only at TOP-1
245
+ * positional cap (shipped) 493/2184 22.6% 171/801 21.3%
246
+ * identifier-first cap 749/2093 35.8% 259/775 33.4%
247
+ *
248
+ * Thirteen points WORSE on both, and the mechanism is worth keeping written down:
249
+ * `[._-]` conflates "discriminative" with "unique to this invocation". The tokens it
250
+ * promotes are this run's own paths and filenames — `d167-measure.mjs`,
251
+ * `s-default.json` — whose IDF is so high they match NOTHING in the corpus, and the
252
+ * tokens it evicts to make room are `enoent`, `syscall`: low-IDF, but present in the
253
+ * memories that actually explain the failure. A row then survives on command vocabulary
254
+ * alone, which is the D#167 defect re-created from the other side.
255
+ *
256
+ * (A variant that promoted identifiers only among ERROR words, sparing command words,
257
+ * measured byte-identical: command words sit at low indices, so a positional tiebreak
258
+ * already keeps them and promotion never decides their fate.)
259
+ *
260
+ * The premise "the evicted tail is systematically the good part" is therefore FALSE.
261
+ * The tail is often hapax. Any future attempt here needs real document frequencies, not
262
+ * a shape heuristic — and planErrorRecall is pure, with no corpus to count against.
263
+ */
264
+
166
265
  /**
167
266
  * Extract discriminative keywords from a failed command and its error output.
168
267
  * Filters out common stop words to produce useful FTS5 search terms.
@@ -172,6 +271,8 @@ function collectErrorTerms(cmd, response) {
172
271
  */
173
272
  export function extractErrorKeywords(cmd, response) {
174
273
  const { cmdWords, errWords } = collectErrorTerms(cmd, response);
274
+ // Same cap rule as planErrorRecall — the two must not drift into dialects, which is
275
+ // pinned by a test asserting they emit identical lists.
175
276
  const result = [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS);
176
277
  return result.length >= 1 ? result : null;
177
278
  }
@@ -231,12 +332,26 @@ export function extractErrorKeywords(cmd, response) {
231
332
  *
232
333
  * @param {string} cmd The command that was executed
233
334
  * @param {string} response The error output text
234
- * @returns {{terms: string[]}|null} null ⇒ do not inject
335
+ * @returns {{terms: string[], cmdWords: string[], errWords: string[]}|null}
336
+ * null ⇒ do not inject. The two classes are returned ALONGSIDE the merged list, and
337
+ * post-cap, so the retrieval surface can rank on "did this row match the failure or
338
+ * only the command" without re-deriving the split from the command string — a
339
+ * re-derivation is the "second program that merely looks like the first" trap this
340
+ * file's consumer (lib/error-recall-core.mjs) is structured to avoid.
235
341
  */
236
342
  export function planErrorRecall(cmd, response) {
237
343
  const { cmdWords, errWords } = collectErrorTerms(cmd, response);
238
344
  if (errWords.length === 0) return null;
239
- return { terms: [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS) };
345
+ const terms = [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS);
346
+ // Intersect with the CAPPED list: a term the cap dropped is not in the query, so
347
+ // reporting it as an error term would have the surface rank on a word it never
348
+ // matched on.
349
+ const kept = new Set(terms);
350
+ return {
351
+ terms,
352
+ cmdWords: cmdWords.filter((t) => kept.has(t)),
353
+ errWords: errWords.filter((t) => kept.has(t)),
354
+ };
240
355
  }
241
356
 
242
357
  // ─── File Paths ──────────────────────────────────────────────────────────────
package/hook.mjs CHANGED
@@ -49,6 +49,7 @@ import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from '.
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
51
  import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
52
+ import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
52
53
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
53
54
  import { cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, resolveDefaultMaintainOps, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
54
55
  import { snapshotDb } from './lib/db-backup.mjs';
@@ -483,7 +484,18 @@ async function handlePostToolUse() {
483
484
 
484
485
  // ─── Error-Triggered Recall (Tier 2 G) ─────────────────────────────────────
485
486
 
486
- function triggerErrorRecall(db, toolInput, response) {
487
+ /**
488
+ * @param {object} db Open handle.
489
+ * @param {object} toolInput The failed tool's input (needs `.command`).
490
+ * @param {string} response The failure text.
491
+ * @param {{eventName?: string, metricEvent?: string}} [opts] Which hook event this is
492
+ * answering. The envelope carries exactly one hookEventName and the host rejects a
493
+ * mismatch, so the PostToolUseFailure path MUST pass its own — defaulting silently
494
+ * would emit a PostToolUse envelope from a PostToolUseFailure hook.
495
+ */
496
+ function triggerErrorRecall(db, toolInput, response, opts = {}) {
497
+ const eventName = opts.eventName || 'PostToolUse';
498
+ const metricEvent = opts.metricEvent || 'error_recall';
487
499
  try {
488
500
  const project = inferProject();
489
501
 
@@ -510,7 +522,7 @@ function triggerErrorRecall(db, toolInput, response) {
510
522
  // G13: this surface feeds the citation denominator but had zero metering —
511
523
  // the G8 gate change (isError→isHardError) could not be volume-verified
512
524
  // from metrics. Counter only; no latency (query is bundled in the hook).
513
- recordMetric(join(RUNTIME_DIR, '..'), { event: 'error_recall', returned: rows.length });
525
+ recordMetric(join(RUNTIME_DIR, '..'), { event: metricEvent, returned: rows.length });
514
526
  // MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
515
527
  // a raw multi-line write corrupts a co-emitted episode-flush receipt.
516
528
  // The follow-up correction (2026-08-17): "two separate JSON lines each parse
@@ -518,11 +530,118 @@ function triggerErrorRecall(db, toolInput, response) {
518
530
  // flushEpisode in one handlePostToolUse, and two documents make the parser
519
531
  // fall back to plain text — which the renderer drops entirely for
520
532
  // PostToolUse. Both receipts vanished. Queue; one envelope is written at exit.
521
- queueHookContext('PostToolUse', out);
533
+ queueHookContext(eventName, out);
522
534
  }
523
535
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
524
536
  }
525
537
 
538
+ /**
539
+ * PostToolUseFailure — the event that carries tool calls the HOST judged failed (D#170).
540
+ *
541
+ * WHY A SECOND ENTRY POINT AT ALL. `PostToolUse` does not fire for a failed tool call;
542
+ * Claude Code routes those here. Registering only `PostToolUse` therefore made this
543
+ * plugin blind to every host-flagged failure, and the only "failures" error-recall ever
544
+ * saw were commands that exited 0 while printing error-ish text — the classic shape
545
+ * being `cmd 2>&1 | tail`, where the pipe launders a failure into a success. Verified on
546
+ * the 2.1.241 bundle (the event and its schema) and by live probe (two genuinely failing
547
+ * Bash calls, one with a full stack frame, left zero trace in the episode buffer and the
548
+ * events table while the successful calls either side were recorded).
549
+ *
550
+ * THE PAYLOAD IS NOT PostToolUse'S. There is no `tool_response`; the failure text is in
551
+ * `error`, and `is_interrupt` marks a user cancellation. Reading `tool_response` here
552
+ * would find undefined and silently do nothing — which is exactly how this class of
553
+ * wiring bug stays invisible, so the field names are asserted in the tests.
554
+ *
555
+ * DELIBERATELY NARROWER THAN THE EVENT. Only `Bash` (this surface queries on a command
556
+ * plus its output; no other tool has that shape), only when the failure came from a
557
+ * PROGRAM rather than from the agent's own tool chain (lib/tool-refusal.mjs — 68.9% of
558
+ * host-flagged failures on the maintainer's machine were guardrails working), and NOT
559
+ * feeding the episode buffer. That last exclusion is scope, not oversight: episode
560
+ * entries flow into LLM summarisation and the bugfix save-nudge, whose behaviour under a
561
+ * sudden influx of failures has not been measured, and this change is already worth a
562
+ * 3.5x increase in this surface's firing volume.
563
+ *
564
+ * NO `isHardError` GATE HERE, AND THAT IS THE POINT — stated because review found it
565
+ * unwritten and it is a real semantic difference between the two entry points. On the
566
+ * PostToolUse path the host says nothing about success, so `isHardError` has to GUESS
567
+ * from vocabulary whether a command failed; here the host has already ruled, and
568
+ * re-deriving its verdict from the text would only discard cases. The measurable
569
+ * consequence: `Segmentation fault (core dumped)` and a Rust `panicked at` both score
570
+ * `isHardError === false` (the first has no error word, the second loses on `\bpanic\b`'s
571
+ * word boundary), so until this event they could not reach term extraction at all. They
572
+ * can now. That is the coverage this event was wired for, not an oversight — but it does
573
+ * mean ERROR_NAMER_RE's `SIG…`/`panicked` alternatives went live here first.
574
+ *
575
+ * Off switch: CLAUDE_MEM_ERROR_RECALL_ON_FAILURE=off.
576
+ */
577
+ async function handlePostToolFailure() {
578
+ if (String(process.env.CLAUDE_MEM_ERROR_RECALL_ON_FAILURE || '').toLowerCase() === 'off') return;
579
+
580
+ let raw;
581
+ try { raw = await readStdin(); } catch { return; }
582
+ let hookData;
583
+ try { hookData = JSON.parse(raw.text); } catch { return; }
584
+
585
+ const { tool_name, tool_input, error, is_interrupt } = hookData || {};
586
+ // The manifest matcher already scopes this to Bash. Re-checking is not redundancy for
587
+ // its own sake: install.mjs writes a SECOND registration into the user's settings.json,
588
+ // and a hand-edited matcher there would otherwise hand this path an Edit or a Read.
589
+ //
590
+ // A payload whose SHAPE is wrong is recorded, not just dropped. Tests pin the field
591
+ // names against a payload we construct; they cannot see the host renaming `error` or
592
+ // changing `tool_name`'s type, and this whole path fails silently by design — the
593
+ // v3.60 binding outage ran four days on exactly that combination, and hook-errors/ was
594
+ // the only window that would have shown it. Volume is bounded by the recorder's
595
+ // 14-day retention and one short line per fire.
596
+ if (tool_name !== undefined && typeof tool_name !== 'string') {
597
+ recordHookError(
598
+ 'post-tool-failure:tool_name-type',
599
+ new TypeError(`tool_name is ${Array.isArray(tool_name) ? 'array' : typeof tool_name}, expected string`),
600
+ RUNTIME_DIR,
601
+ );
602
+ return;
603
+ }
604
+ if (tool_name !== 'Bash') return;
605
+ if (error !== undefined && typeof error !== 'string') {
606
+ recordHookError(
607
+ 'post-tool-failure:error-type',
608
+ new TypeError(`error is ${typeof error}, expected string — host payload shape may have changed`),
609
+ RUNTIME_DIR,
610
+ );
611
+ return;
612
+ }
613
+
614
+ const verdict = shouldRecallOnFailure({ error, is_interrupt });
615
+ if (!verdict.ok) return;
616
+
617
+ const toolInput = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
618
+ if (typeof toolInput?.command !== 'string' || !toolInput.command) return;
619
+
620
+ let db = null;
621
+ try {
622
+ db = openDb();
623
+ if (!db) return;
624
+ // Same selection, same rendering, same core as the PostToolUse path — only the event
625
+ // name on the envelope differs. A second copy of the query here is the twin-drift
626
+ // defect this project keeps paying for.
627
+ triggerErrorRecall(db, toolInput, error, {
628
+ eventName: 'PostToolUseFailure',
629
+ // A separate counter, so the volume this event adds is readable on its own rather
630
+ // than merged into the existing surface's total. The citation funnel deliberately
631
+ // keeps ONE `error_recall` surface: these are the same injections to the model, and
632
+ // splitting the cite-rate denominator would make both halves too small to read.
633
+ metricEvent: 'error_recall_failure',
634
+ });
635
+ } catch (e) {
636
+ debugCatch(e, 'handlePostToolFailure');
637
+ } finally {
638
+ // No flushHookStdout() here: the single flush after the event switch owns writing
639
+ // the envelope, and two flush points is how a process ends up emitting two JSON
640
+ // documents — the degradation that made BOTH receipts vanish in v3.68.
641
+ if (db) try { db.close(); } catch {}
642
+ }
643
+ }
644
+
526
645
  // ─── Stop Handler ───────────────────────────────────────────────────────────
527
646
 
528
647
  async function handleStop() {
@@ -1983,6 +2102,8 @@ function normalizeToolResponse(toolResponse) {
1983
2102
  try {
1984
2103
  switch (event) {
1985
2104
  case 'post-tool-use': await handlePostToolUse(); break;
2105
+ // Host-flagged tool failures arrive on their own event; PostToolUse never sees them.
2106
+ case 'post-tool-failure': await handlePostToolFailure(); break;
1986
2107
  case 'session-start': await handleSessionStart(); break;
1987
2108
  case 'pre-compact': await handlePreCompactDispatch(); break;
1988
2109
  case 'stop': await handleStop(); break;
package/hooks/hooks.json CHANGED
@@ -84,6 +84,18 @@
84
84
  ]
85
85
  }
86
86
  ],
87
+ "PostToolUseFailure": [
88
+ {
89
+ "matcher": "Bash",
90
+ "hooks": [
91
+ {
92
+ "type": "command",
93
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-launcher.mjs\" hook.mjs post-tool-failure",
94
+ "timeout": 5
95
+ }
96
+ ]
97
+ }
98
+ ],
87
99
  "Stop": [
88
100
  {
89
101
  "matcher": "*",
package/install.mjs CHANGED
@@ -718,6 +718,19 @@ const memPostToolRecall = {
718
718
  }]
719
719
  };
720
720
 
721
+ // D#170. A SEPARATE event from PostToolUse, not a variant of it: Claude Code does not
722
+ // fire PostToolUse for a tool call it judged failed, so without this registration the
723
+ // plugin never sees a single host-flagged failure. Matched on Bash alone — the surface
724
+ // it feeds queries on a command plus its output, and no other tool has that shape.
725
+ const memPostToolFailure = {
726
+ matcher: 'Bash',
727
+ hooks: [{
728
+ type: 'command',
729
+ command: nodeHook('hook.mjs', 'post-tool-failure'),
730
+ timeout: 5
731
+ }]
732
+ };
733
+
721
734
  const memSessionStart = {
722
735
  matcher: 'startup|clear|compact',
723
736
  hooks: [{
@@ -819,6 +832,7 @@ const memPreAgentInject = {
819
832
  const hookConfigs = {
820
833
  PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
821
834
  PostToolUse: [memPostToolUse, memPostToolRecall],
835
+ PostToolUseFailure: [memPostToolFailure],
822
836
  PreCompact: [memPreCompact],
823
837
  SessionStart: [memSessionStart],
824
838
  Stop: [memStop],
@@ -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
@@ -569,7 +625,7 @@ export function unionSurfaces(bySurface) {
569
625
  const SUBAGENT_INJECT_MARKER = /surfaced by your operator's claude-mem-lite/;
570
626
  // Row-anchored to the `#NN — ` tag so a #NN quoted inside the lesson body does NOT enter
571
627
  // 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+—/;
628
+ const SUBAGENT_INJECT_ID_RE = new RegExp(`^\\s{0,4}#(${OBS_ID_DIGITS})\\s+—`);
573
629
 
574
630
  /**
575
631
  * Extract observation ids injected into a subagent's PROMPT by pre-agent-inject.js
@@ -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();
@@ -127,15 +127,26 @@ const ERROR_RECALL_HALF_LIFE_MS = '1209600000.0';
127
127
  * (decay reference) and @floor (|bm25| minimum). Named rather than positional
128
128
  * so the binding cannot silently renumber when the statement is rearranged.
129
129
  */
130
- export function errorRecallSql(limit = ERROR_RECALL_LIMIT) {
131
- // Number.isFinite before trunc: `Number('Infinity')` is a number and truncates to
132
- // Infinity, which interpolates as `LIMIT Infinity` and throws at prepare(). Not an
133
- // injection (every string form coerces to the default) but a crash where a fallback
134
- // belongs.
130
+ /**
131
+ * The row cap, coerced. ONE body, because the SQL builder and the rerank's control flow
132
+ * both need it and a second copy let them disagree: at `limit: 0` the rerank compared
133
+ * against the raw value, short-circuited its fallback and became a filter, while the SQL
134
+ * had already fallen back to 3.
135
+ *
136
+ * Number.isFinite before trunc: `Number('Infinity')` is a number and truncates to
137
+ * Infinity, which interpolates as `LIMIT Infinity` and throws at prepare(). Not an
138
+ * injection (every string form coerces to the default) but a crash where a fallback
139
+ * belongs.
140
+ */
141
+ function sanitizeErrorRecallLimit(limit) {
135
142
  const asNum = Number(limit);
136
- const n = Number.isFinite(asNum) && asNum >= 1
143
+ return Number.isFinite(asNum) && asNum >= 1
137
144
  ? Math.max(1, Math.trunc(asNum))
138
145
  : ERROR_RECALL_LIMIT;
146
+ }
147
+
148
+ export function errorRecallSql(limit = ERROR_RECALL_LIMIT) {
149
+ const n = sanitizeErrorRecallLimit(limit);
139
150
  // No floor in the SQL: the gate is SET-LEVEL and lives in selectErrorRecall. See
140
151
  // its docblock for why. This statement is the pre-floor one plus a bm25_raw column.
141
152
  return `
@@ -183,6 +194,40 @@ export function errorRecallFtsQuery(terms) {
183
194
  return (terms || []).map((t) => `"${String(t).replace(/"/g, '""')}"`).join(' OR ');
184
195
  }
185
196
 
197
+ /**
198
+ * The ERROR-FIRST form of the same expression: a row must carry at least one term that
199
+ * came from the FAILURE, while every term — command words included — stays in the
200
+ * expression so bm25 still sums their contribution.
201
+ *
202
+ * The redundant-looking second clause is the point. Dropping command words from the
203
+ * QUERY was measured against the live DB in D#136 and regressed two of five replays:
204
+ * `database` and `vitest` were carrying domain anchoring, not noise. This keeps them
205
+ * scoring while denying them the power to admit a row on their own.
206
+ *
207
+ * Module-private on purpose: selectErrorRecall is the only caller, and the tests assert
208
+ * on the expression it REPORTS (`errorFirstQuery`) rather than importing the builder —
209
+ * a guard through the real path beats one through a side door. Exporting it would add a
210
+ * name to the knip baseline for no consumer (the #9675 precedent).
211
+ *
212
+ * @returns {string|null} null when there is no error term to require.
213
+ */
214
+ function errorRecallErrorFirstQuery(terms, errWords) {
215
+ if (!errWords || !errWords.length) return null;
216
+ const or = (list) => list.map((t) => `"${String(t).replace(/"/g, '""')}"`).join(' OR ');
217
+ return `(${or(errWords)}) AND (${or(terms)})`;
218
+ }
219
+
220
+ /**
221
+ * Rerank kill-switch. Default ON — see selectErrorRecall for the measurement.
222
+ * `CLAUDE_MEM_ERROR_RECALL_RERANK=off` restores the flat-OR ordering exactly.
223
+ *
224
+ * Module-private for the same reason as the builder above; the switch is exercised
225
+ * end-to-end through selectErrorRecall in the test suite, which is where it has to work.
226
+ */
227
+ function errorRecallRerankEnabled() {
228
+ return String(process.env.CLAUDE_MEM_ERROR_RECALL_RERANK || '').toLowerCase() !== 'off';
229
+ }
230
+
186
231
  /**
187
232
  * Decide whether error-recall fires, and select its rows.
188
233
  *
@@ -219,7 +264,69 @@ export function selectErrorRecall(db, {
219
264
  // one. v3.61.0 shipped an unscaled floor and injected 0/8 on fresh installs.
220
265
  const effective = base > 0 ? base * corpusFloorScale(db) : 0;
221
266
 
222
- const rows = db.prepare(errorRecallSql(limit)).all({ q: ftsQuery, project, now });
267
+ const stmt = db.prepare(errorRecallSql(limit));
268
+
269
+ // ── ERROR-FIRST RERANK (D#167) ──────────────────────────────────────────────
270
+ // The flat OR admits a row on ANY term, so a memory that merely shares the command's
271
+ // vocabulary competes for the three slots on equal footing with one that names the
272
+ // failure. Measured on the live DB over 52 real failing commands (with their real
273
+ // stderr, extracted from 1110 transcripts) x 15 projects, ~715 firing cases. Rows that
274
+ // match NO error term at all, and cases whose TOP-1 row is one of those — the top row
275
+ // being the one whose lesson_learned is inlined verbatim into the model's context:
276
+ //
277
+ // cmd-only rows cmd-only at TOP-1
278
+ // v3.78.0 (flat OR, banner terms) 764/1947 39.2% 302/714 42.3%
279
+ // flat OR + ERROR_NAMER_RE 780/1941 40.2% 301/715 42.1%
280
+ // + error-first rerank (shipped) 434/1941 22.4% 154/715 21.5%
281
+ //
282
+ // Read the middle row before reaching for the term fix alone: naming the failure did
283
+ // NOT reduce command-vocabulary injection, it nudged it up. Better terms are more
284
+ // specific, so they match fewer rows, so the flat OR has MORE room to fill the three
285
+ // slots with whatever shares the command's words. Terms and ranking are two
286
+ // independent defects and only the pair moves this number.
287
+ //
288
+ // The obvious alternative — make the error term MANDATORY and stop there — was
289
+ // measured too: 1499 rows (−22.8%) and 157 of 715 cases (22.0%) injecting nothing,
290
+ // with the loss concentrated in small projects. That is the magnitude floor's failure
291
+ // mode wearing a different hat (see errorRecallBm25Floor above), so it is not what
292
+ // ships. This REORDERS and never removes: rows that only match the command fall to
293
+ // slots 2-3 instead of being deleted, and when NOTHING in the project matches an
294
+ // error term the result is byte-identical to the flat OR. The residual ~21.5% is
295
+ // essentially that set — verified, not assumed: of the cases the mandatory form
296
+ // silences, 0 had a base set containing an error-matching row.
297
+ //
298
+ // Cost is one extra query only when the primary comes up short of the cap. D#136
299
+ // rejected this shape on the grounds that "the primary always filled its LIMIT 3" —
300
+ // true of the five cases it replayed, false at 715, where the primary leaves hundreds
301
+ // of slots for the fallback to fill.
302
+ let rows;
303
+ const errorFirst = errorRecallRerankEnabled()
304
+ ? errorRecallErrorFirstQuery(plan.terms, plan.errWords)
305
+ : null;
306
+ const primary = errorFirst ? stmt.all({ q: errorFirst, project, now }) : [];
307
+ // Compare against the SANITIZED cap, not the raw argument. errorRecallSql coerces
308
+ // (finite, >= 1, else the default) before interpolating, so the raw value and the one
309
+ // the SQL used can disagree — and at `limit: 0` the raw comparison short-circuits the
310
+ // fallback, turning the rerank into the filter this face deliberately rejected.
311
+ // Unreachable from the hook (it passes no limit), found by fuzzing in review.
312
+ const cap = sanitizeErrorRecallLimit(limit);
313
+ if (primary.length >= cap) {
314
+ rows = primary;
315
+ } else {
316
+ // Fallback fills the remaining slots from the unchanged flat-OR result, skipping
317
+ // what the primary already returned. `rows` therefore has the same LENGTH as the
318
+ // pre-rerank behaviour in every case, including the case where primary is empty.
319
+ const flat = stmt.all({ q: ftsQuery, project, now });
320
+ rows = [...primary];
321
+ for (const r of flat) {
322
+ if (rows.length >= cap) break;
323
+ // The id check is load-bearing, not defensive: the error-first match set is a
324
+ // SUBSET of the flat one, so every primary row appears again in `flat`. Without
325
+ // this, the top row is injected twice and one of three slots is wasted. Review
326
+ // measured the mutant: ids 1,2,3 -> 1,1,2.
327
+ if (!rows.some((x) => x.id === r.id)) rows.push(r);
328
+ }
329
+ }
223
330
 
224
331
  // SET-LEVEL gate, the shape UPS already uses (read the top row; on failure drop the
225
332
  // WHOLE set) rather than filtering row by row.
@@ -247,10 +354,49 @@ export function selectErrorRecall(db, {
247
354
  // switched to max(): UPS gates on `ftsRows[0]` the same way, and one face quietly
248
355
  // disagreeing with the other about what "the top hit" means is worse than the
249
356
  // occasional veto. Stated here because the comment above used to claim otherwise.
357
+ //
358
+ // With the rerank above, `rows[0]` is the top row of the ERROR-FIRST result whenever
359
+ // one exists. The gate is therefore applied to the row it is actually about — the
360
+ // best row that mentions the failure — instead of to whatever the flat OR floated up.
361
+ //
362
+ // AND IT ALSO CHANGES THE SCALE, which the first version of this comment missed and
363
+ // pre-release review caught. `(errWords) AND (allTerms)` repeats every error term, and
364
+ // FTS5's bm25() sums over phrase instances, so a PRIMARY row's |bm25_raw| is
365
+ // systematically larger than the same row's flat-OR score — measured on one row in an
366
+ // in-memory index, 1.334 -> 1.779. Rows in the same result set can therefore carry two
367
+ // different scales (primary rows error-first, fallback rows flat).
368
+ //
369
+ // What that costs: CALIBRATED_ERROR_RECALL_BM25_FLOOR = 10.5 was derived from the
370
+ // PRE-RERANK distribution — the gap between filler p75 10.99 and relevant min 10.93 in
371
+ // the table above. Re-running `benchmark/error-recall-suite.mjs --scores` with the
372
+ // rerank ON moves filler p75 to 20.27 and relevant min to 21.87, so 10.5 no longer
373
+ // sits in any gap. The drift is toward a LOOSER gate on sets that have a primary and
374
+ // an unchanged one on sets that do not (empty primary keeps the flat scale), which may
375
+ // be an improvement; nothing has measured it.
376
+ //
377
+ // This is documented rather than fixed because the floor's default is 0 — nothing
378
+ // ships gated. Anyone switching it on must re-derive the constant with the rerank in
379
+ // whatever state they intend to run, NOT reuse the table above.
250
380
  if (effective > 0 && rows.length && Math.abs(rows[0].bm25_raw) < effective) {
251
381
  return {
252
- rows: [], terms: plan.terms, ftsQuery, floor: effective, suppressed: rows.length,
382
+ rows: [],
383
+ terms: plan.terms,
384
+ ftsQuery,
385
+ // Reported here too: this is the path where knowing which expression ranked the
386
+ // row the gate just rejected matters MOST, and the first version omitted it.
387
+ errorFirstQuery: errorFirst,
388
+ floor: effective,
389
+ suppressed: rows.length,
253
390
  };
254
391
  }
255
- return { rows, terms: plan.terms, ftsQuery, floor: effective, suppressed: 0 };
392
+ return {
393
+ rows,
394
+ terms: plan.terms,
395
+ ftsQuery,
396
+ // The expression that actually ranked the set, so the calibration suite and any
397
+ // future debugging read the query that ran rather than the one that would have.
398
+ errorFirstQuery: errorFirst,
399
+ floor: effective,
400
+ suppressed: 0,
401
+ };
256
402
  }
@@ -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
  }
@@ -0,0 +1,117 @@
1
+ // Did this tool call fail because a PROGRAM failed, or because the agent's own
2
+ // tool chain said no?
3
+ //
4
+ // WHY THIS EXISTS. Claude Code delivers host-flagged tool failures to the
5
+ // `PostToolUseFailure` event (D#170), which is where error-recall gets its coverage of
6
+ // real failures. But that event fires for EVERY failed call, and a large share of them
7
+ // are not failures at all in the sense that matters here — they are guardrails working:
8
+ // a §8 SAFETY hook denying an `rm -rf $VAR`, a sandbox refusing a syscall, a policy hook
9
+ // steering a `grep` to the AST tool. Measured over 1110 transcripts, 558 of 810
10
+ // host-flagged Bash failures (68.9%) were of that kind — one number, re-measured with the
11
+ // shipped predicate after review found two irreconcilable figures in this file and the
12
+ // CHANGELOG. Reproduce with `node benchmark/error-recall-live-replay.mjs --host-failures`,
13
+ // which now buckets by the gate's own reason instead of lumping refusals with empties.
14
+ //
15
+ // Recalling a past lesson because a permission prompt was declined is noise by
16
+ // construction, and on the lowest-cited injection surface in the system it is the kind of
17
+ // noise that discredits the rest.
18
+ //
19
+ // THE ERROR DIRECTIONS ARE NOT SYMMETRIC, and the list below is tuned accordingly:
20
+ //
21
+ // false "refusal" → we stay silent on a real failure. Costs nothing beyond the
22
+ // status quo, which is silence for every host-flagged failure.
23
+ // missed refusal → we inject three memories about a denied permission prompt.
24
+ //
25
+ // So every entry is anchored on a tool-chain marker — a bracketed plugin tag, a spec
26
+ // section marker, a sandbox syscall name — rather than a generic word like "denied" or
27
+ // "permission", which real programs print constantly (`chmod`, `docker`, `sudo`, HTTP
28
+ // 403 handlers).
29
+ //
30
+ // "NO ORDINARY PROGRAM EMITS THESE" IS TOO STRONG, AND REVIEW MEASURED IT. Over the same
31
+ // 1110-transcript corpus, three sentinels appear in the stdout of SUCCESSFUL commands:
32
+ // `[claudemd]` (21), `No such tool available` (10), `requested permission(s) to use` (7)
33
+ // — mostly this repo's own test runners printing hook output. When such a runner FAILS,
34
+ // the gate swallows a real failure. That is the cheap direction (see above), but the
35
+ // claim is "anchored on a marker real programs rarely emit", not "never".
36
+ //
37
+ // Two of those three (`[claudemd]`, `requested permissions? to use`) had ZERO matches
38
+ // among the 558 actual refusals in that corpus — they are speculative, covering hook
39
+ // stacks other than this one, and today they pay only their false-positive cost. Kept
40
+ // deliberately: a user running those hooks gets the protection, and the cost is silence
41
+ // on a failure that was already silent before this event existed. Delete them if a
42
+ // measurement ever shows them costing more than that.
43
+ //
44
+ // THIS RATIO IS ONE MACHINE'S PROFILE. 68.9% is what the maintainer's own hook stack
45
+ // produces; a user with no policy hooks will see close to 0%. The filter is correctness
46
+ // for whoever has those hooks, not a tuning constant — do not treat the percentage as a
47
+ // property of the product.
48
+
49
+ /**
50
+ * Sentinels that identify a refusal emitted by the agent's own tool chain.
51
+ * Ordered roughly by observed frequency; the array is small enough that order is
52
+ * cosmetic.
53
+ */
54
+ export const REFUSAL_SENTINELS = [
55
+ // Sandbox / seccomp layers refusing to run the command at all.
56
+ /apply-seccomp: unshare\(/,
57
+ /sandbox\.excludedCommands/,
58
+ // Spec / policy hooks (claudemd and friends) denying a command by rule. Anchored on
59
+ // the SECTION MARKER at the start of the text, not on the one section that was found
60
+ // first: pre-release review scanned the same corpus and found the family is wider than
61
+ // `§8` — `§7 Ship-baseline` (a policy hook blocking `git push`), `§11 MEMORY.md`,
62
+ // `§10-V Specificity`, `§10-V prose scan`. The `§7` shape alone was 13 of 135 firing
63
+ // cases (9.6%), and it does not merely waste a slot: its own boilerplate becomes the
64
+ // query, so `git push` blocked by a red-CI rule injected three memories about
65
+ // statusline adoption. The other three shapes were silent only because
66
+ // planErrorRecall found no term in them — luck, not this gate.
67
+ //
68
+ // `§` followed by a digit at the very start of the failure text is a spec citation. No
69
+ // compiler, runtime or CLI opens its stderr that way.
70
+ /^\s*§\s*\d/,
71
+ /§\d[\w.-]*\s+[A-Z][\w-]*[^\n]{0,60}:\s/,
72
+ /\[claudemd\]/,
73
+ // Plugin hooks that deny-and-redirect rather than let the command run.
74
+ /\[code-graph\][^\n]{0,80}denied/i,
75
+ // Host-level refusals: the tool was not available in this context.
76
+ /No such tool available/i,
77
+ /\bis not available to you as the coordinator\b/i,
78
+ // The human said no. Not a program failure, and re-asking with a recalled memory
79
+ // attached is the wrong response to it.
80
+ /user doesn'?t want to (?:take this action|proceed)/i,
81
+ /requested permissions? to use/i,
82
+ ];
83
+
84
+ /**
85
+ * @param {string} text The failure text (`error` on a PostToolUseFailure payload).
86
+ * @returns {boolean} true when the failure came from the tool chain, not the program.
87
+ */
88
+ export function isToolChainRefusal(text) {
89
+ if (typeof text !== 'string' || !text) return false;
90
+ return REFUSAL_SENTINELS.some((re) => re.test(text));
91
+ }
92
+
93
+ /**
94
+ * Should error-recall run for this failed tool call?
95
+ *
96
+ * Three reasons to stay silent, each for a different kind of "this is not a program
97
+ * failure I can recall anything useful about":
98
+ *
99
+ * - `is_interrupt` — the HOST's own flag for "the user stopped it". A cancelled
100
+ * command has no failure to explain, and this is the one discriminator that comes
101
+ * from the host rather than from pattern-matching its text.
102
+ * - a tool-chain refusal (above).
103
+ * - nothing to read: an empty or near-empty error string cannot produce query terms,
104
+ * and the 10-character floor matches the one PostToolUse already applies to
105
+ * `tool_response` so the two entry points do not disagree about what "no output"
106
+ * means.
107
+ *
108
+ * @param {{error?: string, is_interrupt?: boolean}} payload
109
+ * @returns {{ok: boolean, reason?: string}}
110
+ */
111
+ export function shouldRecallOnFailure(payload) {
112
+ if (payload?.is_interrupt === true) return { ok: false, reason: 'interrupt' };
113
+ const text = typeof payload?.error === 'string' ? payload.error : '';
114
+ if (text.length < 10) return { ok: false, reason: 'empty' };
115
+ if (isToolChainRefusal(text)) return { ok: false, reason: 'refusal' };
116
+ return { ok: true };
117
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.78.0",
3
+ "version": "3.80.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.78.0",
9
+ "version": "3.80.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.78.0",
3
+ "version": "3.80.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",
@@ -92,6 +92,7 @@
92
92
  "lib/keyctx-marker.mjs",
93
93
  "lib/inject-search-core.mjs",
94
94
  "lib/error-recall-core.mjs",
95
+ "lib/tool-refusal.mjs",
95
96
  "lib/relevance-floor.mjs",
96
97
  "lib/get-core.mjs",
97
98
  "lib/browse-core.mjs",
@@ -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
package/source-files.mjs CHANGED
@@ -153,6 +153,10 @@ export const SOURCE_FILES = [
153
153
  // injection) and by benchmark/error-recall-suite.mjs (offline calibration) — the
154
154
  // hook is the one that breaks on a missing manifest entry.
155
155
  'lib/error-recall-core.mjs',
156
+ // D#170: the PostToolUseFailure gate. hook.mjs imports it on the failure path and
157
+ // benchmark/error-recall-live-replay.mjs scores the SAME predicate, so a missing
158
+ // registration would ship a hook that cannot load its own filter.
159
+ 'lib/tool-refusal.mjs',
156
160
  // Corpus-size ramp for absolute relevance floors. Imported by BOTH floor-bearing
157
161
  // injection faces: scripts/user-prompt-search.js (standalone hook) and
158
162
  // lib/error-recall-core.mjs. Missing here = UserPromptSubmit dies on auto-update.