claude-mem-lite 3.78.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/bash-utils.mjs +117 -2
- package/hook.mjs +124 -3
- package/hooks/hooks.json +12 -0
- package/install.mjs +14 -0
- package/lib/error-recall-core.mjs +155 -9
- package/lib/tool-refusal.mjs +117 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/source-files.mjs +4 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
@@ -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}
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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],
|
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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: [],
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.79.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.79.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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
|
"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",
|
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.
|