Context compaction that prunes instead of summarising: rules drop exploration output and hook noise locally, Jev ranks the rest against a budget, a note says…

Context compaction for Claude Code that prunes instead of summarising.
The built-in /compact asks the model to rewrite your whole conversation as a summary: slow, lossy, and it replaces every tool output — including the ones you still need — with prose. jev-compact keeps the conversation as it is and removes only what is provably dead weight:
Read, Grep, Glob, read-only Bash, snapshots, MCP reads) older than the recent window is replaced by a one-line stub. Hook noise (<system-reminder> blocks, hook chatter) is stripped from old user turns. Long tool inputs (heredoc scripts, Write contents, big Edit strings) are shortened — the files are on disk. Nothing leaves the machine for this step.~/brain/archive/compaction/ (optional).On a real 100k-token Claude Code window: 54 % smaller in 19 ms, no Jev call needed. On a 35k window: 37 % from rules, then the built-in summary handles the rest. First live compaction on a 270k-token session: 55 % smaller (270k → 121k), 164 exploration outputs pruned, 19 error/test outputs kept, zero Jev calls.
Requires Claude Code ≥ 2.1.274 with function hooks enabled and a TypeSafe API key. Enable function hooks for every launcher (terminal, desktop app, claude agents daemon) by putting the flag in ~/.claude/settings.json rather than in your shell:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add HAR5HA-7663/jev-compact
claude plugin install jev-compact@jev-compact
claude plugin install jev-compact-trigger@jev-compact
Two plugins, one feature. jev-compact holds the session.compact hook (the pruning). jev-compact-trigger watches context use after every turn and starts a compaction at compactAtPercent. They are separate because the engine skips a plugin's own session.compact hook when that plugin raises the compaction — a trigger inside jev-compact would only ever get the built-in summary. Sessions already running when you install do not pick the plugins up; restart them.
The key is read from TYPESAFE_API_KEY in the environment, else from a TYPESAFE_API_KEY= line in ~/.env. Without a key the rule-based pruning still runs; only the Jev ranking is skipped.
Nothing about how you use Claude Code changes: /compact and auto-compact behave as before, just faster and with less lost.
claude plugin install jev-compact@jev-compact --config targetPercent=45 --config preserveRecentMessages=12
| option | default | meaning |
|---|---|---|
targetPercent | 45 | prune down to this share of the context window (estimated) |
compactAtPercent | 75 | context percentage at which a finished turn triggers compaction (acted on by jev-compact-trigger, which reads this row) |
preserveRecentMessages | 12 | newest messages never touched |
minReductionRatio | 0.25 | below this reduction the built-in summary runs on the pruned set |
model | jev-1.13.0 | pinned Jev model |
brainArchive | true | write removed outputs to ~/brain/archive/compaction/<date>/ |
Opt out for a session or a repo without disabling the plugin: JEV_COMPACT_OFF=1, a .jev-compact-off file in the working directory, or [[jev:private]] anywhere in the conversation — pruning stays local, nothing is sent to Jev.
Only what it needs to rank: the first user message (the task), the latest user message, the recent assistant text, and for each candidate the tool name, the head of its input and the head/tail of its output — secrets masked (bearer tokens, *_KEY=…, URL passwords). It never sees the whole conversation.
npx -y tsx --test tests/core.test.mts # unit tests (no network)
npx -y -p typescript tsc --noEmit -p tsconfig.json
TYPESAFE_API_KEY=… npx -y tsx bench/run.mts messages.json [--no-jev] [--target 45]
src/core.ts is pure (no engine access) so it can be benchmarked on converted transcripts; hooks/jev-compact.ts is the thin Claude Code hook around it.
~/.local/state/jev/compact.log — one line per compaction: reduction, what was pruned, Jev requests, and whether the built-in summary ran afterwards (hybrid). The trigger adds auto N% >= T% -> compacted | skipped (…) | failed: …; JEV_COMPACT_DEBUG=1 adds one line per turn with the context percentage. The engine's own view is in ~/.claude/debug/<session>.txt (or --debug-file).
Started from tamaratran/fast-jev-compaction, which scores every message with Jev. This fork moves most decisions into local rules, prunes tool inputs, keeps errors and user text unconditionally, batches Jev into one request, and adds the note, the archive and the fallback.
MIT.
hooks/jev-compact.ts 143 lines1/**
2 * jev-compact — Claude Code function hook. Replaces the compaction step:
3 * rules prune exploration output and hook noise locally (nothing leaves the machine),
4 * Jev ranks only the remaining judgement calls against a context budget,
5 * a note tells the assistant what was removed, and the removed outputs are archived
6 * for the brain. Falls back to the built-in summary whenever it cannot do better.
7 *
8 * Config (plugin userConfig): targetPercent, compactAtPercent, preserveRecentMessages,
9 * minReductionRatio, model, brainArchive. Key: TYPESAFE_API_KEY from the environment, else
10 * read from ~/.env (the [personal] block). Opt-out: JEV_COMPACT_OFF=1, a `.jev-compact-off`
11 * file in the working directory, or `[[jev:private]]` anywhere in the conversation — those
12 * still get the local rule-based pruning, but nothing is sent to Jev.
13 */
14import type { On, PluginOptions, Register, SessionMessage } from 'claude-code';
15import { compactMessages, mask, reductionRatio, type Message, type Result } from '../src/core.js';
16
17const JEV_URL = 'https://api.typesafe.ai/v1/systemone';
18
19type Config = { targetPercent: number; compactAtPercent: number; preserveRecentMessages: number; minReductionRatio: number; model: string; brainArchive: boolean };
20
21function num(o: PluginOptions, k: string, d: number): number { const v = o[k]; return typeof v === 'number' && Number.isFinite(v) ? v : d; }
22function str(o: PluginOptions, k: string, d: string): string { const v = o[k]; return typeof v === 'string' && v ? v : d; }
23function bool(o: PluginOptions, k: string, d: boolean): boolean { const v = o[k]; return typeof v === 'boolean' ? v : d; }
24
25export function resolveConfig(o: PluginOptions): Config {
26 return { targetPercent: num(o, 'targetPercent', 45), compactAtPercent: num(o, 'compactAtPercent', 75),
27 preserveRecentMessages: num(o, 'preserveRecentMessages', 12), minReductionRatio: num(o, 'minReductionRatio', 0.25),
28 model: str(o, 'model', 'jev-1.13.0'), brainArchive: bool(o, 'brainArchive', true) };
29}
30
31/** TYPESAFE_API_KEY from the process, else the first such line in ~/.env. Never logged. */
32async function apiKey($: any): Promise<string | undefined> {
33 const fromEnv = await $.env.get('TYPESAFE_API_KEY');
34 if (fromEnv) return fromEnv;
35 const home = (await $.env.get('HOME')) ?? '';
36 try {
37 const text: string = await $.fs.read(`${home}/.env`);
38 const m = text.match(/^\s*TYPESAFE_API_KEY\s*=\s*"?([^"\n]+)"?\s*$/m);
39 return m?.[1]?.trim();
40 } catch { return undefined; }
41}
42
43async function optedOut($: any, messages: readonly SessionMessage[]): Promise<string | null> {
44 if ((await $.env.get('JEV_COMPACT_OFF')) === '1') return 'JEV_COMPACT_OFF=1';
45 if (await $.fs.exists('.jev-compact-off')) return '.jev-compact-off present';
46 if (messages.some((m) => m.text.includes('[[jev:private]]'))) return '[[jev:private]] marker';
47 return null;
48}
49
50function asker($: any, key: string, model: string) {
51 return async (state: string, questions: Record<string, { type: 'noul'; instructions: string }>) => {
52 const res = await $.http.fetch(JEV_URL, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
53 body: JSON.stringify({ model, state, questions }) });
54 if (!res.ok) throw new Error(`Jev HTTP ${res.status}`);
55 const data = JSON.parse(res.text) as { answers?: Record<string, { noul?: number }> };
56 const out: Record<string, number> = {};
57 for (const [k, v] of Object.entries(data.answers ?? {})) if (typeof v?.noul === 'number') out[k] = v.noul;
58 return out;
59 };
60}
61
62/** Same objects back where nothing changed (handle intact); rebuilt messages carry no handle. */
63function toSession(input: readonly SessionMessage[], output: readonly Message[]): SessionMessage[] {
64 const own = new Set<Message>(input as readonly Message[]);
65 return output.map((m) => (own.has(m) ? (m as SessionMessage) : { role: m.role, text: m.text, toolUses: m.toolUses as SessionMessage['toolUses'], ...(m.toolResults ? { toolResults: m.toolResults as SessionMessage['toolResults'] } : {}) }));
66}
67
68async function appendLog($: any, line: string): Promise<void> {
69 try {
70 const home = (await $.env.get('HOME')) ?? '';
71 const p = `${home}/.local/state/jev/compact.log`;
72 let prev = '';
73 try { prev = await $.fs.read(p); } catch { /* first write */ }
74 if (prev.length > 200_000) prev = prev.slice(-100_000);
75 await $.fs.write(p, `${prev}${new Date().toISOString().slice(0, 19)} ${line}\n`);
76 } catch { /* logging is best-effort */ }
77}
78
79async function archive($: any, result: Result, agentId: string | undefined): Promise<void> {
80 if (result.dropped.length === 0) return;
81 try {
82 const home = (await $.env.get('HOME')) ?? '';
83 const d = new Date().toISOString();
84 const path = `${home}/brain/archive/compaction/${d.slice(0, 10)}/${agentId ?? 'main'}-${d.slice(11, 19).replace(/:/g, '')}.jsonl`;
85 const lines = result.dropped.map((x) => JSON.stringify({ tool: x.tool, input: x.input, result: x.result.slice(0, 20_000) }));
86 let text = lines.join('\n') + '\n';
87 if (text.length > 2_000_000) text = text.slice(0, 2_000_000);
88 await $.fs.write(path, text);
89 } catch { /* archive is best-effort */ }
90}
91
92function summary(r: Result): string {
93 const s = r.stats;
94 const pct = Math.round(reductionRatio(r) * 100);
95 const acts = r.decisions.reduce<Record<string, number>>((a, d) => ((a[d.action] = (a[d.action] ?? 0) + 1), a), {});
96 const kinds = Object.entries(s.byKind).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
97 return `${pct}% smaller (${Math.round(s.charsBefore / 4 / 1000)}k→${Math.round(s.charsAfter / 4 / 1000)}k est. tokens, target ${Math.round(s.targetChars / 4 / 1000)}k) · ` +
98 `pruned ${acts['result_pruned'] ?? 0} exploration, ${acts['jev_dropped'] ?? 0}/${s.jevCandidates} judgement calls, ${Math.round(s.noiseChars / 4)} tok noise · kept ${acts['rule_kept'] ?? 0} errors/tests · ${s.jevRequests} Jev req · calls: ${kinds}`;
99}
100
101export const register: Register = (on: On, options: PluginOptions) => {
102 const cfg = resolveConfig(options);
103
104 on('session.compact', async ($: any, event: any, next: any) => {
105 if (event.trigger === 'precompute') return next(event);
106 try {
107 const reason = await optedOut($, event.messages);
108 const key = reason ? undefined : await apiKey($);
109 const usage = await $.session.usage();
110 const windowTokens = usage?.context?.window ?? 200_000;
111 const tokens = usage?.context?.tokens;
112 const charsPerToken = tokens ? Math.min(5, Math.max(3, event.messages.reduce((n: number, m: SessionMessage) => n + m.text.length + JSON.stringify(m.toolUses).length + JSON.stringify(m.toolResults ?? []).length, 0) / tokens)) : 4;
113 const result = await compactMessages(event.messages as Message[], { preserveRecentMessages: cfg.preserveRecentMessages, targetPercent: cfg.targetPercent, minReductionRatio: cfg.minReductionRatio, charsPerToken, noJev: !key },
114 { ask: key ? asker($, key, cfg.model) : async () => ({}), windowTokens, now: new Date() });
115 const ratio = reductionRatio(result);
116 const line = summary(result) + (reason ? ` · Jev skipped (${reason})` : key ? '' : ' · Jev skipped (no key)');
117 if (cfg.brainArchive) await archive($, result, event.agentId);
118 const messages = toSession(event.messages, result.messages);
119 if (ratio < cfg.minReductionRatio || !result.stats.underTarget) {
120 // Pruning alone is not enough: let the built-in summary work on the pruned set, which is
121 // never worse than summarising the raw one (less noise in, same text out).
122 $.ui.toast(`jev-compact: ${line} · summarising the rest`, { timeoutMs: 12_000 });
123 await appendLog($, `hybrid ${line}`);
124 return next({ ...event, messages });
125 }
126 $.ui.toast(`jev-compact: ${line}`, { timeoutMs: 12_000 });
127 $.ui.log(`jev-compact: ${line}`);
128 await appendLog($, `pruned ${line}`);
129 return { messages };
130 } catch (error) {
131 const msg = mask(error instanceof Error ? error.message : String(error));
132 $.ui.log(`jev-compact: fallback to built-in summary (${msg})`);
133 await appendLog($, `error ${msg}`);
134 return next(event);
135 }
136 });
137
138 // The automatic trigger lives in the companion plugin `jev-compact-trigger` (trigger/): the
139 // engine skips a plugin's own session.compact hook when that plugin raised the compaction, so
140 // a $.session.compact() from here would run the built-in summary instead of the pruning above.
141 // `compactAtPercent` stays in this plugin's config; the trigger reads it through $.config.list().
142};
143src/core.ts 321 lines1/**
2 * jev-compact core: rule-based pruning first, Jev only for the calls that need judgement,
3 * a budget instead of a fixed threshold, and a note that says what was removed.
4 *
5 * Pure: no engine, no fs, no network. The hook and the offline bench both drive it through
6 * `compactMessages(messages, options, ctx)`.
7 */
8
9export type Role = 'user' | 'assistant';
10export interface ToolUse { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string; isError?: boolean }
11export interface ToolResult { tool_use_id: string; text: string; isError?: boolean }
12export interface Message { role: Role; text: string; toolUses: ToolUse[]; toolResults?: ToolResult[]; handle?: string }
13
14export type Kind = 'explore' | 'mutate' | 'verify' | 'agent' | 'other';
15export type Action = 'pinned' | 'kept' | 'result_pruned' | 'result_truncated' | 'jev_dropped' | 'jev_kept' | 'rule_kept';
16export const INPUT_KEEP = 600; // chars of a tool input kept verbatim on older calls
17
18export interface Decision { id: string; tool: string; kind: Kind; action: Action; chars: number; p?: number; head: string; inputTrimmed?: number }
19
20export interface Options {
21 /** Newest messages never touched. */
22 preserveRecentMessages: number;
23 /** Context share to prune down to, as a percentage of the window (chars, estimated). */
24 targetPercent: number;
25 /** Below this estimated char reduction the built-in summary runs instead. */
26 minReductionRatio: number;
27 /** Chars of a pruned/truncated result kept as a stub. */
28 stubChars: number;
29 /** Estimated chars per token, used to turn the token window into a char budget. */
30 charsPerToken: number;
31 /** Do not send anything to Jev (rules only). */
32 noJev: boolean;
33}
34
35export const DEFAULTS: Options = {
36 preserveRecentMessages: 12, targetPercent: 45, minReductionRatio: 0.25, stubChars: 200, charsPerToken: 4, noJev: false,
37};
38
39export interface Ctx {
40 /** Ask Jev: returns the noul probability per question name. */
41 ask: (state: string, questions: Record<string, { type: 'noul'; instructions: string }>) => Promise<Record<string, number>>;
42 /** Context window in tokens, from $.session.usage(). */
43 windowTokens: number;
44 now: Date;
45}
46
47export interface Result {
48 messages: Message[];
49 decisions: Decision[];
50 dropped: Array<{ tool: string; input: Record<string, unknown>; result: string }>;
51 note: string;
52 stats: { charsBefore: number; charsAfter: number; targetChars: number; messagesBefore: number; messagesAfter: number;
53 byKind: Record<Kind, number>; jevRequests: number; jevCandidates: number; jevDropped: number; noiseChars: number; underTarget: boolean };
54}
55
56// ---------------------------------------------------------------- classification
57
58const EXPLORE_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'ToolSearch', 'NotebookRead', 'TodoRead',
59 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'ReadMcpResourceDirTool', 'LSP']);
60const MUTATE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
61const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
62
63// Anything that can change state. Everything else in Bash is treated as exploration: on real
64// transcripts commands are compound (`cd x; grep …`), so an allowlist of read-only verbs misses them.
65const BASH_WRITEISH = /(>|>>|\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\bchmod\b|\bchown\b|\btee\b|\bsed -i|\btouch\b|\bln\b|\binstall\b|\bgit (commit|push|merge|rebase|reset|checkout|switch|restore|stash|branch -[dD]|tag|am|apply|cherry-pick|revert|clean)\b|\bnpm (i|install|ci|publish|run|start)\b|\bpnpm\b|\byarn\b|\bpip3? install\b|\buv (tool|pip|add)\b|\bbrew (install|uninstall)\b|\blaunchctl\b|\bscp\b|\brsync\b|\bssh\b|\bcurl\b.*(-X (POST|PUT|PATCH|DELETE)|-d |--data)|\bpython3? -\b|\bpython3? [^-\s]|\bnode -e\b|\bnode [^-\s]|\bosascript\b|\bkill|\bpbcopy\b|\bgh (pr (create|merge|edit|close|ready|comment)|issue (create|comment|close)|api .*-f |repo (edit|create|delete))|\bagent-browser [a-z-]* ?(open|click|fill|type|press|select|close|record|cookies|upload|download|navigate)|\bjab (click|fill|select|do)|\bhunch\b|\bjev-replicate\b|\bwith-env\b.*\b(sh -c|python|node)|\bdefaults write|\bsecurity\b)/;
66const BASH_VERIFY = /(\bvitest\b|\bjest\b|\bpytest\b|\bnpm (run )?test\b|\bpnpm test\b|\byarn test\b|\btsc\b|\beslint\b|\bruff\b|\bmypy\b|\bgo test\b|\bcargo test\b|\bmake test\b|unittest|\bnpm run build\b|\bnext build\b|\bjab check\b|\bcurl\b.*-w ['"]?%\{http_code|\bgh pr checks\b|\bgh run (view|watch)\b|--dry-run|\bbash -n\b|\bplutil -lint\b|python3? -m py_compile)/;
67const ERRORISH = /(\bFAIL(ED)?\b|\bError\b|error:|Traceback|Exception|✗|✘|exit code [1-9]|command not found|Permission denied|ENOENT|ETIMEDOUT|rc=[1-9])/;
68
69export function classify(tool: string, input: Record<string, unknown>): Kind {
70 if (EXPLORE_TOOLS.has(tool)) return 'explore';
71 if (MUTATE_TOOLS.has(tool)) return 'mutate';
72 if (AGENT_TOOLS.has(tool)) return 'agent';
73 if (tool === 'Bash') {
74 const cmd = String(input['command'] ?? '');
75 if (BASH_VERIFY.test(cmd)) return 'verify';
76 // `2>/dev/null`, `2>&1` and `>/dev/null` are not writes
77 const scrubbed = cmd.replace(/\d?>&?\s*\/dev\/null|2>&1|>\s*\/dev\/null/g, ' ');
78 return BASH_WRITEISH.test(scrubbed) ? 'mutate' : 'explore';
79 }
80 if (tool.startsWith('mcp__')) {
81 const name = tool.split('__').pop() ?? '';
82 if (/(^|_)(get|list|read|search|fetch|query|describe|snapshot|find|screenshot|tabs_context|read_page|get_page_text|stats|resolve|explore)/.test(name)) return 'explore';
83 if (/(^|_)(send|create|update|delete|write|post|add|remove|move|set|publish|complete|upload|change|run|invoke|execute|navigate|form_input|computer|click|type|schedule|react|import|manage|start|stop|cancel|buy)/.test(name)) return 'mutate';
84 return 'other';
85 }
86 return 'other';
87}
88
89// ---------------------------------------------------------------- helpers
90
91export function mask(text: string): string {
92 return text
93 .replace(/(bearer\s+)[A-Za-z0-9._\-]{12,}/gi, '$1<redacted>')
94 .replace(/\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}/g, '<redacted>')
95 .replace(/\b(ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}/g, '<redacted>')
96 .replace(/\bapikey_[0-9a-f]{20,}[0-9a-f_]*/g, '<redacted>')
97 .replace(/\bsk-[A-Za-z0-9\-_]{20,}/g, '<redacted>')
98 .replace(/\bxox[abp]-[A-Za-z0-9\-]{10,}/g, '<redacted>')
99 .replace(/\b([A-Z0-9_]*(?:secret|token|password|passwd|api_key|apikey)[A-Z0-9_]*)(\s*[=:]\s*)(['"]?)[^\s'"]{6,}\3/gi, '$1$2<redacted>')
100 .replace(/(postgres(?:ql)?:\/\/[^:\s]+:)[^@\s]+(@)/gi, '$1<redacted>$2');
101}
102
103/** Hook/system noise in an older user message: system reminders, persisted output, hook banners. */
104export function stripNoise(text: string): { text: string; removed: number } {
105 const before = text.length;
106 let out = text
107 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
108 .replace(/<persisted-output>[\s\S]*?<\/persisted-output>/g, '[persisted output omitted]')
109 .replace(/^(PostToolUse|PreToolUse|SessionStart|UserPromptSubmit):[^\n]*hook[^\n]*\n(?:(?!\n\n)[\s\S])*?(?=\n\n|$)/gm, '[hook output omitted]')
110 .replace(/^\[Request interrupted by user[^\]]*\]\s*$/gm, '')
111 .replace(/\n{3,}/g, '\n\n')
112 .trim();
113 return { text: out, removed: before - out.length };
114}
115
116function inputHead(input: Record<string, unknown>, n = 90): string {
117 let s = String(input['command'] ?? input['file_path'] ?? input['pattern'] ?? input['url'] ?? input['description'] ?? input['prompt'] ?? JSON.stringify(input));
118 if (input['command'] !== undefined) {
119 // show the meaningful part: drop leading `cd …;`, variable assignments and function definitions
120 s = s.replace(/^(\s*(cd\s+\S+|export\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S+|[a-z_]+\(\)\s*\{[^}]*\})\s*(;|&&)?\s*)+/, '');
121 }
122 return s.replace(/\s+/g, ' ').slice(0, n);
123}
124
125function trimInput(tool: string, input: Record<string, unknown>): { input: Record<string, unknown>; saved: number } | null {
126 let saved = 0;
127 const out: Record<string, unknown> = { ...input };
128 const cut = (key: string, keep: number, note: string) => {
129 const v = out[key];
130 if (typeof v === 'string' && v.length > keep + 80) { saved += v.length - keep - note.length; out[key] = `${v.slice(0, keep)}\n[… ${v.length - keep} chars ${note}]`; }
131 };
132 if (tool === 'Write') cut('content', 300, 'written to the file on disk — read it if needed');
133 else if (tool === 'Edit' || tool === 'MultiEdit') { cut('old_string', 300, 'omitted — the edit is applied on disk'); cut('new_string', 400, 'omitted — the edit is applied on disk'); }
134 else if (tool === 'Bash') cut('command', INPUT_KEEP, 'of script omitted — its effect is in the result and the assistant text');
135 else for (const k of Object.keys(out)) cut(k, 800, 'omitted');
136 return saved > 0 ? { input: out, saved } : null;
137}
138
139function headTail(text: string, head: number, tail: number): string {
140 if (text.length <= head + tail + 20) return text;
141 return `${text.slice(0, head)}\n[… ${text.length - head - tail} chars omitted …]\n${text.slice(-tail)}`;
142}
143
144export function sizeOf(messages: readonly Message[]): number {
145 let n = 0;
146 for (const m of messages) {
147 n += m.text.length;
148 for (const u of m.toolUses) n += JSON.stringify(u.input).length + 40;
149 for (const r of m.toolResults ?? []) n += r.text.length + 20;
150 }
151 return n;
152}
153
154// ---------------------------------------------------------------- the pass
155
156interface Cand { id: string; tool: string; kind: Kind; mi: number; ri: number; input: Record<string, unknown>; result: ToolResult; evidence: string }
157
158export async function compactMessages(input: readonly Message[], options: Partial<Options>, ctx: Ctx): Promise<Result> {
159 const opt: Options = { ...DEFAULTS, ...options };
160 const messages: Message[] = input.map((m) => m); // same objects until we rebuild one
161 const rebuilt = new Set<number>();
162 const charsBefore = sizeOf(messages);
163 // Budget: the configured share of the window, but a compaction always cuts at least ~45% of
164 // what is there — otherwise a manual /compact at 50% context would trim 5% and change nothing.
165 const targetChars = Math.min(Math.floor(ctx.windowTokens * (opt.targetPercent / 100) * opt.charsPerToken), Math.floor(charsBefore * 0.55));
166 const pinnedFrom = Math.max(1, messages.length - opt.preserveRecentMessages);
167 const decisions: Decision[] = [];
168 const dropped: Result['dropped'] = [];
169 const byKind: Record<Kind, number> = { explore: 0, mutate: 0, verify: 0, agent: 0, other: 0 };
170 let noiseChars = 0;
171
172 // result lookup: tool_use_id -> (message index, result index)
173 const where = new Map<string, { mi: number; ri: number }>();
174 messages.forEach((m, mi) => (m.toolResults ?? []).forEach((r, ri) => where.set(r.tool_use_id, { mi, ri })));
175
176 const setResult = (mi: number, ri: number, text: string) => {
177 const m = messages[mi]!;
178 const results = (m.toolResults ?? []).map((r, i) => (i === ri ? { ...r, text } : r));
179 messages[mi] = { role: m.role, text: m.text, toolUses: m.toolUses, toolResults: results };
180 rebuilt.add(mi);
181 };
182 const setInput = (mi: number, ui: number, input: Record<string, unknown>) => {
183 const m = messages[mi]!;
184 const uses = m.toolUses.map((u, i) => (i === ui ? { ...u, input } : u));
185 const next: Message = { role: m.role, text: m.text, toolUses: uses };
186 if (m.toolResults) next.toolResults = m.toolResults;
187 messages[mi] = next;
188 rebuilt.add(mi);
189 };
190 const setText = (mi: number, text: string) => {
191 const m = messages[mi]!;
192 const next: Message = { role: m.role, text, toolUses: m.toolUses };
193 if (m.toolResults) next.toolResults = m.toolResults;
194 messages[mi] = next;
195 rebuilt.add(mi);
196 };
197
198 // 1. noise out of older user text
199 for (let mi = 1; mi < pinnedFrom; mi++) {
200 const m = messages[mi]!;
201 if (m.role === 'user' && m.text) {
202 const { text, removed } = stripNoise(m.text);
203 if (removed > 0) { noiseChars += removed; setText(mi, text); }
204 }
205 }
206
207 // 2. rules per tool call
208 const candidates: Cand[] = [];
209 let n = 0;
210 for (let mi = 0; mi < messages.length; mi++) {
211 const m = messages[mi]!;
212 if (m.role !== 'assistant') continue;
213 for (let ui = 0; ui < m.toolUses.length; ui++) {
214 const u = m.toolUses[ui]!;
215 const id = `c${++n}`;
216 const loc = where.get(u.tool_use_id);
217 const result = loc ? messages[loc.mi]!.toolResults![loc.ri]! : undefined;
218 const chars = result?.text.length ?? 0;
219 const kind = classify(u.tool, u.input);
220 byKind[kind]++;
221 const head = `${u.tool} ${inputHead(u.input)}`;
222 const pinned = mi === 0 || mi >= pinnedFrom || !loc || loc.mi >= pinnedFrom;
223 if (pinned || !result) { decisions.push({ id, tool: u.tool, kind, action: 'pinned', chars, head }); continue; }
224 // Large inputs of older calls (heredoc scripts, Write contents, big Edit strings) are the bulk of
225 // real transcripts. The effect is on disk and in the assistant's text; keep a head only.
226 const trimmed = trimInput(u.tool, u.input);
227 if (trimmed) setInput(mi, ui, trimmed.input);
228 const inputTrimmed = trimmed?.saved;
229 const isErr = result.isError || u.isError || (kind === 'verify' && ERRORISH.test(result.text.slice(0, 4000)));
230 if (kind === 'explore') {
231 if (chars > opt.stubChars) {
232 dropped.push({ tool: u.tool, input: u.input, result: result.text });
233 setResult(loc.mi, loc.ri, `[pruned ${chars} chars of ${u.tool} output — re-run if needed] ${result.text.slice(0, 120).replace(/\s+/g, ' ')}`);
234 decisions.push({ id, tool: u.tool, kind, action: 'result_pruned', chars, head, inputTrimmed });
235 } else decisions.push({ id, tool: u.tool, kind, action: 'kept', chars, head, inputTrimmed });
236 } else if (kind === 'mutate') {
237 if (chars > opt.stubChars) {
238 setResult(loc.mi, loc.ri, headTail(result.text, opt.stubChars, 0));
239 decisions.push({ id, tool: u.tool, kind, action: 'result_truncated', chars, head, inputTrimmed });
240 } else decisions.push({ id, tool: u.tool, kind, action: 'kept', chars, head, inputTrimmed });
241 } else if (isErr) {
242 if (chars > 4000) setResult(loc.mi, loc.ri, headTail(result.text, 2500, 1200));
243 decisions.push({ id, tool: u.tool, kind, action: 'rule_kept', chars, head, inputTrimmed });
244 } else if (chars <= opt.stubChars * 3) {
245 decisions.push({ id, tool: u.tool, kind, action: 'kept', chars, head, inputTrimmed });
246 } else {
247 candidates.push({ id, tool: u.tool, kind, mi: loc.mi, ri: loc.ri, input: u.input, result,
248 evidence: headTail(result.text, kind === 'agent' ? 700 : 350, 350) });
249 }
250 }
251 }
252
253 // 3. budget: only if still above target do we ask Jev about the remaining candidates
254 let jevRequests = 0, jevDropped = 0;
255 let after = sizeOf(messages);
256 if (after > targetChars && candidates.length > 0 && !opt.noJev) {
257 const goal = messages[0]!.text.slice(0, 1500);
258 const latest = [...messages].reverse().find((m) => m.role === 'user' && m.text.trim())?.text.slice(0, 800) ?? '';
259 const recent = messages.slice(-8).filter((m) => m.role === 'assistant' && m.text.trim()).map((m) => m.text.slice(0, 300)).join('\n---\n');
260 const scores = new Map<string, number>();
261 for (let i = 0; i < candidates.length; i += 30) {
262 const batch = candidates.slice(i, i + 30);
263 const state = mask([
264 'A coding assistant conversation is being compacted. TASK is what the user originally asked; LATEST is the newest user message; RECENT is what the assistant said last. Each CALL below is an older tool call whose full output is a candidate for deletion. The assistant can always re-run a tool; deleting is only wrong when the exact output is still needed to finish the current work.',
265 `TASK: ${goal}`, `LATEST: ${latest}`, `RECENT:\n${recent}`,
266 ...batch.map((c) => `CALL ${c.id} [${c.kind}] ${c.tool} ${inputHead(c.input, 200)}\nOUTPUT:\n${c.evidence}`),
267 ].join('\n\n'));
268 const questions: Record<string, { type: 'noul'; instructions: string }> = {};
269 for (const c of batch) questions[c.id] = { type: 'noul', instructions: `Is the exact OUTPUT of CALL ${c.id} still needed to finish the current work described by TASK and LATEST, such that re-running the tool later would be worse than keeping this output now?` };
270 const answers = await ctx.ask(state, questions);
271 jevRequests++;
272 for (const c of batch) scores.set(c.id, answers[c.id] ?? 0.5);
273 }
274 const ranked = [...candidates].sort((a, b) => (scores.get(a.id) ?? 0.5) - (scores.get(b.id) ?? 0.5));
275 for (const c of ranked) {
276 const p = scores.get(c.id) ?? 0.5;
277 if (after <= targetChars) { decisions.push({ id: c.id, tool: c.tool, kind: c.kind, action: 'jev_kept', chars: c.result.text.length, p, head: `${c.tool} ${inputHead(c.input)}` }); continue; }
278 dropped.push({ tool: c.tool, input: c.input, result: c.result.text });
279 setResult(c.mi, c.ri, `[pruned ${c.result.text.length} chars of ${c.tool} output (p=${p.toFixed(2)}) — re-run if needed] ${c.result.text.slice(0, 120).replace(/\s+/g, ' ')}`);
280 decisions.push({ id: c.id, tool: c.tool, kind: c.kind, action: 'jev_dropped', chars: c.result.text.length, p, head: `${c.tool} ${inputHead(c.input)}` });
281 jevDropped++;
282 after = sizeOf(messages);
283 }
284 } else {
285 for (const c of candidates) decisions.push({ id: c.id, tool: c.tool, kind: c.kind, action: 'kept', chars: c.result.text.length, head: `${c.tool} ${inputHead(c.input)}` });
286 }
287
288 // 4. the note: what is gone, so the assistant re-runs instead of guessing
289 const note = buildNote(decisions, ctx.now);
290 const out = [...messages];
291 if (note) out.splice(1, 0, { role: 'user', text: note, toolUses: [] });
292
293 after = sizeOf(out);
294 return {
295 messages: out, decisions, dropped, note,
296 stats: { charsBefore, charsAfter: after, targetChars, messagesBefore: input.length, messagesAfter: out.length, byKind,
297 jevRequests, jevCandidates: candidates.length, jevDropped, noiseChars, underTarget: after <= targetChars },
298 };
299}
300
301export function reductionRatio(r: Result): number {
302 return r.stats.charsBefore === 0 ? 0 : 1 - r.stats.charsAfter / r.stats.charsBefore;
303}
304
305function buildNote(decisions: Decision[], now: Date): string {
306 const pruned = decisions.filter((d) => d.action === 'result_pruned' || d.action === 'jev_dropped');
307 if (pruned.length === 0 && !decisions.some((d) => d.inputTrimmed)) return '';
308 const groups = new Map<string, string[]>();
309 for (const d of pruned) {
310 const key = d.tool === 'Bash' ? (d.kind === 'verify' ? 'test/build runs' : 'Bash commands') : d.tool;
311 const list = groups.get(key) ?? [];
312 list.push(d.head.replace(/^\S+\s*/, '').slice(0, 50));
313 groups.set(key, list);
314 }
315 const parts = [...groups.entries()].map(([k, v]) => `${v.length} ${k}${v.length <= 4 ? ` (${v.join('; ')})` : ` (e.g. ${v.slice(0, 3).join('; ')})`}`);
316 const trimmedInputs = decisions.filter((d) => d.inputTrimmed).length;
317 if (trimmedInputs) parts.push(`${trimmedInputs} long tool inputs shortened (scripts, file contents — the files are on disk)`);
318 const ts = now.toISOString().slice(0, 16).replace('T', ' ');
319 return `[jev-compact ${ts}] Context was pruned to make room. The full outputs of these older tool calls were removed and replaced by one-line stubs: ${parts.join('; ')}. Everything else is verbatim: all user and assistant text, every Edit/Write target path (long file contents/scripts are shortened, the files are on disk), every failing or error output, and the last messages. If you need a pruned output, re-run the tool rather than recall it from memory. The complete history remains in the session transcript on disk.`.slice(0, 1200);
320}
321