claude-mem-lite 3.77.0 → 3.79.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.77.0",
13
+ "version": "3.79.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.77.0",
3
+ "version": "3.79.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
@@ -814,7 +814,10 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
814
814
  | `CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP` | Looser floor for follow-up prompts inside an already-injected session. | `5e-6` |
815
815
  | `CLAUDE_MEM_UPS_OR_BM25_MIN` | Floor applied to the OR-fallback arm (looser query, needs a stricter floor). | `30` |
816
816
  | `CLAUDE_MEM_UPS_TOP_MIN` | Minimum score for the top hit; `0` disables (useful on tiny test corpora). | `50` |
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. | `584` |
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
+ | `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)_ |
818
821
  | `CLAUDE_MEM_UPS_IDENTIFIER_BYPASS` | `0` disables the bypass that lets an exact identifier match skip the score floors. | _(on)_ |
819
822
  | `CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT` | How many past-prompt rows the fallback arm may return. | `1` |
820
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
@@ -24,9 +24,9 @@ import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statS
24
24
  import { homedir } from 'os';
25
25
  import {
26
26
  inferProject, detectBashSignificance,
27
- planErrorRecall, extractFilePaths, isRelatedToEpisode,
27
+ extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
- OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
29
+ formatErrorRecallHints,
30
30
  MAX_HOOK_STDIN_BYTES,
31
31
  } from './utils.mjs';
32
32
  import {
@@ -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';
@@ -75,7 +76,8 @@ import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
75
76
  import { detectMemOverride } from './lib/mem-override.mjs';
76
77
  import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
77
78
  import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
78
- import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
79
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
80
+ import { selectErrorRecall } from './lib/error-recall-core.mjs';
79
81
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
80
82
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
81
83
  import { handleLLMOptimize } from './hook-optimize.mjs';
@@ -482,7 +484,18 @@ async function handlePostToolUse() {
482
484
 
483
485
  // ─── Error-Triggered Recall (Tier 2 G) ─────────────────────────────────────
484
486
 
485
- 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';
486
499
  try {
487
500
  const project = inferProject();
488
501
 
@@ -494,44 +507,22 @@ function triggerErrorRecall(db, toolInput, response) {
494
507
  // query degraded to ['npm','run','build'] — the command's topic, not the failure.
495
508
  // planErrorRecall still returns null when nothing usable survives (empty output, or
496
509
  // only stop words), and then we stay silent rather than query the command's topic.
497
- const cmd = toolInput.command || '';
498
- const plan = planErrorRecall(cmd, response);
499
- if (!plan) return;
500
-
501
- // FTS5 OR query for broader recall
502
- const ftsQuery = plan.terms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
503
- if (!ftsQuery) return;
504
-
505
- const nowR = Date.now();
506
- const rows = db.prepare(`
507
- SELECT o.id, o.type, o.title, o.lesson_learned
508
- FROM observations_fts
509
- JOIN observations o ON observations_fts.rowid = o.id
510
- WHERE observations_fts MATCH ? AND o.project = ?
511
- -- Live-row invariant, same as every other model-facing retrieval path
512
- -- (hook-context obsPool/fallbackObs/keyObs, hook-memory, search-engine,
513
- -- recent/search/timeline/recall-core, pre-tool-recall, user-prompt-search).
514
- -- This surface INLINES rows[0].lesson_learned into the model context, so an
515
- -- unfiltered SELECT handed a retracted lesson to the agent verbatim while its
516
- -- correction trailed as a bare pointer. compressed_into is filtered too, not
517
- -- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
518
- -- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
519
- -- so that pointer would resolve to nothing.
520
- AND ${liveObsFilterSql('o')}
521
- AND ${notLowSignalTitleClause('o')}
522
- -- Decay via the shared core (P2-11): the M-1 MAX(0,…) age clamp lives there.
523
- -- Fixed 14d half-life (error recency matters more than obs type here).
524
- ORDER BY ${OBS_BM25}
525
- * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
526
- LIMIT 3
527
- `).all(ftsQuery, project, nowR);
510
+ // Selection lives in lib/error-recall-core.mjs so the offline calibration suite
511
+ // scores THIS statement rather than a re-typed lookalike. null ⇒ do not inject.
512
+ const selected = selectErrorRecall(db, {
513
+ cmd: toolInput.command || '',
514
+ response,
515
+ project,
516
+ });
517
+ if (!selected) return;
518
+ const rows = selected.rows;
528
519
 
529
520
  const out = formatErrorRecallHints(rows);
530
521
  if (out) {
531
522
  // G13: this surface feeds the citation denominator but had zero metering —
532
523
  // the G8 gate change (isError→isHardError) could not be volume-verified
533
524
  // from metrics. Counter only; no latency (query is bundled in the hook).
534
- recordMetric(join(RUNTIME_DIR, '..'), { event: 'error_recall', returned: rows.length });
525
+ recordMetric(join(RUNTIME_DIR, '..'), { event: metricEvent, returned: rows.length });
535
526
  // MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
536
527
  // a raw multi-line write corrupts a co-emitted episode-flush receipt.
537
528
  // The follow-up correction (2026-08-17): "two separate JSON lines each parse
@@ -539,11 +530,118 @@ function triggerErrorRecall(db, toolInput, response) {
539
530
  // flushEpisode in one handlePostToolUse, and two documents make the parser
540
531
  // fall back to plain text — which the renderer drops entirely for
541
532
  // PostToolUse. Both receipts vanished. Queue; one envelope is written at exit.
542
- queueHookContext('PostToolUse', out);
533
+ queueHookContext(eventName, out);
543
534
  }
544
535
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
545
536
  }
546
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
+
547
645
  // ─── Stop Handler ───────────────────────────────────────────────────────────
548
646
 
549
647
  async function handleStop() {
@@ -2004,6 +2102,8 @@ function normalizeToolResponse(toolResponse) {
2004
2102
  try {
2005
2103
  switch (event) {
2006
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;
2007
2107
  case 'session-start': await handleSessionStart(); break;
2008
2108
  case 'pre-compact': await handlePreCompactDispatch(); break;
2009
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],
@@ -900,16 +900,35 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
900
900
  // literal +1: a FIRST resolution counts the obs into the denominator (1); a
901
901
  // cross-turn LATE upgrade of an already-resolved obs must NOT re-count it (0), else
902
902
  // cite-rate reads N/2 for a single injected-then-cited obs instead of N/1.
903
+ // v46 (D#159): stamp decay_seen_at_first_cite on the FIRST citation only.
904
+ //
905
+ // `cited_count = 0` is evaluated against the row's PRE-UPDATE state (SQLite reads
906
+ // the old values on the right-hand side of every SET), so it identifies the first
907
+ // promote even though the same statement increments the counter. Once set, the
908
+ // CASE re-writes the column to itself and later citations cannot move it.
909
+ //
910
+ // The stamped value INCLUDES this resolution (`decay_seen_count + @seenInc`), i.e.
911
+ // "this memory was cited on the Nth time the decay loop saw it" — so 1 means cited
912
+ // immediately, and a large N means it was injected-and-ignored N-1 times first.
913
+ // That is exactly the quantity a future "stop injecting after K silent decays"
914
+ // gate must be validated against.
915
+ //
916
+ // NAMED parameters: this statement now binds the same value twice, and a positional
917
+ // list would silently renumber if a clause were ever reordered.
903
918
  const updatePromote = db.prepare(`
