Skip to content

Commit 61cb39e

Browse files
NickCirvclaude
andcommitted
feat(intercept): same-session read dedup (recall-safe, ADR-0003)
Within a session the Read handler records every full read; a repeat read of the same byte-unchanged file returns a small 'you already read this, it's above' pointer instead of re-serving the packet or letting a full re-read through. Phase-0 measured 38-46% of real reads (2,983 across 16 hook-logs) are same-session repeats, ~88% of them full-file passthrough re-reads — so this targets a large, real slice of the read budget. Reuses no graph work: a tiny session-scoped JSON store at .engram/served-reads-<session>.json (gitignored, relative keys). Recall-safety is the whole design. The risk is telling an agent 'you already have this' when it doesn't. Five guards bound it — three by design, two added after an adversarial review caught real holes: 1. unchanged only (mtime+size; a changed file re-serves) 2. full reads only (a partial offset/limit read always passes through) 3. PreCompact reset (compaction evicts content → clear the served-set) 4. SessionStart reset (NEW) — /clear and resume empty the window without a PreCompact event; a fresh/cleared/compacted SessionStart also clears the set (resume keeps it — its context is restored). Closes the highest- severity hole the review found. 5. TTL + cap (NEW) — 30-min per-entry TTL + 256-entry cap bound the blast radius of any unsignalled eviction (silent overflow/micro-compaction) to recent reads. Expiry/eviction re-serves — the safe direction. A 400-byte floor skips tiny files. On by default; ENGRAM_READ_DEDUP=0 opts out. Audit evidence: tsc clean; full suite 1097/1097 (+15 dedup tests covering every branch incl. TTL, cap, PreCompact + SessionStart reset, passthrough-repeat, partial-read, opt-out); e2e through the built CLI verified 1st→packet, 2nd→pointer, partial→passthrough, post-compact→re-serve, post-/clear→re-serve, opt-out→no-dedup. Adversarial recall-safety review: SHIP-WITH-FIXES → both fixes applied. Leak-scan 0 hits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4e31107 commit 61cb39e

7 files changed

Lines changed: 587 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ All notable changes to engram are documented here. Format based on
77
## [Unreleased]
88

