SLOPSHOPPER

jev-compact

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…

newtoastnetwork
v0.1.1MITupdated 2026-09-24HAR5HA-7663/jev-compact
A shopper browsing a rack in a slop shop
README

jev-compact

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:

  1. Rules first, locally. Exploration output (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.
  2. Jev ranks the rest. If the result is still above the budget, the remaining judgement calls (agent reports, mutations, passing test runs) go to Jev (TypeSafe System One) in one batched request (~100–400 ms): "will the assistant need this output again to finish the task?" The lowest-scoring outputs are dropped until the budget is met.
  3. Never touched: every user and assistant message, every error or failing test output, the first message, the last N messages.
  4. A note tells the assistant what was removed so it re-runs a tool instead of guessing from memory, and the removed outputs are archived to ~/brain/archive/compaction/ (optional).
  5. Falls back to the built-in summary — on the pruned set — whenever pruning alone cannot reach the budget or anything errors. You never get a worse outcome than today.

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.

Install

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.

Configuration

claude plugin install jev-compact@jev-compact --config targetPercent=45 --config preserveRecentMessages=12

optiondefaultmeaning
targetPercent45prune down to this share of the context window (estimated)
compactAtPercent75context percentage at which a finished turn triggers compaction (acted on by jev-compact-trigger, which reads this row)
preserveRecentMessages12newest messages never touched
minReductionRatio0.25below this reduction the built-in summary runs on the pruned set
modeljev-1.13.0pinned Jev model
brainArchivetruewrite 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.

What Jev sees

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.

Development

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.

Log

~/.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).

Credits

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.

Source 2 files
hooks/jev-compact.ts 143 lines
1/**
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};
143
src/core.ts 321 lines
1/**
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