904
919
  UPDATE observations
905
- SET importance = MIN(?, COALESCE(importance, 1) + 1),
920
+ SET importance = MIN(@cap, COALESCE(importance, 1) + 1),
906
921
  cited_count = cited_count + 1,
907
922
  uncited_streak = 0,
908
923
  demoted_at = NULL,
909
- last_decided_session_id = ?,
910
- last_cited_session_id = ?,
911
- decay_seen_count = decay_seen_count + ?
912
- WHERE id = ?
924
+ last_decided_session_id = @session,
925
+ last_cited_session_id = @session,
926
+ decay_seen_count = decay_seen_count + @seenInc,
927
+ decay_seen_at_first_cite = CASE
928
+ WHEN COALESCE(cited_count, 0) = 0 THEN decay_seen_count + @seenInc
929
+ ELSE decay_seen_at_first_cite
930
+ END
931
+ WHERE id = @id
913
932
  `);
914
933
  const updateStreakOnly = db.prepare(`
915
934
  UPDATE observations
@@ -959,7 +978,9 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
959
978
  // obs already in the injected denominator, so re-counting it would inflate both
960
979
  // decay_seen_count and the funnel's injected_n (cite-rate would read N/2, not N/1).
961
980
  const firstResolution = !decidedThisSession;
962
- updatePromote.run(IMPORTANCE_CAP, sessionId, sessionId, firstResolution ? 1 : 0, id);
981
+ updatePromote.run({
982
+ cap: IMPORTANCE_CAP, session: sessionId, seenInc: firstResolution ? 1 : 0, id,
983
+ });
963
984
  promoted++;
964
985
  if (firstResolution) touched++;
965
986
  } else {