Skip to content

Commit 618409d

Browse files
NickCirvclaude
andcommitted
feat(intercept): richer find-usages in the Grep packet (ADR-0004)
The session bench showed the grep packet was a file list that contained the investigated symbol ZERO times — high effective-P (low recall-sufficiency) that caps the real saving. The handler now scans the resolved caller files at query time (no graph/miner change) and returns the actual call-site lines (file:line: code), deduplicated and capped, plus the rg -n escalation — so the agent gets the usage context it grepped for and rarely re-greps. An adversarial audit then caught that the naive version was a token REGRESSION on most greps (bigger than a default filenames grep always; bigger than a content grep for low-usage symbols, where ~50 tok of boilerplate dominates). Two gates fix it so every interception that fires is a genuine win: - output_mode === 'content' only (the default files_with_matches / count modes return filenames / a number — cheaper than any packet → passthrough). - callerFiles.length >= 4 (below that a content grep is small enough that the packet would cost more → passthrough). Measured on engram's own repo (content mode): init 573 vs 9,317 tok, parse 578 vs 2,152, getStore 531 vs 1,781 — all smaller; files-mode + low-usage → passthrough. Also from the audit: word boundaries via lookarounds (so $-identifiers anchor), and the cap note no longer claims a specific (wrong) omitted-file count. Caps: 15 caller files scanned, 25 lines, 140 chars/line, files > 1 MB skipped. Best-effort, never throws; 0 call sites found (dynamic dispatch) → passthrough. Audit: tsc clean; full suite 1100/1100 (+3 grep tests: call-site format, cap, two gate passthroughs); e2e re-measured (content→smaller, files-mode→passthrough, low-usage→passthrough); adversarial review SHIP-WITH-FIXES → all 3 fixes applied. Leak-scan 0 hits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a56fbb commit 618409d

4 files changed

