Skip to content

Commit eb08344

Browse files
committed
Publish Jev coding agent compaction experiment
1 parent e8dcab8 commit eb08344

33 files changed

Lines changed: 2117 additions & 0 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "jev-context",
3+
"version": "0.1.0",
4+
"description": "Jev-guided verbatim context pruning for Claude Code sessions.",
5+
"author": {
6+
"name": "Desty"
7+
},
8+
"license": "MIT",
9+
"userConfig": {
10+
"apiKey": {
11+
"type": "string",
12+
"title": "TypeSafe API key",
13+
"description": "Leave unset to use TYPESAFE_API_KEY.",
14+
"sensitive": true
15+
},
16+
"keepThreshold": {
17+
"type": "number",
18+
"title": "Keep threshold",
19+
"description": "Minimum probability for a call or result to remain.",
20+
"default": 0.3
21+
},
22+
"preserveRecentMessages": {
23+
"type": "number",
24+
"title": "Recent messages to preserve",
25+
"description": "Newest messages pinned from pruning.",
26+
"default": 6
27+
},
28+
"compactAtPercent": {
29+
"type": "number",
30+
"title": "Compaction percentage",
31+
"description": "Context percentage that requests compaction.",
32+
"default": 60
33+
},
34+
"minReductionRatio": {
35+
"type": "number",
36+
"title": "Minimum reduction ratio",
37+
"description": "Fall back to native summary below this ratio.",
38+
"default": 0.25
39+
},
40+
"truncateHeadChars": {
41+
"type": "number",
42+
"title": "Truncated result head",
43+
"description": "Characters retained when only the call matters.",
44+
"default": 300
45+
}
46+
}
47+
}

‎experiments/jev-context/LICENSE‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
MIT License
2+
3+
Copyright (c) 2026
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6+
associated documentation files (the "Software"), to deal in the Software without restriction,
7+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
8+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all copies or substantial
12+
portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
17+
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

