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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -1
- package/bash-utils.mjs +117 -2
- package/hook.mjs +137 -37
- package/hooks/hooks.json +12 -0
- package/install.mjs +14 -0
- package/lib/citation-tracker.mjs +27 -6
- package/lib/error-recall-core.mjs +402 -0
- package/lib/inject-search-core.mjs +10 -2
- package/lib/relevance-floor.mjs +136 -0
- package/lib/tool-refusal.mjs +117 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -1
- package/schema.mjs +16 -1
- package/scripts/user-prompt-search.js +9 -96
- package/source-files.mjs +12 -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
|
@@ -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}
|
|
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
|
@@ -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
|
-
|
|
27
|
+
extractFilePaths, isRelatedToEpisode,
|
|
28
28
|
makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
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:
|
|
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(
|
|
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],
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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 {
|