Lines changed: 248 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ All notable changes to engram are documented here. Format based on
2121
- **Grep interception (research-loop elimination — ADR-0001).** A new
2222
`PreToolUse:Grep` handler answers symbol-usage searches from the `calls`
2323
reference graph instead of letting a raw match dump flood the context window.
24-
When the agent greps a bare identifier that's a known symbol with references,
25-
engram denies the grep and returns the list of files that reference it,
26-
plus the exact `rg -n "<pattern>"` escalation command. **Recall-safe by
24+
For a **content-mode** grep of a well-referenced symbol (≥4 caller files),
25+
engram denies the grep and returns the actual **call-site lines**
26+
(`file:line: code`, scanned from the resolved caller files, deduplicated and
27+
capped — ADR-0004) plus the exact `rg -n "<pattern>"` escalation command.
28+
The call-site lines give the agent the usage context it grepped for (so it
29+
rarely re-greps) while staying smaller than the content grep it replaces — e.g.
30+
on engram's own repo `init` is 573 tok vs 9,317 for the raw grep. Default
31+
`files_with_matches` / `count` greps and low-usage symbols pass through (their
32+
grep is already cheaper than any packet). **Recall-safe by
2733
construction:** regex/text/multi-word/stopword patterns and unknown symbols
2834
pass straight through (grep out-recalls the structural graph there), and the
2935
escalation command means the agent can always recover full textual matches in
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ADR-0004: Richer find-usages in the Grep packet (call-site lines)
2+
3+
**Status:** Accepted · **Date:** 2026-06-03 · **Author:** Nicholas
4+
5+
## Context
6+
7+
ADR-0001 made the Grep handler answer a symbol search from the `calls` graph with a list of caller
8+
**files**. The session-level bench (ADR-0002) and its adversarial audit then showed the load-bearing
9+
weakness: that packet is a strict *subset* of what the agent grepped for — it contains the symbol's
10+
call sites **zero times**, only file names. So the agent often re-runs the raw grep to see the actual
11+
usage, which is exactly the high "recall-recovery fraction" (P) that caps the real session saving
12+
between the optimistic ceiling and break-even. To move the real saving toward the ceiling we must raise
13+
**recall-sufficiency**: make the packet actually answer "where/how is this symbol used."
14+
15+
## Decision
16+
17+
The Grep handler now scans the resolved caller files (at query time, no graph/miner change) for the
18+
lines that reference the symbol (word-boundary via lookarounds, so `$`-identifiers anchor) and returns
19+
the actual **call-site lines** (`file:line: code`) in the packet, deduplicated and capped, plus the
20+
existing `rg -n` escalation.
21+
22+
**Two gates make this a genuine token win, never a regression** (added after an adversarial audit showed
23+
the naive version cost *more* on most greps):
24+
25+
1. **`output_mode === "content"` only.** Claude Code's Grep defaults to `files_with_matches` (filenames)
26+
and also offers `count` — both far cheaper than any packet. engram's call-site packet can only beat a
27+
*content* grep (matching lines), so for the filenames/count modes it passes through.
28+
2. **`callerFiles.length >= 4`.** Below that, even a content grep is small enough that engram's packet +
29+
its ~50-token boilerplate would cost more than the grep it replaces. The win scales with usage; this
30+
floors out the regressing tail.
31+
32+
Bounds keep the packet smaller than the content grep it replaces: at most 15 caller files scanned, 25
33+
lines returned, each trimmed to 140 chars, files > 1 MB skipped. If the scan finds **zero** lines (a
34+
`calls` edge from dynamic dispatch the literal symbol word doesn't appear for), it passes through.
35+
36+
## The trade-off (honest)
37+
38+
The packet is larger than a bare file list — but it is the right kind of larger: it contains the real
39+
usage context the agent grepped for, drawn **only from the files the graph says actually reference the
40+
symbol** (no comment/string/test-file/partial-match noise), capped. The naive version (no gates) was a
41+
token *regression* on most greps — bigger than a default filenames grep always, and bigger than a
42+
content grep for low-usage symbols (the ~50-token boilerplate dominates). The two gates fix that: scoped
43+
to `content` mode and `>= 4` caller files, every interception that fires is smaller than the content
44+
grep it replaces (measured on engram's own repo: `init` 573 vs 9,317 tok, `parse` 578 vs 2,152,
45+
`getStore` 531 vs 1,781), while raising recall-sufficiency from ~0 (file names) to the real call sites.
46+
This is a *structural* context-token effect, not a bill saving. The discarded alternative — returning every match
47+
line — is just the grep dump (no saving); the discarded alternative of reading the call-site line from
48+
`GraphEdge.sourceLocation` was rejected to avoid touching the hardened reference-graph code (the
49+
line *text* requires reading the file anyway, so the scan is simpler and self-contained).
50+
51+
We cannot deterministically measure the drop in P (it is behavioural). We can and do measure the two
52+
proxies: (a) the packet still smaller than the raw grep, and (b) the packet now *contains* the call-site
53+
lines a grep would show (recall-coverage), vs ~0 before.

src/intercept/handlers/grep.ts

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,31 @@
1616
* real grep runs. Default to doing nothing; act only when provably helpful.
1717
*
1818
* Returns:
19-
* - A deny response whose reason is engram's caller list (the files that
20-
* reference the symbol), when the pattern is a known symbol with references.
19+
* - A deny response whose reason is engram's call-site list (the actual
20+
* `file:line: code` lines that reference the symbol, from the files that
21+
* call it), when the pattern is a known symbol with references.
2122
* - PASSTHROUGH (null) otherwise — caller writes nothing, exits 0, the real
2223
* Grep runs unchanged.
2324
* Never throws. Every error path resolves to PASSTHROUGH via wrapSafely.
2425
*/
26+
import { readFileSync, statSync } from "node:fs";
27+
import { join } from "node:path";
2528
import { PASSTHROUGH, isHookDisabled, type HandlerResult } from "../safety.js";
2629
import { findProjectRoot, isValidCwd } from "../context.js";
2730
import { buildDenyResponse } from "../formatter.js";
2831
import { callers } from "../../core.js";
2932

