claude-mem-lite 3.78.0 → 3.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/citation-tracker.mjs +62 -6
- package/lib/cite-back-hint.mjs +6 -1
- package/lib/error-recall-core.mjs +155 -9
- package/lib/hook-stdout.mjs +84 -29
- package/lib/tool-refusal.mjs +117 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/scripts/post-tool-recall.js +7 -4
- package/scripts/pre-agent-inject.js +16 -3
- package/scripts/pre-skill-bridge.js +5 -7
- package/scripts/pre-tool-recall.js +31 -27
- 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.80.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.80.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -816,6 +816,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
816
816
|
| `CLAUDE_MEM_UPS_TOP_MIN` | Minimum score for the top hit; `0` disables (useful on tiny test corpora). | `50` |
|
|
817
817
|
| `CLAUDE_MEM_UPS_FLOOR_REF_CORPUS` | Reference corpus size the score floors are normalized against, so a fresh install with few rows is not silently gated to zero injections. Shared by every floor-bearing surface, including error-recall below. | `584` |
|
|
818
818
|
| `CLAUDE_MEM_ERROR_RECALL_BM25_MIN` | Relevance floor for the error-recall surface (memories injected after a failed Bash command). **Off by default.** Setting it to `10.5` (the calibrated value) makes the surface stay silent when its best-matching memory is not actually about the failure — the whole set is dropped, never trimmed row-by-row. **It is a real trade, not a free win:** measured on a live database at that threshold, injections fall ~37% and ~39% of firings go silent, concentrated in projects with few memories. Off by default because nothing shows the dropped rows were noise. Explore with `node benchmark/error-recall-suite.mjs --sweep`. | `0` (off) |
|
|
819
|
+
| `CLAUDE_MEM_ERROR_RECALL_RERANK` | `off` restores the flat keyword ordering of the error-recall surface. **On by default**, and unlike the floor above it removes nothing: memories that share only the failed command's vocabulary are demoted below memories that mention the failure itself, and when a project has none of the latter the result is unchanged. Measured on a live database over 52 real failing commands × 15 projects: the lead memory matched no error term in 42.3% of firings before, 21.5% after, with the injected row count identical. | _(on)_ |
|
|
820
|
+
| `CLAUDE_MEM_ERROR_RECALL_ON_FAILURE` | `off` stops the plugin from recalling memories when a Bash command **fails at the host level**. On by default. Claude Code delivers failed tool calls to a separate `PostToolUseFailure` hook event, so before this the surface only ever saw commands that exited `0` while printing error-ish text — a genuinely failing build recalled nothing. Denials from your own guardrails (sandbox, policy hooks, declined permission prompts) and commands you interrupted are never recalled for. | _(on)_ |
|
|
819
821
|
| `CLAUDE_MEM_UPS_IDENTIFIER_BYPASS` | `0` disables the bypass that lets an exact identifier match skip the score floors. | _(on)_ |
|
|
820
822
|
| `CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT` | How many past-prompt rows the fallback arm may return. | `1` |
|
|
821
823
|
| `MEM_COVERAGE_THRESHOLD` | Fraction of query terms a memory must cover to qualify (∈ [0,1]). | `0.4` |
|
package/bash-utils.mjs
CHANGED
|
@@ -120,6 +120,58 @@ const ERROR_STOP_WORDS = new Set([
|
|
|
120
120
|
const ERROR_LINE_RE = /error|fail|exception|cannot|not found|undefined|null/i;
|
|
121
121
|
const ERROR_RECALL_MAX_TERMS = 6;
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The token that NAMES the failure: an exception class (`ModuleNotFoundError`,
|
|
125
|
+
* `JSONDecodeError`), an errno-style code (`ENOENT`, `EACCES`), a signal (`SIGSEGV`),
|
|
126
|
+
* or Rust's `panicked`.
|
|
127
|
+
*
|
|
128
|
+
* WHY THIS EXISTS (D#167). The line scan below takes the FIRST 3 matching lines and the
|
|
129
|
+
* first 5 tokens of each, and a real failure puts its banner first and its name last:
|
|
130
|
+
*
|
|
131
|
+
* Traceback (most recent call last): <- matches, contributes `traceback most recent`
|
|
132
|
+
* File "<string>", line 3, in <module>
|
|
133
|
+
* ModuleNotFoundError: No module named 'x' <- the only line that says WHAT broke
|
|
134
|
+
*
|
|
135
|
+
* Measured over 52 real failing commands pulled from 1110 transcripts: 28 of them name
|
|
136
|
+
* their failure this way, and in 25 of those 28 (89.3%) THE NAME NEVER REACHED THE
|
|
137
|
+
* QUERY. The six-term budget went to the banner (`traceback,most,recent`) and to path
|
|
138
|
+
* fragments from the command (`mnt,data_ssd,dev`). Downstream, 39.2% of injected rows
|
|
139
|
+
* (764/1947 over 8 projects x 52 shapes on the live DB) matched no error term at all —
|
|
140
|
+
* they were admitted on command vocabulary alone — and for 42.3% of firing cases that
|
|
141
|
+
* was true of the TOP-1 row, whose lesson_learned is inlined into the model's context.
|
|
142
|
+
*
|
|
143
|
+
* This is deliberately a POSITIVE pattern for the signal, not a stop-list for the noise:
|
|
144
|
+
* a stop-list of boilerplate ("traceback", "most", "recent", "call", "last", …) grows
|
|
145
|
+
* once per runtime forever, which is the enumeration the D#136 docblock below warns
|
|
146
|
+
* against. Matching the shape of an exception name instead covers runtimes nobody has
|
|
147
|
+
* seen yet, and when nothing matches, extraction is byte-identical to before.
|
|
148
|
+
*
|
|
149
|
+
* Note `E[A-Z]{3,}` also matches the literal `ERROR`; it is dropped by ERROR_STOP_WORDS
|
|
150
|
+
* on the next line, and that interaction is load-bearing rather than incidental.
|
|
151
|
+
*/
|
|
152
|
+
const ERROR_NAMER_RE = /\b(?:[A-Z][A-Za-z]*(?:Error|Exception)|E[A-Z]{3,}|SIG[A-Z]{2,}|panicked)\b/g;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* How many names may jump the queue. Each one displaces a scanned term (the 6-term cap
|
|
156
|
+
* is unchanged), so this trades tail tokens for the identifier.
|
|
157
|
+
*
|
|
158
|
+
* ONE, and that is measured rather than argued. Swept on the live DB over the same 52
|
|
159
|
+
* shapes x 15 projects, reading the share of injected rows that match no error term and
|
|
160
|
+
* the share of cases whose top row does:
|
|
161
|
+
*
|
|
162
|
+
* MAX=1 434 rows (22.4%) 154 cases (21.5%)
|
|
163
|
+
* MAX=2 442 rows (22.8%) 157 cases (22.0%)
|
|
164
|
+
* MAX=3 445 rows (22.9%) 157 cases (22.0%)
|
|
165
|
+
*
|
|
166
|
+
* The second name never pays: 96.4% of shapes already have their failure named by the
|
|
167
|
+
* first, so slots 2 and 3 buy a duplicate or a second-order name while still evicting a
|
|
168
|
+
* scanned term — and the evicted tail is often the most specific token in the list (see
|
|
169
|
+
* the golden case in tests/error-recall-gate.test.mjs, where a filename is lost). This
|
|
170
|
+
* shipped at 2 on the reasoning that a chained Python traceback has two real names; the
|
|
171
|
+
* sweep says that reasoning does not survive contact with the sample.
|
|
172
|
+
*/
|
|
173
|
+
const ERROR_NAMER_MAX = 1;
|
|
174
|
+
|
|
123
175
|
/**
|
|
124
176
|
* Split a failed command + its output into command-derived and error-derived terms.
|
|
125
177
|
* Shared by extractErrorKeywords (merged view, unchanged contract) and
|
|
@@ -137,6 +189,19 @@ function collectErrorTerms(cmd, response) {
|
|
|
137
189
|
if (!ERROR_STOP_WORDS.has(lw) && !seen.has(lw)) { seen.add(lw); cmdWords.push(lw); }
|
|
138
190
|
}
|
|
139
191
|
const errWords = [];
|
|
192
|
+
// The failure's NAME goes in first — see ERROR_NAMER_RE for the measurement that put
|
|
193
|
+
// it here. Prepending rather than re-ordering the LINE scan is deliberate: sorting
|
|
194
|
+
// namer-bearing lines to the front also promotes their verbose neighbours, which costs
|
|
195
|
+
// real terms (on npm's ENOENT output it evicts `syscall` in favour of `such`/`file`
|
|
196
|
+
// from the long "no such file or directory" line). Prepending only ever displaces the
|
|
197
|
+
// TAIL of what the scan would have produced.
|
|
198
|
+
for (const m of String(response || '').match(ERROR_NAMER_RE) || []) {
|
|
199
|
+
if (errWords.length >= ERROR_NAMER_MAX) break;
|
|
200
|
+
const lt = m.toLowerCase();
|
|
201
|
+
if (ERROR_STOP_WORDS.has(lt) || seen.has(lt)) continue;
|
|
202
|
+
seen.add(lt);
|
|
203
|
+
errWords.push(lt);
|
|
204
|
+
}
|
|
140
205
|
// The line filter is the TRIGGER's pattern list OR'd with the prose one. Anything
|
|
141
206
|
// that made detectBashSignificance call this a hard error is, by construction, also
|
|
142
207
|
// something we will extract terms from — which closes the "trigger fired, extractor
|
|
@@ -163,6 +228,40 @@ function collectErrorTerms(cmd, response) {
|
|
|
163
228
|
return { cmdWords, errWords };
|
|
164
229
|
}
|
|
165
230
|
|
|
231
|
+
/**
|
|
232
|
+
* THE CAP TRUNCATES BY POSITION, AND THAT WAS TESTED AGAINST THE ALTERNATIVE (D#169).
|
|
233
|
+
*
|
|
234
|
+
* The alternative looked obviously right. Tokens are scanned line by line, prose comes
|
|
235
|
+
* before identifiers within a line, so `AssertionError: expected observation-write.mjs
|
|
236
|
+
* to be defined` yields `assertionerror, expected, observation-write.mjs` — and whatever
|
|
237
|
+
* the cap removes comes off that end. On a real shape, `npx vitest run
|
|
238
|
+
* tests/scope-label.test.mjs` failing with an AssertionError kept `fail, tests` and
|
|
239
|
+
* dropped `scope-label.test.mjs`: a filename traded for a word in thousands of memories.
|
|
240
|
+
* Keeping identifier-shaped tokens (`[._-]`) first should fix that.
|
|
241
|
+
*
|
|
242
|
+
* Measured on the live DB, same 58 real shapes x 15 projects either way:
|
|
243
|
+
*
|
|
244
|
+
* cmd-only rows cmd-only at TOP-1
|
|
245
|
+
* positional cap (shipped) 493/2184 22.6% 171/801 21.3%
|
|
246
|
+
* identifier-first cap 749/2093 35.8% 259/775 33.4%
|
|
247
|
+
*
|
|
248
|
+
* Thirteen points WORSE on both, and the mechanism is worth keeping written down:
|
|
249
|
+
* `[._-]` conflates "discriminative" with "unique to this invocation". The tokens it
|
|
250
|
+
* promotes are this run's own paths and filenames — `d167-measure.mjs`,
|
|
251
|
+
* `s-default.json` — whose IDF is so high they match NOTHING in the corpus, and the
|
|
252
|
+
* tokens it evicts to make room are `enoent`, `syscall`: low-IDF, but present in the
|
|
253
|
+
* memories that actually explain the failure. A row then survives on command vocabulary
|
|
254
|
+
* alone, which is the D#167 defect re-created from the other side.
|
|
255
|
+
*
|
|
256
|
+
* (A variant that promoted identifiers only among ERROR words, sparing command words,
|
|
257
|
+
* measured byte-identical: command words sit at low indices, so a positional tiebreak
|
|
258
|
+
* already keeps them and promotion never decides their fate.)
|
|
259
|
+
*
|
|
260
|
+
* The premise "the evicted tail is systematically the good part" is therefore FALSE.
|
|
261
|
+
* The tail is often hapax. Any future attempt here needs real document frequencies, not
|
|
262
|
+
* a shape heuristic — and planErrorRecall is pure, with no corpus to count against.
|
|
263
|
+
*/
|
|
264
|
+
|
|
166
265
|
/**
|
|
167
266
|
* Extract discriminative keywords from a failed command and its error output.
|
|
168
267
|
* Filters out common stop words to produce useful FTS5 search terms.
|
|
@@ -172,6 +271,8 @@ function collectErrorTerms(cmd, response) {
|
|
|
172
271
|
*/
|
|
173
272
|
export function extractErrorKeywords(cmd, response) {
|
|
174
273
|
const { cmdWords, errWords } = collectErrorTerms(cmd, response);
|
|
274
|
+
// Same cap rule as planErrorRecall — the two must not drift into dialects, which is
|
|
275
|
+
// pinned by a test asserting they emit identical lists.
|
|
175
276
|
const result = [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS);
|
|
176
277
|
return result.length >= 1 ? result : null;
|
|
177
278
|
}
|
|
@@ -231,12 +332,26 @@ export function extractErrorKeywords(cmd, response) {
|
|
|
231
332
|
*
|
|
232
333
|
* @param {string} cmd The command that was executed
|
|
233
334
|
* @param {string} response The error output text
|
|
234
|
-
* @returns {{terms: string[]}|null}
|
|
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],
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -20,9 +20,65 @@ import { readTranscriptEntries } from './transcript-scan.mjs';
|
|
|
20
20
|
import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
|
|
21
21
|
|
|
22
22
|
import { DAY_MS } from './time-constants.mjs';
|
|
23
|
+
/**
|
|
24
|
+
* The ONE caliber for an observation id appearing in text. Bounded to 1-7 digits to
|
|
25
|
+
* skip URL fragments, markdown anchors, etc.
|
|
26
|
+
*
|
|
27
|
+
* Exported because the offline benchmarks re-derive production's numbers from the same
|
|
28
|
+
* transcripts, and each had hand-copied its own: `benchmark/cite-recall.mjs` scanned
|
|
29
|
+
* citations with `{2,6}` while its OWN injected denominator used `{1,7}`, and
|
|
30
|
+
* `efficacy-observational.mjs` / `adoption-replay.mjs` had a third and fourth caliber
|
|
31
|
+
* (`{2,6}` / `{2,7}`). A denominator wider than its numerator counts an id as
|
|
32
|
+
* injected-never-cited that the numerator structurally cannot see, which biases the
|
|
33
|
+
* measured cite-rate DOWN — and nothing errors when it happens.
|
|
34
|
+
*
|
|
35
|
+
* Measured live impact at the time this was unified (2026-08-24, 3692 rows, ids 1..10834):
|
|
36
|
+
* exactly ZERO. The only ids outside `{2,6}` are four 1-digit rows, and all four have
|
|
37
|
+
* injection_count = 0, so they never entered a denominator; there are no 7-digit ids.
|
|
38
|
+
* This is a latent-class fix, not a correction to any published number — do not
|
|
39
|
+
* re-attribute past readings to it.
|
|
40
|
+
*/
|
|
41
|
+
export const OBS_ID_DIGITS = '\\d{1,7}';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A fresh global matcher for a bare `#NN` citation.
|
|
45
|
+
*
|
|
46
|
+
* Returned fresh per call rather than shared: a `/g` regex carries `lastIndex`, so one
|
|
47
|
+
* exported instance reused by two scanners silently starts mid-string in whichever one
|
|
48
|
+
* runs second.
|
|
49
|
+
*/
|
|
50
|
+
export function citationIdRe() {
|
|
51
|
+
return new RegExp(`#(${OBS_ID_DIGITS})\\b`, 'g');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Caliber for an id scraped from UNANCHORED text that is then treated as an INJECTED
|
|
56
|
+
* (denominator) set.
|
|
57
|
+
*
|
|
58
|
+
* `citationIdRe()` above is a NUMERATOR caliber. On the numerator side a spurious `#1`
|
|
59
|
+
* costs nothing, because a cited id only counts once it intersects an injected set that
|
|
60
|
+
* WAS anchored — every injected-side extractor in this module matches a row shape
|
|
61
|
+
* (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`). On the
|
|
62
|
+
* denominator side nothing anchors it, so a prose `#1` is a false positive by
|
|
63
|
+
* construction — it inflates "injected, never cited" and biases the measured rate DOWN.
|
|
64
|
+
*
|
|
65
|
+
* The v3.80.0 pre-tag review caught this concretely: pointing
|
|
66
|
+
* `benchmark/adoption-replay.mjs` at `citationIdRe()` pulled `#1` and `#2` out of a
|
|
67
|
+
* subagent prompt discussing fixture rows ("with `#1` superseded by `#2` …") straight into
|
|
68
|
+
* `injectedIds`, on a real transcript. Excluding 1-digit ids costs nothing measurable —
|
|
69
|
+
* the four 1-digit rows in the live corpus have `injection_count = 0` — and removes the
|
|
70
|
+
* commonest prose collision.
|
|
71
|
+
*
|
|
72
|
+
* This is a stopgap for a caliber symptom, NOT a fix for the cause. The cause is that
|
|
73
|
+
* adoption-replay scrapes a whole prompt where it should match injected ROWS, the way
|
|
74
|
+
* production does. Do not reach for this anywhere else; anchor instead.
|
|
75
|
+
*/
|
|
76
|
+
export function unanchoredInjectedIdRe() {
|
|
77
|
+
return new RegExp('#(\\d{2,7})\\b', 'g');
|
|
78
|
+
}
|
|
79
|
+
|
|
23
80
|
// `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
|
|
24
|
-
|
|
25
|
-
const CITATION_RE = /#(\d{1,7})\b/g;
|
|
81
|
+
const CITATION_RE = citationIdRe();
|
|
26
82
|
|
|
27
83
|
/**
|
|
28
84
|
* Parse a Claude Code transcript .jsonl and extract unique observation IDs
|
|
@@ -147,7 +203,7 @@ export function bumpCitationAccess(db, ids, project) {
|
|
|
147
203
|
// Matches a pre-tool-recall / error-recall lesson line: ` #NN [type] body...`.
|
|
148
204
|
// Bounded type list mirrors observations.type CHECK + the events table's allowed
|
|
149
205
|
// event_type values these surfaces can emit.
|
|
150
|
-
const INJECTED_RE =
|
|
206
|
+
const INJECTED_RE = new RegExp(`#(${OBS_ID_DIGITS})\\s+\\[(bugfix|decision|change|discovery|feature|refactor|lesson)\\]`, 'g');
|
|
151
207
|
// Line-anchored variant: a genuine injected ROW begins (after its short indent) with
|
|
152
208
|
// `#NN [type]`. pre-tool-recall AND error-recall inline a lesson_learned body into the
|
|
153
209
|
// row; a body that quotes another obs ("same as #1234 [decision]") must NOT count as
|
|
@@ -217,7 +273,7 @@ function eachHookAttachment(transcriptPath, fn, opts = {}) {
|
|
|
217
273
|
// like "see (#999)" doesn't pollute the injected set (would streak-uncite an
|
|
218
274
|
// obs we never actually displayed as a top-level entry).
|
|
219
275
|
const UPS_LINE_PREFIX = '- [';
|
|
220
|
-
const UPS_ID_RE =
|
|
276
|
+
const UPS_ID_RE = new RegExp(`\\(#(${OBS_ID_DIGITS})\\)`, 'g');
|
|
221
277
|
// Quote-normalized (see normalizeHookCommand): real recorded command is
|
|
222
278
|
// `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
|
|
223
279
|
const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
|
|
@@ -230,7 +286,7 @@ const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
|
|
|
230
286
|
const FYI_HEADER = '[mem] FYI — Related memories';
|
|
231
287
|
// Anchored at line start so `P#NN` past-question rows (user_prompts, different id
|
|
232
288
|
// space) and any `#NN` inside lesson text are NOT matched.
|
|
233
|
-
const FYI_LINE_ID_RE =
|
|
289
|
+
const FYI_LINE_ID_RE = new RegExp(`^#(${OBS_ID_DIGITS})\\s`);
|
|
234
290
|
|
|
235
291
|
/**
|
|
236
292
|
* The injection FACES memory can reach the model through, as stored in
|
|
@@ -569,7 +625,7 @@ export function unionSurfaces(bySurface) {
|
|
|
569
625
|
const SUBAGENT_INJECT_MARKER = /surfaced by your operator's claude-mem-lite/;
|
|
570
626
|
// Row-anchored to the `#NN — ` tag so a #NN quoted inside the lesson body does NOT enter
|
|
571
627
|
// the injected set — same discipline as INJECTED_ROW_RE for the attachment surfaces.
|
|
572
|
-
const SUBAGENT_INJECT_ID_RE =
|
|
628
|
+
const SUBAGENT_INJECT_ID_RE = new RegExp(`^\\s{0,4}#(${OBS_ID_DIGITS})\\s+—`);
|
|
573
629
|
|
|
574
630
|
/**
|
|
575
631
|
* Extract observation ids injected into a subagent's PROMPT by pre-agent-inject.js
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import { basename, join } from 'path';
|
|
|
15
15
|
import { readFileSync } from 'fs';
|
|
16
16
|
import { readTranscriptEntries } from './transcript-scan.mjs';
|
|
17
17
|
import { EDIT_TOOLS } from '../utils.mjs';
|
|
18
|
+
// One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
|
|
19
|
+
// is acyclic.
|
|
20
|
+
import { citationIdRe } from './citation-tracker.mjs';
|
|
18
21
|
|
|
19
22
|
const MAX_FILES = 2;
|
|
20
23
|
|
|
@@ -269,7 +272,9 @@ export function loadCiteBackForEpisode(episode, runtimeDir) {
|
|
|
269
272
|
// #NN. The Stop handler unions these into the cited set passed to
|
|
270
273
|
// applyCitationDecay (lib/citation-tracker.mjs), so acting on a lesson promotes
|
|
271
274
|
// it and lifts the project's adoption rate. Returns an empty set on missing path.
|
|
272
|
-
|
|
275
|
+
// The ids collected here are unioned into the SAME cited set applyCitationDecay reads,
|
|
276
|
+
// so this caliber must be the extractor's own — imported, not a sixth hand-copy.
|
|
277
|
+
const CITE_BACK_ID_RE = citationIdRe();
|
|
273
278
|
|
|
274
279
|
export function extractCiteBackSignals(transcriptPath) {
|
|
275
280
|
const ids = new Set();
|
|
@@ -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
|
}
|
package/lib/hook-stdout.mjs
CHANGED
|
@@ -27,6 +27,40 @@
|
|
|
27
27
|
let parts = [];
|
|
28
28
|
let queuedEvent = null;
|
|
29
29
|
let systemParts = [];
|
|
30
|
+
let queuedInput = null;
|
|
31
|
+
|
|
32
|
+
/** Emit the noisy drop notice. stderr is safe: the host never parses it as the envelope. */
|
|
33
|
+
function warnDrop(deps, msg) {
|
|
34
|
+
const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
|
|
35
|
+
warn(msg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Claim this process's single hookEventName, or refuse the contribution.
|
|
40
|
+
*
|
|
41
|
+
* Mixed event names cannot be merged — Claude Code throws when
|
|
42
|
+
* hookSpecificOutput.hookEventName does not match the event it dispatched.
|
|
43
|
+
* In practice one process serves one event; keep the first and drop the
|
|
44
|
+
* stragglers rather than emit an envelope the host rejects outright.
|
|
45
|
+
*
|
|
46
|
+
* The drop is NOISY on purpose. It is unreachable today (all call sites are
|
|
47
|
+
* event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
|
|
48
|
+
* so a future caller that omits the argument would both mis-tag its receipt and
|
|
49
|
+
* have it swallowed without a trace. Silently vanishing work is this repo's
|
|
50
|
+
* most-repeated defect class.
|
|
51
|
+
*
|
|
52
|
+
* @returns {boolean} true when the caller may proceed.
|
|
53
|
+
*/
|
|
54
|
+
function claimEvent(hookEventName, what, deps) {
|
|
55
|
+
if (queuedEvent && queuedEvent !== hookEventName) {
|
|
56
|
+
warnDrop(deps, `[claude-mem-lite] hook-stdout: dropped a ${hookEventName} ${what} — this process `
|
|
57
|
+
+ `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
|
|
58
|
+
+ 'This is a wiring bug: the contribution is lost.\n');
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
queuedEvent = hookEventName;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
30
64
|
|
|
31
65
|
/**
|
|
32
66
|
* Queue a contribution to this process's single stdout envelope.
|
|
@@ -40,26 +74,40 @@ export function queueHookContext(hookEventName, text, deps = {}) {
|
|
|
40
74
|
if (!hookEventName) return;
|
|
41
75
|
const body = String(text ?? '').trim();
|
|
42
76
|
if (!body) return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
if (!claimEvent(hookEventName, 'contribution', deps)) return;
|
|
78
|
+
parts.push(body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Queue a `hookSpecificOutput.updatedInput` — a REPLACEMENT of the tool's input,
|
|
83
|
+
* not a contribution to it. PreToolUse is the only event whose schema carries one
|
|
84
|
+
* (2.1.241 bundle: `{hookEventName: "PreToolUse", permissionDecision?,
|
|
85
|
+
* permissionDecisionReason?, updatedInput?, additionalContext?}`), and that same
|
|
86
|
+
* schema is why this belongs here rather than in its own writer: a mutation and a
|
|
87
|
+
* context line may ride ONE envelope, so a hook that grew both would otherwise
|
|
88
|
+
* emit two documents and lose both (the v3.70.0 degradation this module exists for).
|
|
89
|
+
*
|
|
90
|
+
* FIRST writer wins, and a second is dropped noisily. Unlike additionalContext
|
|
91
|
+
* there is no merge: two callers each hand over a whole tool_input, so last-wins
|
|
92
|
+
* would silently discard the earlier mutation — the same vanishing-work shape
|
|
93
|
+
* claimEvent guards against.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} hookEventName Event name for hookSpecificOutput.
|
|
96
|
+
* @param {object} input Replacement tool_input; non-objects and null are ignored.
|
|
97
|
+
* @param {{warn?: (msg: string) => void}} [deps]
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
export function queueHookUpdatedInput(hookEventName, input, deps = {}) {
|
|
101
|
+
if (!hookEventName) return;
|
|
102
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return;
|
|
103
|
+
if (!claimEvent(hookEventName, 'updatedInput', deps)) return;
|
|
104
|
+
if (queuedInput) {
|
|
105
|
+
warnDrop(deps, '[claude-mem-lite] hook-stdout: dropped a second updatedInput — one envelope '
|
|
106
|
+
+ 'replaces the tool input exactly once, and merging two whole inputs is not defined. '
|
|
107
|
+
+ 'This is a wiring bug: the second mutation is lost.\n');
|
|
59
108
|
return;
|
|
60
109
|
}
|
|
61
|
-
|
|
62
|
-
parts.push(body);
|
|
110
|
+
queuedInput = input;
|
|
63
111
|
}
|
|
64
112
|
|
|
65
113
|
/**
|
|
@@ -93,24 +141,25 @@ export function queueHookSystemMessage(text) {
|
|
|
93
141
|
* @returns {boolean} true when an envelope was written.
|
|
94
142
|
*/
|
|
95
143
|
export function flushHookStdout(deps = {}) {
|
|
96
|
-
const hasContext = queuedEvent && parts.length > 0;
|
|
144
|
+
const hasContext = Boolean(queuedEvent) && parts.length > 0;
|
|
145
|
+
const hasInput = Boolean(queuedEvent) && queuedInput !== null;
|
|
97
146
|
const hasSystem = systemParts.length > 0;
|
|
98
|
-
if (!hasContext && !hasSystem) return false;
|
|
147
|
+
if (!hasContext && !hasInput && !hasSystem) return false;
|
|
99
148
|
const write = deps.write || ((s) => process.stdout.write(s));
|
|
100
149
|
const envelope = { suppressOutput: true };
|
|
101
150
|
if (hasSystem) envelope.systemMessage = systemParts.join('\n');
|
|
102
|
-
// Omitted entirely when there is
|
|
103
|
-
// hookSpecificOutput block, and an envelope carrying only a
|
|
104
|
-
// invent an event name to hang one on.
|
|
105
|
-
if (hasContext) {
|
|
106
|
-
envelope.hookSpecificOutput = {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
};
|
|
151
|
+
// Omitted entirely when there is nothing addressed to the host's per-event block:
|
|
152
|
+
// Stop's schema REJECTS a hookSpecificOutput block, and an envelope carrying only a
|
|
153
|
+
// user notice must not invent an event name to hang one on.
|
|
154
|
+
if (hasContext || hasInput) {
|
|
155
|
+
envelope.hookSpecificOutput = { hookEventName: queuedEvent };
|
|
156
|
+
if (hasInput) envelope.hookSpecificOutput.updatedInput = queuedInput;
|
|
157
|
+
if (hasContext) envelope.hookSpecificOutput.additionalContext = parts.join('\n\n');
|
|
110
158
|
}
|
|
111
159
|
parts = [];
|
|
112
160
|
queuedEvent = null;
|
|
113
161
|
systemParts = [];
|
|
162
|
+
queuedInput = null;
|
|
114
163
|
write(JSON.stringify(envelope) + '\n');
|
|
115
164
|
return true;
|
|
116
165
|
}
|
|
@@ -120,9 +169,15 @@ export function resetHookStdout() {
|
|
|
120
169
|
parts = [];
|
|
121
170
|
queuedEvent = null;
|
|
122
171
|
systemParts = [];
|
|
172
|
+
queuedInput = null;
|
|
123
173
|
}
|
|
124
174
|
|
|
125
175
|
/** Test seam: what is queued right now. */
|
|
126
176
|
export function peekHookStdout() {
|
|
127
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
hookEventName: queuedEvent,
|
|
179
|
+
parts: [...parts],
|
|
180
|
+
systemParts: [...systemParts],
|
|
181
|
+
updatedInput: queuedInput,
|
|
182
|
+
};
|
|
128
183
|
}
|
|
@@ -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.80.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.80.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.80.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"lib/keyctx-marker.mjs",
|
|
93
93
|
"lib/inject-search-core.mjs",
|
|
94
94
|
"lib/error-recall-core.mjs",
|
|
95
|
+
"lib/tool-refusal.mjs",
|
|
95
96
|
"lib/relevance-floor.mjs",
|
|
96
97
|
"lib/get-core.mjs",
|
|
97
98
|
"lib/browse-core.mjs",
|
|
@@ -17,6 +17,11 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
17
17
|
import { basename, join } from 'path';
|
|
18
18
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
19
19
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
20
|
+
// D#154: every envelope on this stdout goes through the one writer. This script has a
|
|
21
|
+
// single emit today, so the change buys nothing on its own — it buys that a SECOND
|
|
22
|
+
// emit added later merges instead of producing two JSON documents, which the host
|
|
23
|
+
// parses as neither (lib/hook-stdout.mjs). Import-free module over no runtime deps.
|
|
24
|
+
import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
|
|
20
25
|
|
|
21
26
|
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
22
27
|
|
|
@@ -69,10 +74,8 @@ async function main() {
|
|
|
69
74
|
for (const d of dropped.slice(0, 3)) {
|
|
70
75
|
lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
|
|
71
76
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
|
|
75
|
-
}));
|
|
77
|
+
queueHookContext('PostToolUse', lines.join('\n'));
|
|
78
|
+
flushHookStdout();
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
// No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
|
|
@@ -66,15 +66,28 @@ async function main() {
|
|
|
66
66
|
const { ensureDb } = await import('../schema.mjs');
|
|
67
67
|
const { inferProject } = await import('../utils.mjs');
|
|
68
68
|
const { buildSubagentInjection } = await import('../hook-memory.mjs');
|
|
69
|
+
// D#154: single envelope writer. Deferred to this line, not hoisted to a static
|
|
70
|
+
// import, because the file's stated contract is that the default-off path costs one
|
|
71
|
+
// env check and nothing else — the deferral filed this as "shared module vs
|
|
72
|
+
// import-free fast path, pick one", but the script already resolves that conflict
|
|
73
|
+
// three lines up: dynamic import on the enabled path only. The fast path above is
|
|
74
|
+
// untouched.
|
|
75
|
+
const { queueHookUpdatedInput, flushHookStdout } = await import('../lib/hook-stdout.mjs');
|
|
69
76
|
|
|
70
77
|
let db;
|
|
71
78
|
try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
|
|
72
79
|
try {
|
|
73
80
|
const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
|
|
74
81
|
if (updatedInput) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// Behaviour delta vs the hand-written envelope this replaced: it now carries
|
|
83
|
+
// top-level `suppressOutput: true`. Verified display-only in the 2.1.241 bundle —
|
|
84
|
+
// the field is documented "Hide stdout from transcript (default: false)" and is
|
|
85
|
+
// read at exactly one place, the transcript-render branch
|
|
86
|
+
// (`if (a6(he) && !he.suppressOutput && …)`); the updatedInput mutation is taken
|
|
87
|
+
// from the parsed hookSpecificOutput regardless. Hiding it is also the right
|
|
88
|
+
// audience call: this payload is the whole prompt echoed back, not a message.
|
|
89
|
+
queueHookUpdatedInput('PreToolUse', updatedInput);
|
|
90
|
+
flushHookStdout();
|
|
78
91
|
}
|
|
79
92
|
} catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
|
|
80
93
|
try { db.close(); } catch { /* */ }
|
|
@@ -11,6 +11,9 @@ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
|
11
11
|
// format-utils.mjs is import-free — pulling three defang helpers keeps this script
|
|
12
12
|
// inside its "lightweight standalone" budget (no heavy transitive deps).
|
|
13
13
|
import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
|
|
14
|
+
// D#154: single envelope writer. Also import-free (no runtime deps), so it stays
|
|
15
|
+
// inside this script's "lightweight standalone" budget.
|
|
16
|
+
import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
|
|
14
17
|
|
|
15
18
|
// CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
|
|
16
19
|
const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
@@ -110,13 +113,8 @@ try {
|
|
|
110
113
|
} else {
|
|
111
114
|
additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
|
|
112
115
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
hookSpecificOutput: {
|
|
116
|
-
hookEventName: 'PreToolUse',
|
|
117
|
-
additionalContext,
|
|
118
|
-
},
|
|
119
|
-
}));
|
|
116
|
+
queueHookContext('PreToolUse', additionalContext);
|
|
117
|
+
flushHookStdout();
|
|
120
118
|
} catch (e) {
|
|
121
119
|
// Silent failure — never block Skill tool, but record for self-observation.
|
|
122
120
|
recordHookError('skill-bridge:query', e, RUNTIME_DIR, { skillName });
|
|
@@ -19,6 +19,25 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
|
|
|
19
19
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
20
20
|
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
21
21
|
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
22
|
+
// D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
|
|
23
|
+
// repeated-read guard, lesson block) and they stay one document because each branch
|
|
24
|
+
// process.exit()s before reaching the next.
|
|
25
|
+
//
|
|
26
|
+
// Be precise about what routing them through the queue does and does not buy, because an
|
|
27
|
+
// earlier version of this comment claimed "a second write is now impossible by
|
|
28
|
+
// construction" and that is FALSE (pre-tag review, v3.80.0): each site flushes
|
|
29
|
+
// IMMEDIATELY after queueing, and the flush resets the queue — so queue→flush→queue→flush
|
|
30
|
+
// emits two documents exactly like two raw writes would. Merging is a property of
|
|
31
|
+
// DEFERRING the flush (what hook.mjs does with a single flush at the end of its dispatch),
|
|
32
|
+
// not of using the queue.
|
|
33
|
+
//
|
|
34
|
+
// What it does buy: one construction site instead of three, so the "only the writer
|
|
35
|
+
// assembles an envelope" invariant is checkable (tests/hook-script-stdout-contract.test.mjs),
|
|
36
|
+
// and the merge is AVAILABLE to anyone who later defers the flush. The mutual exclusion
|
|
37
|
+
// itself is still control flow — the process.exit(0) below.
|
|
38
|
+
//
|
|
39
|
+
// Import-free module, no runtime deps — nothing added to this script's load cost.
|
|
40
|
+
import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
|
|
22
41
|
// Recall queries the SAVE-path project, so this MUST produce the same string as the
|
|
23
42
|
// save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
|
|
24
43
|
// drifted once (missing the process.env.PWD fallback, so a symlinked project dir
|
|
@@ -305,16 +324,11 @@ try {
|
|
|
305
324
|
const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
|
|
306
325
|
if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
|
|
307
326
|
const idList = seenIds.map(id => `#${id}`).join(', ');
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
314
|
-
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
|
|
315
|
-
].join('\n'),
|
|
316
|
-
},
|
|
317
|
-
}));
|
|
327
|
+
queueHookContext('PreToolUse', [
|
|
328
|
+
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
329
|
+
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
|
|
330
|
+
].join('\n'));
|
|
331
|
+
flushHookStdout();
|
|
318
332
|
cooldown[filePath] = { ...entry, mode: 'edit' };
|
|
319
333
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
320
334
|
} else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
|
|
@@ -322,16 +336,11 @@ try {
|
|
|
322
336
|
// nudge to reuse what's already in context. Read-only; never throws.
|
|
323
337
|
const meta = readFileMeta(filePath);
|
|
324
338
|
if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
331
|
-
buildRereadWarning(basename(filePath), entry.reread.tokens),
|
|
332
|
-
].join('\n'),
|
|
333
|
-
},
|
|
334
|
-
}));
|
|
339
|
+
queueHookContext('PreToolUse', [
|
|
340
|
+
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
341
|
+
buildRereadWarning(basename(filePath), entry.reread.tokens),
|
|
342
|
+
].join('\n'));
|
|
343
|
+
flushHookStdout();
|
|
335
344
|
recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
|
|
336
345
|
}
|
|
337
346
|
}
|
|
@@ -605,13 +614,8 @@ try {
|
|
|
605
614
|
}
|
|
606
615
|
|
|
607
616
|
if (lines.length > 0) {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
hookSpecificOutput: {
|
|
611
|
-
hookEventName: 'PreToolUse',
|
|
612
|
-
additionalContext: lines.join('\n'),
|
|
613
|
-
},
|
|
614
|
-
}));
|
|
617
|
+
queueHookContext('PreToolUse', lines.join('\n'));
|
|
618
|
+
flushHookStdout();
|
|
615
619
|
}
|
|
616
620
|
// Cooldown applies on ALL branches (including silent-Read) so subsequent
|
|
617
621
|
// calls on the same file in the same session don't re-query — preserving
|
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.
|