claude-mem-lite 3.79.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/lib/citation-tracker.mjs +62 -6
- package/lib/cite-back-hint.mjs +6 -1
- package/lib/hook-stdout.mjs +84 -29
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -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
|
@@ -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/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();
|
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
|
}
|
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",
|
|
@@ -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
|