33+
/** Bounds that keep the packet richer than a file list yet smaller than a raw
34+
* repo-wide grep (ADR-0004). */
35+
const MAX_CALLER_FILES = 15;
36+
const MAX_SITES = 25;
37+
const MAX_LINE_LEN = 140;
38+
const MAX_FILE_BYTES = 1_000_000;
39+
/** Below this many caller files a content grep is small enough that engram's
40+
* packet + boilerplate would cost MORE than the grep it replaces — so pass
41+
* through. The win scales with usage; this floors out the regressing tail. */
42+
const MIN_CALLER_FILES = 4;
43+
3044
export interface GrepHookPayload {
3145
tool_name?: string;
3246
cwd?: string;
@@ -80,6 +94,12 @@ export async function handleGrep(
8094
return PASSTHROUGH;
8195
}
8296

97+
// (1b) Output-mode gate (ADR-0004): engram's call-site packet can only beat a
98+
// CONTENT grep (matching lines). The default `files_with_matches` and `count`
99+
// modes return just filenames / a number — cheaper than any packet — so for
100+
// those, pass through and let the agent's cheap grep run.
101+
if (payload.tool_input?.output_mode !== "content") return PASSTHROUGH;
102+
83103
// (2) Resolve the project root from cwd (no file path on a Grep).
84104
const cwd = payload.cwd;
85105
if (typeof cwd !== "string" || !isValidCwd(cwd)) return PASSTHROUGH;
@@ -89,28 +109,112 @@ export async function handleGrep(
89109
// (3) Kill switch.
90110
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
91111

92-
// (4) Ask the reference graph who references this symbol. No callers means
93-
// either an unknown symbol or a textual-only occurrence — let grep run.
112+
// (4) Ask the reference graph who references this symbol. Fewer than
113+
// MIN_CALLER_FILES → a content grep would be small enough that engram's packet
114+
// would cost more; pass through. (0 callers = unknown / textual-only symbol.)
94115
const callerFiles = await callers(projectRoot, pattern);
95-
if (callerFiles.length === 0) return PASSTHROUGH;
116+
if (callerFiles.length < MIN_CALLER_FILES) return PASSTHROUGH;
117+
118+
// (5) Pull the actual call-site lines from those files (ADR-0004) so the
119+
// packet answers "where/how is it used", not just "which files". If the scan
120+
// finds nothing (e.g. the edge came from dynamic dispatch the literal symbol
121+
// word doesn't appear), let the agent's grep run.
122+
const found = collectCallSites(projectRoot, callerFiles, pattern);
123+
if (found.sites.length === 0) return PASSTHROUGH;
124+
125+
return buildDenyResponse(buildGrepAnswer(pattern, callerFiles.length, found));
126+
}
127+
128+
interface CallSite {
129+
readonly file: string;
130+
readonly line: number;
131+
readonly text: string;
132+
}
133+
interface CallSiteScan {
134+
readonly sites: readonly CallSite[];
135+
readonly filesOmitted: number;
136+
readonly truncated: boolean;
137+
}
138+
139+
function escapeRegExp(s: string): string {
140+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
141+
}
96142

97-
return buildDenyResponse(buildGrepAnswer(pattern, callerFiles));
143+
/**
144+
* Scan the resolved caller files for the lines that reference `symbol`
145+
* (word-boundary) — the actual call sites the agent grepped for (ADR-0004).
146+
* Bounded so the packet stays smaller than a raw grep: at most MAX_CALLER_FILES
147+
* files, MAX_SITES lines, each trimmed to MAX_LINE_LEN; files > MAX_FILE_BYTES
148+
* are skipped. Best-effort — unreadable files are skipped, never throws.
149+
*/
150+
function collectCallSites(
151+
projectRoot: string,
152+
callerFiles: readonly string[],
153+
symbol: string
154+
): CallSiteScan {
155+
// Identifier boundaries via lookarounds (not `\b`) so symbols containing `$`
156+
// (jQuery/legacy) anchor correctly — `\b` can't sit next to a non-word `$`.
157+
const re = new RegExp(`(?<![\\w$])${escapeRegExp(symbol)}(?![\\w$])`);
158+
const sites: CallSite[] = [];
159+
const scanFiles = callerFiles.slice(0, MAX_CALLER_FILES);
160+
const filesOmitted = callerFiles.length - scanFiles.length;
161+
let truncated = false;
162+
163+
for (const rel of scanFiles) {
164+
if (sites.length >= MAX_SITES) {
165+
truncated = true;
166+
break;
167+
}
168+
let content: string;
169+
try {
170+
if (statSync(join(projectRoot, rel)).size > MAX_FILE_BYTES) continue;
171+
content = readFileSync(join(projectRoot, rel), "utf-8");
172+
} catch {
173+
continue;
174+
}
175+
const lines = content.split("\n");
176+
for (let i = 0; i < lines.length; i++) {
177+
if (sites.length >= MAX_SITES) {
178+
truncated = true;
179+
break;
180+
}
181+
if (!re.test(lines[i])) continue;
182+
let text = lines[i].trim();
183+
if (text.length > MAX_LINE_LEN) text = text.slice(0, MAX_LINE_LEN) + "…";
184+
sites.push({ file: rel, line: i + 1, text });
185+
}
186+
}
187+
return { sites, filesOmitted, truncated };
98188
}
99189

100190
/**
101-
* Format engram's structural answer plus the explicit escalation path. The
191+
* Render engram's call-site answer plus the explicit escalation path. The
102192
* escalation line is non-negotiable — it's what makes denying the grep
103193
* recall-safe (the agent can always get the full textual matches).
104194
*/
105-
function buildGrepAnswer(pattern: string, callerFiles: string[]): string {
106-
const list = callerFiles.map((f) => ` - ${f}`).join("\n");
195+
function buildGrepAnswer(
196+
symbol: string,
197+
callerCount: number,
198+
scan: CallSiteScan
199+
): string {
200+
const lines = scan.sites.map((s) => ` ${s.file}:${s.line}: ${s.text}`);
201+
const head =
202+
`[engram] "${symbol}" — ${scan.sites.length}${scan.truncated ? "+" : ""} ` +
203+
`call site(s) across ${callerCount} file(s) (resolved \`calls\` edges):`;
204+
// When capped, don't claim a specific omitted-file count — the line cap can
205+
// hit before all caller files are scanned, so any number would be wrong. Just
206+
// point at the rg escalation for the complete set.
207+
const capNote =
208+
scan.truncated || scan.filesOmitted > 0
209+
? [` … more call sites exist — run the rg command below for the complete set.`]
210+
: [];
107211
return [
108-
`[engram] "${pattern}" is referenced by ${callerFiles.length} file(s) ` +
109-
`in the reference graph (structural \`calls\` edges):`,
110-
list,
212+
head,
213+
...lines,
214+
...capNote,
111215
"",
112-
"This is engram's structural answer — resolved function/class references " +
113-
"only. It does NOT include comments, strings, or dynamic references. " +
114-
`If you need full textual matches, run: rg -n "${pattern}"`,
216+
"This is engram's structural answer — resolved references in files that " +
217+
"call the symbol. It may omit comments, strings, and dynamic references. " +
218+
`For a full textual search, run: rg -n "${symbol}"`,
115219
].join("\n");
116220
}

tests/intercept/handlers/grep.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,32 @@ export function lookup(name: string): number {
5252
}
5353
`
5454
);
55+
// 3 more caller files so hashToken clears MIN_CALLER_FILES (≥4 callers →
56+
// a content grep is big enough that engram's packet is a real win).
57+
for (const n of [2, 3, 4]) {
58+
writeFileSync(
59+
join(projectRoot, "src", `svc${n}.ts`),
60+
`import { hashToken } from "./util";\nexport function use${n}(x: string): number { return hashToken(x); }\n`
61+
);
62+
}
5563
await init(projectRoot);
5664
});
5765

5866
afterEach(() => {
5967
rmSync(projectRoot, { recursive: true, force: true });
6068
});
6169

62-
function grepPayload(pattern: string): Record<string, unknown> {
70+
function grepPayload(
71+
pattern: string,
72+
outputMode: string | undefined = "content"
73+
): Record<string, unknown> {
6374
return {
6475
tool_name: "Grep",
6576
cwd: projectRoot,
66-
tool_input: { pattern },
77+
tool_input:
78+
outputMode === undefined
79+
? { pattern }
80+
: { pattern, output_mode: outputMode },
6781
};
6882
}
6983

@@ -127,7 +141,58 @@ export function lookup(name: string): number {
127141
// engram's structural answer names the calling file...
128142
expect(reason).toContain("svc.ts");
129143
expect(reason).toContain("hashToken");
144+
// ...with the ACTUAL call-site lines (file:line: code) — the recall
145+
// sufficiency a bare file list lacked (ADR-0004)...
146+
expect(reason).toContain("call site");
147+
expect(reason).toMatch(/src\/svc\.ts:\d+:/); // file:line prefix
148+
expect(reason).toContain("return hashToken(name)"); // a real usage line
130149
// ...and ALWAYS includes the recall-safety escalation command.
131150
expect(reason).toContain('rg -n "hashToken"');
132151
});
152+
153+
it("caps the call-site list for a heavily-used symbol", async () => {
154+
// a caller file with far more than MAX_SITES (25) references to hashToken
155+
const many = Array.from(
156+
{ length: 60 },
157+
(_, i) => `export function use${i}(n: string): number { return hashToken(n) + ${i}; }`
158+
).join("\n");
159+
writeFileSync(join(projectRoot, "src", "heavy.ts"), `import { hashToken } from "./util";\n${many}\n`);
160+
await init(projectRoot);
161+
162+
const r = (await handleGrep(grepPayload("hashToken"))) as Record<
163+
string,
164+
unknown
165+
>;
166+
const reason = (r.hookSpecificOutput as Record<string, unknown>)
167+
.permissionDecisionReason as string;
168+
// cap marker present, and the rendered line count never exceeds the cap
169+
expect(reason).toContain("more call sites exist");
170+
expect(reason).toMatch(/\d+\+ call site/); // "25+ call site(s)"
171+
const siteLines = reason.split("\n").filter((l) => /:\d+:/.test(l));
172+
expect(siteLines.length).toBeLessThanOrEqual(25);
173+
});
174+
175+
it("passes through a non-content grep (files_with_matches / default / count)", async () => {
176+
// engram's call-site packet can't beat a filenames-only or count grep
177+
expect(await handleGrep(grepPayload("hashToken", "files_with_matches"))).toBe(
178+
PASSTHROUGH
179+
);
180+
expect(await handleGrep(grepPayload("hashToken", "count"))).toBe(PASSTHROUGH);
181+
// output_mode omitted entirely = default files mode → passthrough.
182+
// (Built as a raw payload; passing `undefined` to grepPayload would trigger
183+
// its `= "content"` default — the classic JS default-param gotcha.)
184+
expect(
185+
await handleGrep({
186+
tool_name: "Grep",
187+
cwd: projectRoot,
188+
tool_input: { pattern: "hashToken" },
189+
} as never)
190+
).toBe(PASSTHROUGH);
191+
});
192+
193+
it("passes through a symbol with too few caller files (content grep is small)", async () => {
194+
// `normalize` is called only by svc.ts (1 caller) < MIN_CALLER_FILES → a
195+
// content grep would be tiny, so engram's packet would cost more → passthrough
196+
expect(await handleGrep(grepPayload("normalize"))).toBe(PASSTHROUGH);
197+
});
133198
});

0 commit comments

Comments
 (0)