‎experiments/jev-context/README.md‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# jev-context
2+
3+
Decision-based context pruning for coding agents. The core normalizes a transcript into text,
4+
tool calls, and tool results; asks a bounded judge whether each old call and result should remain;
5+
then keeps, truncates, or drops the pair without rewriting retained content.
6+
7+
This repository is an experiment, not a claim that semantic deletion is lossless. The benchmark
8+
tracks reduction separately from recovery work and task success.
9+
10+
## Status
11+
12+
- Core library: working prototype
13+
- Offline replay benchmark: included
14+
- Jev transport: included, requires `TYPESAFE_API_KEY`
15+
- Claude Code adapter: planned first-class integration
16+
- Codex adapter: experimental; current `PreCompact` hooks can stop compaction but cannot replace
17+
the transcript, so parity requires an App Server/SDK integration
18+
19+
## Run
20+
21+
```bash
22+
npm install
23+
npm test
24+
npm run bench
25+
npm run bench:astro
26+
npm run bench:session -- /path/to/claude-session.jsonl bench/results/session.json
27+
```
28+
29+
The package has no runtime dependencies. In this monorepo it can also be compiled with the root
30+
TypeScript installation.
31+
32+
`bench:astro` replays reads of real files from the installed Astro package. Its path oracle marks
33+
route and manifest files as relevant, so the result is an upper bound on reducible context rather
34+
than a Jev accuracy claim. Replace that oracle with `JevJudge` to run the same fixture live.
35+
36+
Latest live run over 240 file reads (60 relevant, 180 irrelevant):
37+
38+
| Judge and threshold | Relevant retained | Irrelevant retained | Reduction |
39+
|---|---:|---:|---:|
40+
| Path oracle | 60/60 | 0/180 | 73.3% |
41+
| Jev 0.5 | 52/60 | 1/180 | 94.2% |
42+
| Jev 0.3 | 60/60 | 6/180 | 79.4% |
43+
44+
The six concurrent Jev batches completed in 1.01 seconds. This is a path-labeled replay, not an
45+
end-to-end coding-task result.
46+
47+
The real-session replay produced a materially weaker result. Across checkpoints at 50%, 70%, and
48+
85% of a completed 188-call Claude Code session, threshold 0.3 retained an average 76.9% of calls
49+
whose paths or URLs appeared later and removed 20.4% of calls that did not. Threshold 0.2 retained
50+
100% but removed only 0.5%. There was no tested threshold with both safe recall and useful removal.
51+
The replay is stored in `bench/results/claude-session-297c442d.json`; it measures future-reference
52+
recall, not counterfactual task completion or billed savings.
53+
54+
## Agent A/B runner
55+
56+
`bench/agent-ab.mjs` copies a repository into isolated `native` and `jev` workspaces, runs the same
57+
Claude model and prompt with a per-run budget cap, then executes the same verification command.
58+
It defaults to a dry run because even a trivial Claude Code invocation can create an expensive
59+
system-prompt cache. Review the plan before adding `--execute yes`:
60+
61+
```bash
62+
node bench/agent-ab.mjs \
63+
--repo /path/to/repo \
64+
--prompt /path/to/task.md \
65+
--test "npm test" \
66+
--model sonnet \
67+
--budget 1
68+
```
69+
70+
The runner never copies `.git`, `node_modules`, or `dist`. Put `TYPESAFE_API_KEY` in the environment
71+
for the Jev arm. Results are written under the system temp directory, not into the source repo.
72+
73+
## Safety model
74+
75+
By default, recent messages and tool classes matching `Deploy`, `Database`, `ExternalAPI`,
76+
`Permission`, or `AskUser` are pinned. Repository reads and searches are treated as cheap to
77+
recover. Production users should replace these defaults with policies for their own tools.
78+
79+
## Evaluation
80+
81+
Compare no compaction, native summary compaction, and Jev pruning on the same task. Record total
82+
input/output tokens, billed cost, wall time, re-reads, re-runs, human interventions, and final test
83+
success. Reduction ratio alone is not a success metric.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env node
2+
import { spawn } from "node:child_process";
3+
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
4+
import { basename, join, relative, resolve } from "node:path";
5+
import { tmpdir } from "node:os";
6+
7+
const args = new Map();
8+
for (let index = 2; index < process.argv.length; index += 2) {
9+
args.set(process.argv[index], process.argv[index + 1]);
10+
}
11+
const source = args.get("--repo");
12+
const promptFile = args.get("--prompt");
13+
const testCommand = args.get("--test");
14+
const budget = Number(args.get("--budget") ?? "1");
15+
const model = args.get("--model") ?? "sonnet";
16+
const execute = args.get("--execute") === "yes";
17+
const claudeBin = args.get("--claude") ?? "/Users/desty/.local/bin/claude";
18+
const timeoutMs = Number(args.get("--timeout-ms") ?? "180000");
19+
20+
if (!source || !promptFile || !testCommand) {
21+
console.error("Usage: node bench/agent-ab.mjs --repo PATH --prompt FILE --test COMMAND [--budget 1] [--model sonnet] [--execute yes]");
22+
process.exit(2);
23+
}
24+
25+
const sourcePath = resolve(source);
26+
const prompt = await readFile(resolve(promptFile), "utf8");
27+
const runRoot = await mkdir(join(tmpdir(), "jev-context-ab"), { recursive: true }).then(() =>
28+
join(tmpdir(), "jev-context-ab", `${Date.now()}-${basename(sourcePath)}`),
29+
);
30+
await mkdir(runRoot, { recursive: true });
31+
32+
const variants = ["native", "jev"];
33+
for (const variant of variants) {
34+
const destination = join(runRoot, variant);
35+
await cp(sourcePath, destination, {
36+
recursive: true,
37+
filter: (path) => {
38+
const local = relative(sourcePath, path);
39+
return !/(?:^|\/)(?:\.git|node_modules)(?:\/|$)/.test(local);
40+
},
41+
});
42+
}
43+
44+
const plan = {
45+
source: sourcePath,
46+
runRoot,
47+
model,
48+
maxBudgetUsdPerRun: budget,
49+
testCommand,
50+
promptChars: prompt.length,
51+
variants,
52+
claudeBin,
53+
timeoutMs,
54+
};
55+
await writeFile(join(runRoot, "plan.json"), JSON.stringify(plan, null, 2));
56+
console.log(JSON.stringify(plan, null, 2));
57+
58+
if (!execute) {
59+
console.log("Dry run only. Add --execute yes after reviewing the copied workspaces and budget.");
60+
process.exit(0);
61+
}
62+
63+
async function command(command, options) {
64+
const started = performance.now();
65+
return await new Promise((resolvePromise) => {
66+
const child = spawn(command[0], command.slice(1), { ...options, stdio: ["ignore", "pipe", "pipe"] });
67+
let stdout = "";
68+
let stderr = "";
69+
child.stdout.on("data", (chunk) => (stdout += chunk));
70+
child.stderr.on("data", (chunk) => (stderr += chunk));
71+
child.on("error", (error) => resolvePromise({
72+
code: null,
73+
stdout,
74+
stderr: `${stderr}${error.message}`,
75+
wallMs: Math.round(performance.now() - started),
76+
}));
77+
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
78+
child.on("close", (code, signal) => {
79+
clearTimeout(timer);
80+
resolvePromise({
81+
code,
82+
signal,
83+
stdout,
84+
stderr,
85+
wallMs: Math.round(performance.now() - started),
86+
});
87+
});
88+
});
89+
}
90+
91+
const results = [];
92+
for (const variant of variants) {
93+
const cwd = join(runRoot, variant);
94+
const claudeArgs = [
95+
"-p", prompt,
96+
"--model", model,
97+
"--autocompact", "100k",
98+
"--max-budget-usd", String(budget),
99+
"--output-format", "json",
100+
"--permission-mode", "acceptEdits",
101+
"--permission-prompts", "none",
102+
"--allowedTools", "Read,Glob,Grep,Bash,Write,Edit",
103+
];
104+
if (variant === "jev") claudeArgs.push("--plugin-dir", resolve("."));
105+
const env = {
106+
...process.env,
107+
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1",
108+
};
109+
const agent = await command([claudeBin, ...claudeArgs], { cwd, env });
110+
const tests = await command(["/bin/zsh", "-lc", testCommand], { cwd, env: process.env });
111+
results.push({ variant, agent, tests });
112+
await writeFile(join(runRoot, `${variant}.json`), JSON.stringify({ variant, agent, tests }, null, 2));
113+
}
114+
await writeFile(join(runRoot, "results.json"), JSON.stringify(results, null, 2));
115+
console.log(JSON.stringify(results.map(({ variant, agent, tests }) => ({
116+
variant,
117+
agentExit: agent.code,
118+
agentWallMs: agent.wallMs,
119+
testExit: tests.code,
120+
testWallMs: tests.wallMs,
121+
})), null, 2));
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { readdir, readFile, stat } from "node:fs/promises";
2+
import { relative, resolve } from "node:path";
3+
import { compact } from "../src/compact.js";
4+
import { JevJudge } from "../src/jev.js";
5+
import type { Judge, Message } from "../src/types.js";
6+
7+
const astroRoot = resolve(process.cwd(), "../../node_modules/astro");
8+
const task = "Trace how Astro resolves routes and builds the route manifest without changing its public API.";
9+
10+
async function filesUnder(root: string): Promise<string[]> {
11+
const output: string[] = [];
12+
async function walk(directory: string): Promise<void> {
13+
for (const name of await readdir(directory)) {
14+
if (name === "node_modules" || name.startsWith(".")) continue;
15+
const path = resolve(directory, name);
16+
const info = await stat(path);
17+
if (info.isDirectory()) await walk(path);
18+
else if (/\.(?:js|mjs|d\.ts)$/.test(name) && info.size > 200 && info.size < 120_000) output.push(path);
19+
}
20+
}
21+
await walk(root);
22+
return output.sort();
23+
}
24+
25+
const available = await filesUnder(astroRoot);
26+
const relevantFiles = available.filter((file) => /(route|routing|manifest)/i.test(relative(astroRoot, file)));
27+
const irrelevantFiles = available.filter((file) => !/(route|routing|manifest)/i.test(relative(astroRoot, file)));
28+
const files = [...relevantFiles.slice(0, 60), ...irrelevantFiles.slice(0, 180)].sort();
29+
if (files.length < 50) throw new Error(`Expected a framework-sized fixture, found ${files.length} files`);
30+
31+
const messages: Message[] = [
32+
{ id: "goal", role: "user", parts: [{ type: "text", text: task }] },
33+
];
34+
const relevant = new Set<string>();
35+
36+
for (const [index, file] of files.entries()) {
37+
const path = relative(astroRoot, file);
38+
const callId = `read-${index}`;
39+
if (/(route|routing|manifest)/i.test(path)) relevant.add(callId);
40+
messages.push({
41+
id: `assistant-${index}`,
42+
role: "assistant",
43+
parts: [{ type: "tool_call", id: callId, name: "Read", input: { path } }],
44+
});
45+
messages.push({
46+
id: `tool-${index}`,
47+
role: "tool",
48+
parts: [{ type: "tool_result", callId, output: await readFile(file, "utf8") }],
49+
});
50+
}
51+
messages.push({
52+
id: "tail",
53+
role: "assistant",
54+
parts: [{ type: "text", text: "Continue with the route and manifest implementation files." }],
55+
});
56+
57+
const pathOracle: Judge = {
58+
async judge({ candidates }) {
59+
return candidates.map((candidate) => ({
60+
callId: candidate.id,
61+
keepCall: relevant.has(candidate.id) ? 0.98 : 0.05,
62+
keepResult: relevant.has(candidate.id) ? 0.95 : 0.02,
63+
}));
64+
},
65+
};
66+
67+
const live = process.argv.includes("--jev");
68+
const judge: Judge = live ? new JevJudge({ batchSize: 40 }) : pathOracle;
69+
70+
const started = performance.now();
71+
const result = await compact(messages, judge, {
72+
goal: task,
73+
preserveRecentMessages: 6,
74+
});
75+
const elapsed = performance.now() - started;
76+
const relevantDecisions = result.decisions.filter((decision) => relevant.has(decision.callId));
77+
const captured = new Map(result.decisions.map((decision) => [decision.callId, decision]));
78+
const threshold03 = await compact(messages, {
79+
async judge({ candidates }) {
80+
return candidates.map((candidate) => {
81+
const decision = captured.get(candidate.id);
82+
if (!decision) throw new Error(`Missing captured judgment for ${candidate.id}`);
83+
return { callId: candidate.id, keepCall: decision.keepCall, keepResult: decision.keepResult };
84+
});
85+
},
86+
}, {
87+
goal: task,
88+
preserveRecentMessages: 6,
89+
keepThreshold: 0.3,
90+
});
91+
const thresholdSweep = [0.2, 0.3, 0.4, 0.5].map((threshold) => {
92+
const retained = (keepCall: number, keepResult: number, reason: string) =>
93+
reason === "policy" || keepCall >= threshold || keepResult >= threshold;
94+
return {
95+
threshold,
96+
relevantRetained: result.decisions.filter(
97+
(decision) => relevant.has(decision.callId) &&
98+
retained(decision.keepCall, decision.keepResult, decision.reason),
99+
).length,
100+
irrelevantRetained: result.decisions.filter(
101+
(decision) => !relevant.has(decision.callId) &&
102+
retained(decision.keepCall, decision.keepResult, decision.reason),
103+
).length,
104+
};
105+
});
106+
107+
console.log(JSON.stringify({
108+
fixture: "installed astro package",
109+
frameworkFilesRead: files.length,
110+
goldRelevantCalls: relevant.size,
111+
goldRelevantRetained: relevantDecisions.filter((decision) => decision.action !== "drop").length,
112+
irrelevantRetained: result.decisions.filter(
113+
(decision) => !relevant.has(decision.callId) && decision.action !== "drop",
114+
).length,
115+
thresholdSweep,
116+
threshold03Stats: threshold03.stats,
117+
...result.stats,
118+
localDecisionMs: Number(elapsed.toFixed(2)),
119+
judge: live ? "jev-live" : "path-oracle",
120+
caveat: live
121+
? "Jev sees call metadata and conversation state, not tool-result bodies."
122+
: "Path oracle measures the reducible upper bound; it is not a Jev accuracy result.",
123+
}, null, 2));

0 commit comments

Comments
 (0)