99
### Added
10+
- **Same-session read dedup (ADR-0003).** Within a session, the Read handler
11+
records every full read; a subsequent read of the **same, byte-unchanged**
12+
file returns a small pointer ("you already read this unchanged file earlier;
13+
it's in your context above") instead of re-serving the packet or letting a
14+
full re-read through. Phase-0 measured 38–46% of real reads are same-session
15+
repeats, ~88% of them full-file passthrough re-reads — so this targets a large,
16+
real slice of the read budget. **Recall-safe by construction:** only fires on
17+
a byte-identical file (mtime+size), only on full reads (a partial offset/limit
18+
read always passes through), and the **PreCompact hook clears the session's
19+
served-set** so dedup never points at content that context compaction evicted.
20+
A 400-byte floor skips tiny files. On by default; `ENGRAM_READ_DEDUP=0` opts out.
1021
- **Grep interception (research-loop elimination — ADR-0001).** A new
1122
`PreToolUse:Grep` handler answers symbol-usage searches from the `calls`
1223
reference graph instead of letting a raw match dump flood the context window.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ADR-0003: Same-session read dedup
2+
3+
**Status:** Accepted · **Date:** 2026-06-03 · **Author:** Nicholas
4+
5+
## Context
6+
7+
Phase-0 measurement over 2,983 real Read interceptions (16 hook-logs) found **38–46% are
8+
same-session repeat reads** of a file already read earlier — 38.3% even at the strictest 10-minute
9+
session boundary. Of those repeats, **~88% are `passthrough` full-file re-reads** (the agent re-reads
10+
the raw file at full token cost), only ~12% are re-served packets. So a large, real slice of the read
11+
budget is spent re-reading files whose content the agent already has in context.
12+
13+
## Decision
14+
15+
Within a session, the Read handler records every full read in
16+
`<projectRoot>/.engram/served-reads-<session_id>.json` (relative paths). On a subsequent Read of the
17+
**same, byte-unchanged** file (mtime+size match), it returns a small pointer ("you already read this
18+
unchanged file earlier; it's in your context above") instead of re-serving the packet or letting the
19+
raw re-read through. `ENGRAM_READ_DEDUP=0` opts out.
20+
21+
It dedups **both** re-served-packet repeats *and* passthrough (raw) repeats — excluding passthrough
22+
would forfeit ~88% of the opportunity. Three guards make the assertive passthrough case sound:
23+
24+
1. **Unchanged only** — mtime+size must match what was recorded; a changed file re-serves (the agent
25+
rightly wants new content). mtime+size is the same cheap proxy engram's incremental indexer uses.
26+
2. **Full reads only** — the dedup check sits *after* the offset/limit passthrough gate, so a partial
27+
read (the agent wanting specific lines) is never deduped.
28+
3. **Compaction reset** — the PreCompact hook deletes the session's served-set, so after context
29+
compaction every read re-serves. An agent re-reads a file *because* compaction evicted it, and
30+
dedup must never answer "you already have this" when the agent provably doesn't.
31+
4. **SessionStart reset** (added after adversarial review) — PreCompact is *not* the only way context
32+
empties: `/clear` and session-resume reuse/retain a `session_id` while emptying the window, with no
33+
PreCompact event. So a fresh / cleared / compacted SessionStart also clears the served-set. (A
34+
`resume` SessionStart does not — its conversation context is restored, so its set stays valid.)
35+
This closes the highest-severity hole the review found.
36+
5. **TTL + cap** (added after adversarial review) — a 30-minute per-entry TTL and a 256-entry cap bound
37+
the blast radius of any *unsignalled* eviction (silent overflow, micro-compaction): only recent,
38+
most-recently-read files are dedup-eligible. Expiry/eviction causes a re-serve — the safe direction.
39+
40+
A **400-byte (~100-token) floor** skips tiny files where the dedup pointer saves little and the
41+
"scroll up" friction isn't worth it.
42+
43+
## The trade-off
44+
45+
The risk is recall: telling an agent "you already have this" when it doesn't, blinding it. The three
46+
guards bound it — content is byte-identical AND still in the (un-compacted) context window AND it was a
47+
full read. The residual case is an agent re-reading within a non-compacted window because attention
48+
faded; there the content *is* still in context, so the pointer redirects rather than blinds — mild
49+
friction, not data loss, and the opt-out covers it. The discarded alternative — dedup only the safe
50+
deny-repeats — is provably too small to matter (12% of repeats). The other discarded alternative —
51+
content hashing instead of mtime+size — costs a read+hash on every Read for a vanishingly rare failure
52+
(same-size edit within mtime resolution) whose blast radius is "a structural summary is one line stale."

src/intercept/handlers/pre-compact.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ import { godNodes, mistakes, stats } from "../../core.js";
1818
import { findProjectRoot, isValidCwd } from "../context.js";
1919
import { isHookDisabled, PASSTHROUGH, type HandlerResult } from "../safety.js";
2020
import { buildSessionContextResponse } from "../formatter.js";
21+
import { clearServedReads } from "../served-reads.js";
2122

2223
export interface PreCompactHookPayload {
2324
readonly hook_event_name: "PreCompact" | string;
2425
readonly cwd: string;
26+
/** Claude Code session id — used to reset same-session read dedup (ADR-0003). */
27+
readonly session_id?: string;
2528
}
2629

2730
/** Compact survival payload — fewer nodes than SessionStart, just essentials. */
@@ -89,6 +92,14 @@ export async function handlePreCompact(
8992
const projectRoot = findProjectRoot(cwd);
9093
if (projectRoot === null) return PASSTHROUGH;
9194

95+
// ADR-0003: compaction evicts the content a read-dedup pointer refers to, so
96+
// reset this session's served-read set — post-compaction reads must re-serve.
97+
// Done before the kill-switch check: the reset is a correctness operation,
98+
// independent of whether we inject the survival brief.
99+
if (typeof payload.session_id === "string") {
100+
clearServedReads(projectRoot, payload.session_id);
101+
}
102+
92103
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
93104

94105
try {

src/intercept/handlers/read.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "../context.js";
2424
import { isHookDisabled, PASSTHROUGH, type HandlerResult } from "../safety.js";
2525
import { buildDenyResponse } from "../formatter.js";
26+
import { dedupOrRecord } from "../served-reads.js";
2627
import { resolveRichPacket } from "../../providers/resolver.js";
2728
import type { NodeContext } from "../../providers/types.js";
2829

@@ -38,6 +39,8 @@ export interface ReadHookPayload {
3839
readonly limit?: number;
3940
};
4041
readonly cwd: string;
42+
/** Claude Code session id — used for same-session read dedup (ADR-0003). */
43+
readonly session_id?: string;
4144
}
4245

4346
/**
@@ -51,6 +54,22 @@ export interface ReadHookPayload {
5154
*/
5255
export const READ_CONFIDENCE_THRESHOLD = 0.7;
5356

57+
/**
58+
* The pointer engram returns for a same-session repeat read of an unchanged
59+
* file (ADR-0003). Honest by construction: it only fires on a byte-identical
60+
* file whose content is still in the (un-compacted) context window, and it
61+
* tells the agent exactly how to override if it believes otherwise.
62+
*/
63+
function buildDedupPointer(relPath: string): string {
64+
return (
65+
`[engram] You already read \`${relPath}\` earlier in this session and it ` +
66+
`has not changed since — its contents are already in your context above, ` +
67+
`no need to re-read it. (If you believe it changed, read it again: engram ` +
68+
`only dedupes byte-identical files within one session and resets on context ` +
69+
`compaction.)`
70+
);
71+
}
72+
5473
/**
5574
* Handle a PreToolUse:Read hook payload. Returns either:
5675
* - A deny response containing the engram structural summary (Claude
@@ -108,6 +127,24 @@ export async function handleRead(
108127
// (6) Kill switch check — respects `.engram/hook-disabled` flag.
109128
if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
110129

130+
// (6b) Same-session read dedup (ADR-0003). If the agent already read this
131+
// exact file this session and it hasn't changed (and no PreCompact has reset
132+
// the session), point back to the content it already has instead of
133+
// re-serving the packet or letting a full re-read through. Recording happens
134+
// as a side effect, so the NEXT identical read can dedup. Sits BEFORE the
135+
// graph query so it also covers passthrough (not-in-graph) re-reads — ~88% of
136+
// real repeats. Opt out with ENGRAM_READ_DEDUP=0.
137+
if (
138+
process.env.ENGRAM_READ_DEDUP !== "0" &&
139+
typeof payload.session_id === "string" &&
140+
dedupOrRecord(ctx.projectRoot, payload.session_id, ctx.absPath)
141+
) {
142+
const rel = relative(ctx.projectRoot, ctx.absPath)
143+
.split(/[\\/]/)
144+
.join("/");
145+
return buildDenyResponse(buildDedupPointer(rel));
146+
}
147+
111148
// (7) Query the graph for file context.
112149
const fileCtx = await getFileContext(ctx.projectRoot, ctx.absPath);
113150
if (!fileCtx.found || fileCtx.codeNodeCount === 0) return PASSTHROUGH;

src/intercept/handlers/session-start.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { basename, dirname, join, resolve } from "node:path";
2626
const execFileAsync = promisify(execFile);
2727
import { godNodes, mistakes, stats } from "../../core.js";
2828
import { findProjectRoot, isValidCwd } from "../context.js";
29+
import { clearServedReads } from "../served-reads.js";
2930
import { isHookDisabled, PASSTHROUGH, type HandlerResult } from "../safety.js";
3031
import { buildSessionContextResponse } from "../formatter.js";
3132
import { warmAllProviders } from "../../providers/resolver.js";
@@ -34,6 +35,8 @@ export interface SessionStartHookPayload {
3435
readonly hook_event_name: "SessionStart" | string;
3536
readonly cwd: string;
3637
readonly source?: "startup" | "resume" | "clear" | "compact" | string;
38+
/** Claude Code session id — used to reset same-session read dedup (ADR-0003). */
39+
readonly session_id?: string;
3740
}
3841

3942
/** Max god nodes in the brief — more than this gets noisy. */
@@ -246,6 +249,17 @@ export async function handleSessionStart(
246249
const projectRoot = findProjectRoot(cwd);
247250
if (projectRoot === null) return PASSTHROUGH;
248251

252+
// ADR-0003: a fresh / cleared / compacted SessionStart means the prior
253+
// context — and the read-dedup pointers into it — is gone. Reset this
254+
// session's served-read set so the first read of a previously-seen file
255+
// re-serves the content instead of pointing at what's no longer there.
256+
// (resume returned above; its conversation context is restored, so its
257+
// served-set stays valid.) This closes the /clear + session-reuse hole that
258+
// PreCompact alone does not cover.
259+
if (typeof payload.session_id === "string") {
260+
clearServedReads(projectRoot, payload.session_id);
261+
}
262+
249263
// Kill switch.
250264
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
251265

src/intercept/served-reads.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Same-session read dedup store (ADR-0003).
3+
*
4+
* Records which files an agent has already read in a session so a repeat read
5+
* of an UNCHANGED, non-trivial file (within the same compaction epoch) can be
6+
* answered with a tiny pointer instead of re-serving the packet / re-reading
7+
* the raw file. State lives at `<projectRoot>/.engram/served-reads-<session>.json`
8+
* (gitignored; relative paths only, no absolute/home paths). The set is cleared
9+
* on PreCompact (`clearServedReads`) so dedup never points at content that
10+
* context compaction evicted.
11+
*
12+
* Every function is best-effort and NEVER throws — a dedup-store failure must
13+
* never break a Read. On any error we simply don't dedup.
14+
*/
15+
import {
16+
readFileSync,
17+
writeFileSync,
18+
existsSync,
19+
statSync,
20+
mkdirSync,
21+
rmSync,
22+
readdirSync,
23+
} from "node:fs";
24+
import { join, relative } from "node:path";
25+
26+
/** Below this, re-reading the raw file is cheaper than the dedup pointer + the
27+
* "scroll up" friction — so we don't dedup tiny files. ~100 tokens. */
28+
const DEDUP_MIN_BYTES = 400;
29+
30+
/** Served-read sets from sessions older than this are pruned (best-effort). */
31+
const STALE_MS = 24 * 60 * 60 * 1000;
32+
33+
/**
34+
* A recorded read is only dedup-eligible for this long. Recall-safety backstop
35+
* for the case where context content is evicted WITHOUT a PreCompact/SessionStart
36+
* reset firing (silent overflow, micro-compaction): an old recorded read is more
37+
* likely to have left the window. Expiry causes a re-serve (a false negative —
38+
* the safe direction), never a false "you already have this".
39+
*/
40+
const DEDUP_TTL_MS = 30 * 60 * 1000;
41+
42+
/**
43+
* Cap on entries per session set. Bounds the blast radius of volume-based
44+
* eviction (a very long, read-heavy session that overflows the window before any
45+
* compaction event) to the most-recently-read files — the ones genuinely still
46+
* in context — and keeps the on-disk set small. Oldest entries are evicted.
47+
*/
48+
const MAX_ENTRIES = 256;
49+
50+
/** Sanitise the session id into a safe filename fragment (no path traversal). */
51+
function safeSession(sessionId: string): string | null {
52+
const s = sessionId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
53+
return s.length > 0 ? s : null;
54+
}
55+
56+
function storePath(projectRoot: string, session: string): string {
57+
return join(projectRoot, ".engram", `served-reads-${session}.json`);
58+
}
59+
60+
type ServedMap = Record<string, { mtimeMs: number; size: number; at: number }>;
61+
62+
function load(path: string): ServedMap {
63+
try {
64+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
65+
return parsed && typeof parsed === "object" ? (parsed as ServedMap) : {};
66+
} catch {
67+
return {};
68+
}
69+
}
70+
71+
/** Evict the oldest entries (by `at`) so a session set never exceeds the cap. */
72+
function capEntries(map: ServedMap): void {
73+
const keys = Object.keys(map);
74+
if (keys.length <= MAX_ENTRIES) return;
75+
keys
76+
.sort((a, b) => map[a].at - map[b].at)
77+
.slice(0, keys.length - MAX_ENTRIES)
78+
.forEach((k) => delete map[k]);
79+
}
80+
81+
/** Best-effort prune of served-read sets left behind by old sessions. */
82+
function pruneStale(dir: string): void {
83+
try {
84+
const now = Date.now();
85+
for (const name of readdirSync(dir)) {
86+
if (!name.startsWith("served-reads-") || !name.endsWith(".json")) continue;
87+
const p = join(dir, name);
88+
try {
89+
if (now - statSync(p).mtimeMs > STALE_MS) rmSync(p, { force: true });
90+
} catch {
91+
/* ignore one bad file */
92+
}
93+
}
94+
} catch {
95+
/* ignore */
96+
}
97+
}
98+
99+
/**
100+
* Record a full read; return `true` iff this is a repeat read of an unchanged,
101+
* non-trivial file already seen this session (the caller should dedup). On a
102+
* first read (or a changed/tiny file) it records and returns `false`.
103+
*/
104+
export function dedupOrRecord(
105+
projectRoot: string,
106+
sessionId: string,
107+
absPath: string
108+
): boolean {
109+
try {
110+
const session = safeSession(sessionId);
111+
if (!session) return false;
112+
113+
let st: { mtimeMs: number; size: number };
114+
try {
115+
const s = statSync(absPath);
116+
st = { mtimeMs: s.mtimeMs, size: s.size };
117+
} catch {
118+
return false; // can't verify the file → never dedup
119+
}
120+
121+
const now = Date.now();
122+
const key = relative(projectRoot, absPath).split(/[\\/]/).join("/");
123+
const path = storePath(projectRoot, session);
124+
const firstOfSession = !existsSync(path);
125+
const map = load(path);
126+
const prev = map[key];
127+
128+
if (
129+
prev &&
130+
prev.mtimeMs === st.mtimeMs &&
131+
prev.size === st.size &&
132+
st.size >= DEDUP_MIN_BYTES &&
133+
now - prev.at < DEDUP_TTL_MS // recall-safety: stale records re-serve
134+
) {
135+
return true; // unchanged, recent repeat of a non-trivial file → dedup
136+
}
137+
138+
// First read (or changed, or tiny, or stale): record and continue. `at` is
139+
// refreshed only on a real (re-)serve, so dedup-eligibility ages from when
140+
// the content last entered context, not from the last pointer.
141+
map[key] = { mtimeMs: st.mtimeMs, size: st.size, at: now };
142+
capEntries(map);
143+
try {
144+
const engramDir = join(projectRoot, ".engram");
145+
if (!existsSync(engramDir)) mkdirSync(engramDir, { recursive: true });
146+
writeFileSync(path, JSON.stringify(map));
147+
if (firstOfSession) pruneStale(engramDir);
148+
} catch {
149+
/* recording is best-effort; failure just means no dedup next time */
150+
}
151+
return false;
152+
} catch {
153+
return false;
154+
}
155+
}
156+
157+
/**
158+
* Clear a session's served-read set — called on PreCompact, the eviction
159+
* boundary, so a post-compaction re-read always re-serves the content.
160+
*/
161+
export function clearServedReads(projectRoot: string, sessionId: string): void {
162+
try {
163+
const session = safeSession(sessionId);
164+
if (!session) return;
165+
rmSync(storePath(projectRoot, session), { force: true });
166+
} catch {
167+
/* ignore */
168+
}
169+
}

0 commit comments

Comments
 (0)