Jev-scored context pruning for Claude Code through OpenRouter: at compaction every tool call and result is scored by TypeSafe's Jev decision model, stale ones…

A Claude Code mod that prunes the context window with TypeSafe's Jev decision model, called through OpenRouter. At compaction, every tool call and result in the transcript is scored in one fast Jev request. Stale results are truncated, stale calls are dropped, and everything kept stays verbatim. No summary is written. An optional gate asks Jev about each oversized tool output as it arrives and cuts the middle when the full output is not needed.
Jev is a "System One" model: it takes a state plus typed questions and returns probabilities, in about 300 ms, at $0.042 per million input tokens. One compaction of a long session costs well under a cent.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Function hooks (mods) are early access and the API may change between releases.OPENROUTER_API_KEY, or the apiKey plugin option.export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export OPENROUTER_API_KEY="sk-or-..."
claude plugin marketplace add JayDoubleu/cc-mod-jev
claude plugin install jev-context@cc-mod-jev
To run from a checkout instead:
claude --plugin-dir /path/to/cc-mod-jev
Both variables can also live in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1", "OPENROUTER_API_KEY": "sk-or-..." } }
hooks/jev-context.ts registers four hooks:
session.compact: /compact, the engine's auto-compaction and the plugin's own trigger all pass through here. The transcript is scored by Jev and the pruned messages are returned in place of a summary. If the key is missing, Jev fails, or the pruning would remove less than minReductionRatio of the transcript's characters, /compact and auto-compaction fall back to the built-in summary. A plugin-triggered compaction is skipped instead, and so is the engine's precompute dispatch, so every real compaction is scored live.turn.complete: after each main-loop turn, if the context window is at least compactAtPercent full, the plugin requests a compaction. After a skip it waits until the window grew 10 more points before trying again.tool.call (the gate, off by default): after a gated tool returns, on the main loop only, and the part the gate can cut (Bash's stdout, Read's file content) is at least gateMinChars, Jev is asked whether the model needs the full output. Below gateThreshold, Bash keeps the head and tail of stdout, and Read keeps the head of the file, each with a note saying what was cut and how to get it back.command.run: /jev prints the configuration, the last compaction and its per-call decisions, and the gate decisions. /compact prunes now, since it goes through the session.compact hook.tool_use is paired with its tool_result. Calls in the first message, in the newest preserveRecentMessages messages, or still in flight are pinned. Pairs smaller than 300 characters are kept without a question.ok, 4213 chars plus a short head. The state is fitted into maxStateTokens (20k) in five stages, from full detail down to one line per call. If it still does not fit, the hook falls back.noul questions: should the call stay, and should its full output stay verbatim. Questions are batched so that state plus questions stays under maxRequestTokens (28k, under Jev's 32k window); batches run concurrently.keepThreshold: result probability at or above it keeps the pair; else call probability at or above it keeps the call and truncates the result to truncateHeadChars plus a note; else the pair is removed.One interactive session on a 1M-context model, Claude Code 2.1.278, the plugin at its defaults. The model read the 8 largest files of a repository, ran git log, find, grep, git status and git branch: 22 tool calls, 541,928 characters of results, 314,058 tokens of context. Then /compact:
| Context after, as the engine recorded it | 11,399 tokens (96% fewer) |
| Messages kept | 47 of 51, none rewritten |
| Calls scored | 19: 0 kept whole, 17 truncated to their head, 2 dropped |
| Jev | 1 request, 7,776 input tokens, $0.00033, 503 ms end to end |
Afterwards the model named the 8 files and quoted the first line of the largest one from the kept head without a tool call. Asked about the last test function in that file, past the cut, it ran one grep and answered correctly, as the truncation note tells it to.
Plugin options (set at install, or in settings.json under pluginConfigs["jev-context"].options):
| Option | Default | Meaning |
|---|---|---|
apiKey | unset | OpenRouter key; else OPENROUTER_API_KEY |
model | typesafe/jev-1.13 | Model id on OpenRouter's decisions endpoint |
keepThreshold | 0.5 | Minimum probability to keep a call or its result |
preserveRecentMessages | 6 | Newest messages never pruned |
compactAtPercent | 60 | Context percent at which the plugin requests a compaction; 0 disables |
minReductionRatio | 0.2 | Pruning must remove this fraction of characters, else fall back or skip |
truncateHeadChars | 300 | Head kept of a truncated result |
gate | false | Gate oversized tool outputs |
gateTools | Bash | Comma-separated tools the gate applies to (Bash, Read) |
gateMinChars | 8000 | Outputs shorter than this are never gated |
gateThreshold | 0.3 | Cut only when P(full output needed) is below this |
log | transcript | transcript shows one dim line per decision in the session; debug keeps it to the debug log |
Every option except apiKey can be overridden by an environment variable named JEV_CONTEXT_<OPTION> in upper snake case, and again by EVAL_JEV_CONTEXT_<OPTION>, which plugin evals can pass to a run. Examples: JEV_CONTEXT_GATE=1, JEV_CONTEXT_GATE_TOOLS=Bash,Read, JEV_CONTEXT_KEEP_THRESHOLD=0.4. Under an eval the key is read from EVAL_OPENROUTER_API_KEY.
npm install # TypeScript for the typecheck only; the mod has no runtime dependencies
npm run typecheck # tsc over hooks, src and tests against .claude/types
npm run validate # claude plugin validate .
npm test # claude plugin test . (28 tests, a fake OpenRouter beneath the plugin)
npm run types # regenerate .claude/types/claude-code.d.ts after a Claude Code update
bun run scripts/smoke.ts # one live compaction against OpenRouter; needs OPENROUTER_API_KEY
The library under src/ is plain TypeScript with no imports outside the plugin, because a hooks module runs in Claude Code's own runtime with no Node and no node_modules. hooks/jev-context.ts is the thin adapter.
Constraints the hooks loader enforces, found the hard way:
$ may only be passed to functions declared at the top of the module, never to a closure inside register.$.env.get takes a literal variable name, so every variable the module reads is spelled out in configOf.export EVAL_OPENROUTER_API_KEY="sk-or-..."
npm run eval # claude plugin eval . --trust-plugin
Two cases under evals/, both driven by /jev, so a run costs no model call in the with-arm:
jev-status: a regex grader checks the status line.env-passthrough: the case sets EVAL_JEV_CONTEXT_* variables; the grader checks that the key and the options reached the mod.The without-arm runs the same prompts with no plugin, where /jev is an unknown command, so Δ is positive when the mod loads.
What the evals cannot see: inside a claude plugin eval child session of Claude Code 2.1.278, a tool.call hook's own { result } is not what the model reads. The gate ran and Jev answered, but the trace, the child's transcript and the token counts all showed the full output. The same headless run outside the harness (--permission-mode dontAsk, sandbox on) shows the cut in both the stream and the transcript. A compaction cannot be driven from an eval prompt either: $.session.compact() is refused from a command.run hook, and a fresh session has nothing to prune. So the gate and the pruning are verified by claude plugin test and by two live scripts:
scripts/gate-smoke.sh # one headless session; checks the stream for the cut note
bun run scripts/smoke.ts # one live compaction; prints decisions, tokens and cost
npm run smoke # both
/jev command, and an eval suite.hooks/jev-context.ts 373 lines1import type {
2 EngineInterface,
3 PluginOptions,
4 Register,
5 SessionMessage,
6 ToolCallResult,
7} from 'claude-code';
8
9import { compact, reductionRatio } from '../src/compact.ts';
10import { describeConfig, resolveConfig, type Config } from '../src/config.ts';
11import { cutResult, GATE_QUESTION, gateQuestions, gateState, outputOf } from '../src/gate.ts';
12import { askerOver, type ClientConfig, type Transport } from '../src/openrouter.ts';
13import { noulOf } from '../src/questions.ts';
14import type { CompactResult } from '../src/types.ts';
15
16const PLUGIN = 'jev-context';
17const LOG_LINE_MAX_CHARS = 3800;
18
19type LastCompaction = {
20 trigger: string;
21 outcome: 'pruned' | 'fallback' | 'skipped';
22 reason?: string;
23 summary?: string;
24 decisions: string[];
25};
26
27type GateRecord = { tool: string; chars: number; need: number; cut: boolean };
28
29/** What the hooks share across dispatches for one activation of the plugin. */
30type Activation = {
31 options: PluginOptions;
32 config?: Config;
33 last?: LastCompaction;
34 gates: GateRecord[];
35 compacting: boolean;
36 skippedAtPercent: number;
37};
38
39/**
40 * The engine prefixes every toast, log line and command answer with the
41 * plugin's name, so the texts below carry none.
42 */
43
44/** One line per scored call: `t3:Bash:drop_call/call=0.12/result=0.08`. */
45export function decisionLines(result: CompactResult): string[] {
46 return result.decisions
47 .filter((d) => d.action !== 'pinned' && d.action !== 'small')
48 .map(
49 (d) =>
50 `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`,
51 );
52}
53
54/** The decision lines joined into chunks a `$.ui.log` line can hold. */
55export function chunkLines(lines: readonly string[], maxChars = LOG_LINE_MAX_CHARS): string[] {
56 const chunks: string[] = [];
57 let current = '';
58 for (const line of lines) {
59 const joined = current ? `${current} ${line}` : line;
60 if (current && joined.length > maxChars) {
61 chunks.push(current);
62 current = line;
63 } else {
64 current = joined;
65 }
66 }
67 if (current) chunks.push(current);
68 return chunks;
69}
70
71export function summarize(result: CompactResult): string {
72 const s = result.stats;
73 const percent = Math.round(reductionRatio(result) * 100);
74 return (
75 `${percent}% fewer chars (${s.charsBefore} to ${s.charsAfter}), ` +
76 `${s.messagesAfter}/${s.messagesBefore} messages; ` +
77 `${s.candidates} scored: ${s.kept} kept, ${s.truncated} truncated, ${s.dropped} dropped; ` +
78 `${s.pinned} pinned, ${s.small} small; ` +
79 `state ~${s.stateTokens} tokens (stage ${s.stateStage}); ` +
80 `${s.requests} Jev request(s), ${s.jevInputTokens} input tokens, $${s.jevCostUsd.toFixed(5)}`
81 );
82}
83
84function errorText(error: unknown): string {
85 return error instanceof Error ? error.message : String(error);
86}
87
88/**
89 * The configuration, read once per activation: the plugin options, then the
90 * environment (`$.env.get` takes literal names, so every variable the module
91 * reads is listed here, one per `CONFIG_KEYS` entry and prefix), then the
92 * `env` block of settings.json for the key.
93 */
94async function configOf($: EngineInterface, activation: Activation): Promise<Config> {
95 if (activation.config) return activation.config;
96 const values = await Promise.all([
97 $.env.get('OPENROUTER_API_KEY'),
98 $.env.get('EVAL_OPENROUTER_API_KEY'),
99 $.env.get('JEV_CONTEXT_MODEL'),
100 $.env.get('EVAL_JEV_CONTEXT_MODEL'),
101 $.env.get('JEV_CONTEXT_BASE_URL'),
102 $.env.get('EVAL_JEV_CONTEXT_BASE_URL'),
103 $.env.get('JEV_CONTEXT_GOAL'),
104 $.env.get('EVAL_JEV_CONTEXT_GOAL'),
105 $.env.get('JEV_CONTEXT_KEEP_THRESHOLD'),
106 $.env.get('EVAL_JEV_CONTEXT_KEEP_THRESHOLD'),
107 $.env.get('JEV_CONTEXT_PRESERVE_RECENT_MESSAGES'),
108 $.env.get('EVAL_JEV_CONTEXT_PRESERVE_RECENT_MESSAGES'),
109 $.env.get('JEV_CONTEXT_MIN_PAIR_CHARS'),
110 $.env.get('EVAL_JEV_CONTEXT_MIN_PAIR_CHARS'),
111 $.env.get('JEV_CONTEXT_TRUNCATE_HEAD_CHARS'),
112 $.env.get('EVAL_JEV_CONTEXT_TRUNCATE_HEAD_CHARS'),
113 $.env.get('JEV_CONTEXT_MAX_STATE_TOKENS'),
114 $.env.get('EVAL_JEV_CONTEXT_MAX_STATE_TOKENS'),
115 $.env.get('JEV_CONTEXT_MAX_REQUEST_TOKENS'),
116 $.env.get('EVAL_JEV_CONTEXT_MAX_REQUEST_TOKENS'),
117 $.env.get('JEV_CONTEXT_COMPACT_AT_PERCENT'),
118 $.env.get('EVAL_JEV_CONTEXT_COMPACT_AT_PERCENT'),
119 $.env.get('JEV_CONTEXT_MIN_REDUCTION_RATIO'),
120 $.env.get('EVAL_JEV_CONTEXT_MIN_REDUCTION_RATIO'),
121 $.env.get('JEV_CONTEXT_GATE'),
122 $.env.get('EVAL_JEV_CONTEXT_GATE'),
123 $.env.get('JEV_CONTEXT_GATE_TOOLS'),
124 $.env.get('EVAL_JEV_CONTEXT_GATE_TOOLS'),
125 $.env.get('JEV_CONTEXT_GATE_MIN_CHARS'),
126 $.env.get('EVAL_JEV_CONTEXT_GATE_MIN_CHARS'),
127 $.env.get('JEV_CONTEXT_GATE_THRESHOLD'),
128 $.env.get('EVAL_JEV_CONTEXT_GATE_THRESHOLD'),
129 $.env.get('JEV_CONTEXT_GATE_HEAD_CHARS'),
130 $.env.get('EVAL_JEV_CONTEXT_GATE_HEAD_CHARS'),
131 $.env.get('JEV_CONTEXT_GATE_TAIL_CHARS'),
132 $.env.get('EVAL_JEV_CONTEXT_GATE_TAIL_CHARS'),
133 $.env.get('JEV_CONTEXT_LOG'),
134 $.env.get('EVAL_JEV_CONTEXT_LOG'),
135 ]);
136 const env: Record<string, string | undefined> = {
137 OPENROUTER_API_KEY: values[0],
138 EVAL_OPENROUTER_API_KEY: values[1],
139 JEV_CONTEXT_MODEL: values[2],
140 EVAL_JEV_CONTEXT_MODEL: values[3],
141 JEV_CONTEXT_BASE_URL: values[4],
142 EVAL_JEV_CONTEXT_BASE_URL: values[5],
143 JEV_CONTEXT_GOAL: values[6],
144 EVAL_JEV_CONTEXT_GOAL: values[7],
145 JEV_CONTEXT_KEEP_THRESHOLD: values[8],
146 EVAL_JEV_CONTEXT_KEEP_THRESHOLD: values[9],
147 JEV_CONTEXT_PRESERVE_RECENT_MESSAGES: values[10],
148 EVAL_JEV_CONTEXT_PRESERVE_RECENT_MESSAGES: values[11],
149 JEV_CONTEXT_MIN_PAIR_CHARS: values[12],
150 EVAL_JEV_CONTEXT_MIN_PAIR_CHARS: values[13],
151 JEV_CONTEXT_TRUNCATE_HEAD_CHARS: values[14],
152 EVAL_JEV_CONTEXT_TRUNCATE_HEAD_CHARS: values[15],
153 JEV_CONTEXT_MAX_STATE_TOKENS: values[16],
154 EVAL_JEV_CONTEXT_MAX_STATE_TOKENS: values[17],
155 JEV_CONTEXT_MAX_REQUEST_TOKENS: values[18],
156 EVAL_JEV_CONTEXT_MAX_REQUEST_TOKENS: values[19],
157 JEV_CONTEXT_COMPACT_AT_PERCENT: values[20],
158 EVAL_JEV_CONTEXT_COMPACT_AT_PERCENT: values[21],
159 JEV_CONTEXT_MIN_REDUCTION_RATIO: values[22],
160 EVAL_JEV_CONTEXT_MIN_REDUCTION_RATIO: values[23],
161 JEV_CONTEXT_GATE: values[24],
162 EVAL_JEV_CONTEXT_GATE: values[25],
163 JEV_CONTEXT_GATE_TOOLS: values[26],
164 EVAL_JEV_CONTEXT_GATE_TOOLS: values[27],
165 JEV_CONTEXT_GATE_MIN_CHARS: values[28],
166 EVAL_JEV_CONTEXT_GATE_MIN_CHARS: values[29],
167 JEV_CONTEXT_GATE_THRESHOLD: values[30],
168 EVAL_JEV_CONTEXT_GATE_THRESHOLD: values[31],
169 JEV_CONTEXT_GATE_HEAD_CHARS: values[32],
170 EVAL_JEV_CONTEXT_GATE_HEAD_CHARS: values[33],
171 JEV_CONTEXT_GATE_TAIL_CHARS: values[34],
172 EVAL_JEV_CONTEXT_GATE_TAIL_CHARS: values[35],
173 JEV_CONTEXT_LOG: values[36],
174 EVAL_JEV_CONTEXT_LOG: values[37],
175 };
176 if (!env.OPENROUTER_API_KEY && !env.EVAL_OPENROUTER_API_KEY && !activation.options.apiKey) {
177 const settings = await $.settings.read();
178 const block = settings['env'];
179 if (block && typeof block === 'object') {
180 const value = (block as Record<string, unknown>)['OPENROUTER_API_KEY'];
181 if (typeof value === 'string' && value) env.OPENROUTER_API_KEY = value;
182 }
183 }
184 activation.config = resolveConfig(activation.options, env);
185 return activation.config;
186}
187
188function clientOf(config: Config): ClientConfig {
189 return { apiKey: config.apiKey ?? '', model: config.model, baseUrl: config.baseUrl };
190}
191
192/** A transport over the engine's `$.http.fetch`. */
193function transportOf($: EngineInterface): Transport {
194 return async (url, init) => {
195 const response = await $.http.fetch(url, init);
196 return { status: response.status, ok: response.ok, text: response.text };
197 };
198}
199
200async function log($: EngineInterface, config: Config, text: string): Promise<void> {
201 await $.ui.log(text, { to: config.log });
202}
203
204/** Records a compaction the plugin did not do and says why. */
205async function noteFallback(
206 $: EngineInterface,
207 activation: Activation,
208 config: Config,
209 trigger: string,
210 reason: string,
211): Promise<void> {
212 const pluginTriggered = trigger === 'plugin';
213 activation.last = {
214 trigger,
215 outcome: pluginTriggered ? 'skipped' : 'fallback',
216 reason,
217 decisions: [],
218 };
219 await log(
220 $,
221 config,
222 pluginTriggered ? `compaction skipped: ${reason}` : `built-in summary used: ${reason}`,
223 );
224}
225
226/** The `/jev` status text. */
227function statusText(activation: Activation, config: Config): string {
228 const lines = [describeConfig(config)];
229 const last = activation.last;
230 if (last) {
231 lines.push(`last compaction (${last.trigger}): ${last.outcome}${last.reason ? `, ${last.reason}` : ''}`);
232 if (last.summary) lines.push(last.summary);
233 for (const line of last.decisions) lines.push(` ${line}`);
234 } else {
235 lines.push('no compaction yet this session; /compact runs a Jev-scored one');
236 }
237 if (activation.gates.length > 0) {
238 lines.push('gate decisions:');
239 for (const gate of activation.gates) {
240 lines.push(
241 ` ${gate.tool} ${gate.chars} chars: P(need full)=${gate.need.toFixed(2)}, ${gate.cut ? 'cut' : 'kept'}`,
242 );
243 }
244 }
245 return lines.join('\n');
246}
247
248export const register: Register = (on, options) => {
249 const activation: Activation = {
250 options,
251 gates: [],
252 compacting: false,
253 skippedAtPercent: -1,
254 };
255
256 on('session.start', async ($, e, next) => {
257 await $.command.register({
258 name: 'jev',
259 description:
260 'jev-context: configuration, last compaction and gate decisions; /compact prunes now',
261 });
262 return next(e);
263 });
264
265 on('session.compact', { trigger: 'precompute' }, () => ({
266 skip: `${PLUGIN} prunes at compaction time`,
267 }));
268
269 on('session.compact', async ($, e, next) => {
270 const config = await configOf($, activation);
271 const pluginTriggered = e.trigger === 'plugin';
272 let reason: string | undefined;
273 if (!config.apiKey) {
274 reason = 'no OpenRouter key: set OPENROUTER_API_KEY or the apiKey plugin option';
275 } else {
276 try {
277 const result = await compact(e.messages, askerOver(transportOf($), clientOf(config)), config);
278 const ratio = reductionRatio(result);
279 const summary = summarize(result);
280 const lines = decisionLines(result);
281 for (const chunk of chunkLines(lines)) {
282 await $.ui.log(`decisions: ${chunk}`, { to: 'debug' });
283 }
284 if (ratio >= config.minReductionRatio) {
285 activation.last = { trigger: e.trigger, outcome: 'pruned', summary, decisions: lines };
286 activation.skippedAtPercent = -1;
287 await log($, config, `pruned verbatim, no summary: ${summary}`);
288 await $.ui.toast(
289 `kept ${result.messages.length}/${e.messages.length} messages verbatim, ${Math.round(ratio * 100)}% fewer chars`,
290 { timeoutMs: 8000 },
291 );
292 return { messages: result.messages as SessionMessage[] };
293 }
294 reason = `reduction ${Math.round(ratio * 100)}% is below the ${Math.round(config.minReductionRatio * 100)}% minimum (${summary})`;
295 } catch (error) {
296 reason = errorText(error);
297 }
298 }
299 await noteFallback($, activation, config, e.trigger, reason);
300 if (pluginTriggered) return { skip: `${PLUGIN}: ${reason}` };
301 return next(e);
302 });
303
304 on('turn.complete', async ($, e, next) => {
305 if (e.agentId || activation.compacting) return next(e);
306 const config = await configOf($, activation);
307 if (config.compactAtPercent <= 0 || !config.apiKey) return next(e);
308 try {
309 const usage = await $.session.usage();
310 const percent = usage.context.percent ?? 0;
311 if (percent < config.compactAtPercent) return next(e);
312 if (activation.skippedAtPercent >= 0 && percent < activation.skippedAtPercent + 10) {
313 return next(e);
314 }
315 activation.compacting = true;
316 try {
317 const outcome = await $.session.compact();
318 if (outcome.skip !== undefined) activation.skippedAtPercent = percent;
319 } finally {
320 activation.compacting = false;
321 }
322 } catch (error) {
323 await log($, config, `auto-compaction failed: ${errorText(error)}`);
324 }
325 return next(e);
326 });
327
328 on('tool.call', async ($, e, next) => {
329 const outcome = await next(e);
330 if (e.agentId !== undefined) return outcome;
331 const config = await configOf($, activation);
332 if (!config.gate || !config.apiKey || !config.gateTools.includes(e.tool)) return outcome;
333 if (outcome.deny !== undefined || outcome.isError) return outcome;
334 const output = outputOf(e.tool, outcome.result);
335 if (!output || output.length < config.gateMinChars) return outcome;
336 if (output.length <= config.gateHeadChars + config.gateTailChars) return outcome;
337 try {
338 const {
339 tool: _tool,
340 tool_use_id: _id,
341 agentId: _agent,
342 consent: _consent,
343 ...input
344 } = e as Record<string, unknown>;
345 const recent = await $.session.messages();
346 const state = gateState(recent, e.tool, input, output, config, config.goal);
347 const response = await askerOver(transportOf($), clientOf(config)).ask(state, gateQuestions());
348 const need = noulOf(response.answers, GATE_QUESTION);
349 const result = need < config.gateThreshold ? cutResult(e.tool, outcome.result, config) : undefined;
350 const cut = result !== undefined;
351 activation.gates.push({ tool: e.tool, chars: output.length, need, cut });
352 if (activation.gates.length > 20) activation.gates.shift();
353 await log(
354 $,
355 config,
356 `gate ${e.tool} ${output.length} chars: P(need full)=${need.toFixed(2)}, ${cut ? 'cut' : 'kept'}`,
357 );
358 if (!cut) return outcome;
359 const answer: ToolCallResult = { result } as ToolCallResult;
360 if (outcome.context !== undefined) answer.context = outcome.context;
361 return answer;
362 } catch (error) {
363 await log($, config, `gate skipped: ${errorText(error)}`);
364 return outcome;
365 }
366 });
367
368 on('command.run', { command: 'jev' }, async ($) => {
369 const config = await configOf($, activation);
370 return { text: statusText(activation, config) };
371 });
372};
373src/compact.ts 185 lines1import { applyDecisions } from './apply.ts';
2import { fitState, goalOf } from './state.ts';
3import { estimateTokens, transcriptChars } from './tokens.ts';
4import { collectToolCalls, pairChars } from './transcript.ts';
5import { callQuestionName, decide, noulOf, questionsFor, resultQuestionName } from './questions.ts';
6import type {
7 CompactOptions,
8 CompactResult,
9 CompactStats,
10 Decision,
11 JevAnswer,
12 JevAsker,
13 JevQuestions,
14 Message,
15 ToolCall,
16} from './types.ts';
17
18export const DEFAULT_OPTIONS: CompactOptions = {
19 keepThreshold: 0.5,
20 preserveRecentMessages: 6,
21 minPairChars: 300,
22 truncateHeadChars: 300,
23 maxStateTokens: 20000,
24 maxRequestTokens: 28000,
25};
26
27const REQUEST_OVERHEAD_TOKENS = 200;
28
29function finite(value: unknown, fallback: number): number {
30 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
31}
32
33export function resolveOptions(options: Partial<CompactOptions> = {}): CompactOptions {
34 const resolved: CompactOptions = {
35 keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
36 preserveRecentMessages: Math.max(
37 0,
38 Math.floor(finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages)),
39 ),
40 minPairChars: Math.max(0, finite(options.minPairChars, DEFAULT_OPTIONS.minPairChars)),
41 truncateHeadChars: Math.max(
42 0,
43 Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
44 ),
45 maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
46 maxRequestTokens: Math.max(1, finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens)),
47 };
48 if (options.goal) resolved.goal = options.goal;
49 return resolved;
50}
51
52/**
53 * Splits the candidates into batches whose questions, with the (always
54 * complete) state, fit one request; throws when one call alone does not fit.
55 */
56export function batchCalls(
57 calls: readonly ToolCall[],
58 stateTokens: number,
59 maxRequestTokens: number,
60): ToolCall[][] {
61 const budget = maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
62 const batches: ToolCall[][] = [];
63 let current: ToolCall[] = [];
64 let currentTokens = 0;
65 for (const call of calls) {
66 const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
67 if (current.length === 0 && tokens > budget) {
68 throw new Error(
69 `the questions for ${call.id} do not fit beside the state within ${maxRequestTokens} tokens`,
70 );
71 }
72 if (current.length > 0 && currentTokens + tokens > budget) {
73 batches.push(current);
74 current = [];
75 currentTokens = 0;
76 }
77 current.push(call);
78 currentTokens += tokens;
79 }
80 if (current.length > 0) batches.push(current);
81 return batches;
82}
83
84function emptyStats(messages: readonly Message[], calls: readonly ToolCall[]): CompactStats {
85 const chars = transcriptChars(messages);
86 return {
87 messagesBefore: messages.length,
88 messagesAfter: messages.length,
89 charsBefore: chars,
90 charsAfter: chars,
91 calls: calls.length,
92 candidates: 0,
93 kept: 0,
94 truncated: 0,
95 dropped: 0,
96 pinned: calls.filter((call) => call.pinned).length,
97 small: 0,
98 stateTokens: 0,
99 stateStage: 0,
100 requests: 0,
101 jevInputTokens: 0,
102 jevCostUsd: 0,
103 };
104}
105
106/**
107 * Scores every non-pinned tool call with Jev and returns the transcript with
108 * stale calls dropped and stale results truncated, everything else verbatim.
109 * Throws on a Jev failure, a malformed answer, or a state that cannot fit.
110 */
111export async function compact(
112 messages: readonly Message[],
113 asker: JevAsker,
114 partial: Partial<CompactOptions> = {},
115): Promise<CompactResult> {
116 const options = resolveOptions(partial);
117 const calls = collectToolCalls(messages, options.preserveRecentMessages);
118 const stats = emptyStats(messages, calls);
119 const decisions: Decision[] = [];
120 const candidates: ToolCall[] = [];
121 for (const call of calls) {
122 const chars = pairChars(call);
123 if (call.pinned) {
124 decisions.push({ id: call.id, tool: call.tool, action: 'pinned', keepCall: 1, keepResult: 1, chars });
125 } else if (chars < options.minPairChars) {
126 stats.small += 1;
127 decisions.push({ id: call.id, tool: call.tool, action: 'small', keepCall: 1, keepResult: 1, chars });
128 } else {
129 candidates.push(call);
130 }
131 }
132 stats.candidates = candidates.length;
133 if (candidates.length === 0) {
134 return { messages: [...messages], decisions, stats };
135 }
136
137 const goal = goalOf(messages, options.goal);
138 const fitted = fitState(messages, calls, goal, options.maxStateTokens);
139 stats.stateTokens = fitted.tokens;
140 stats.stateStage = fitted.stage;
141
142 const batches = batchCalls(candidates, fitted.tokens, options.maxRequestTokens);
143 const answers: Record<string, JevAnswer> = {};
144 const responses = await Promise.all(
145 batches.map((batch) => {
146 const questions: JevQuestions = {};
147 for (const call of batch) Object.assign(questions, questionsFor(call));
148 return asker.ask(fitted.state, questions);
149 }),
150 );
151 for (const response of responses) {
152 Object.assign(answers, response.answers);
153 stats.jevInputTokens += response.usage?.input_tokens ?? 0;
154 stats.jevCostUsd += response.usage?.cost ?? 0;
155 }
156 stats.requests = batches.length;
157
158 for (const call of candidates) {
159 const decision = decide(
160 call,
161 noulOf(answers, callQuestionName(call)),
162 noulOf(answers, resultQuestionName(call)),
163 options.keepThreshold,
164 options.truncateHeadChars,
165 );
166 decisions.push(decision);
167 if (decision.action === 'keep') stats.kept += 1;
168 else if (decision.action === 'truncate_result') stats.truncated += 1;
169 else stats.dropped += 1;
170 }
171 decisions.sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
172
173 const output = applyDecisions(messages, calls, decisions, options.truncateHeadChars);
174 stats.messagesAfter = output.length;
175 stats.charsAfter = transcriptChars(output);
176 return { messages: output, decisions, stats };
177}
178
179/** The fraction of characters the pruning removed, 0 to 1. */
180export function reductionRatio(result: CompactResult): number {
181 const { charsBefore, charsAfter } = result.stats;
182 if (charsBefore === 0) return 0;
183 return (charsBefore - charsAfter) / charsBefore;
184}
185src/config.ts 179 lines1import { DEFAULT_MODEL, OPENROUTER_DECISIONS_URL } from './openrouter.ts';
2
3export type LogSink = 'transcript' | 'debug';
4
5export type Config = {
6 apiKey?: string;
7 model: string;
8 baseUrl: string;
9 goal?: string;
10 keepThreshold: number;
11 preserveRecentMessages: number;
12 minPairChars: number;
13 truncateHeadChars: number;
14 maxStateTokens: number;
15 maxRequestTokens: number;
16 compactAtPercent: number;
17 minReductionRatio: number;
18 gate: boolean;
19 gateTools: string[];
20 gateMinChars: number;
21 gateThreshold: number;
22 gateHeadChars: number;
23 gateTailChars: number;
24 log: LogSink;
25};
26
27export const DEFAULT_CONFIG: Config = {
28 model: DEFAULT_MODEL,
29 baseUrl: OPENROUTER_DECISIONS_URL,
30 keepThreshold: 0.5,
31 preserveRecentMessages: 6,
32 minPairChars: 300,
33 truncateHeadChars: 300,
34 maxStateTokens: 20000,
35 maxRequestTokens: 28000,
36 compactAtPercent: 60,
37 minReductionRatio: 0.2,
38 gate: false,
39 gateTools: ['Bash'],
40 gateMinChars: 8000,
41 gateThreshold: 0.3,
42 gateHeadChars: 2500,
43 gateTailChars: 1500,
44 log: 'transcript',
45};
46
47/** The keys an environment variable may override, `JEV_CONTEXT_<KEY>` or `EVAL_JEV_CONTEXT_<KEY>`. */
48export const CONFIG_KEYS = [
49 'model',
50 'baseUrl',
51 'goal',
52 'keepThreshold',
53 'preserveRecentMessages',
54 'minPairChars',
55 'truncateHeadChars',
56 'maxStateTokens',
57 'maxRequestTokens',
58 'compactAtPercent',
59 'minReductionRatio',
60 'gate',
61 'gateTools',
62 'gateMinChars',
63 'gateThreshold',
64 'gateHeadChars',
65 'gateTailChars',
66 'log',
67] as const;
68
69export type ConfigKey = (typeof CONFIG_KEYS)[number];
70
71export const API_KEY_VARIABLES = ['OPENROUTER_API_KEY', 'EVAL_OPENROUTER_API_KEY'] as const;
72
73export function envName(key: ConfigKey, prefix = ''): string {
74 return `${prefix}JEV_CONTEXT_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`;
75}
76
77/** Every environment variable the plugin reads. */
78export function envNames(): string[] {
79 const names: string[] = [...API_KEY_VARIABLES];
80 for (const key of CONFIG_KEYS) names.push(envName(key), envName(key, 'EVAL_'));
81 return names;
82}
83
84type Raw = string | number | boolean | readonly string[] | undefined;
85
86function asNumber(value: Raw): number | undefined {
87 if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
88 if (typeof value === 'string' && value.trim() !== '') {
89 const parsed = Number(value);
90 return Number.isFinite(parsed) ? parsed : undefined;
91 }
92 return undefined;
93}
94
95function asBoolean(value: Raw): boolean | undefined {
96 if (typeof value === 'boolean') return value;
97 if (typeof value === 'string') {
98 const lowered = value.trim().toLowerCase();
99 if (['1', 'true', 'on', 'yes'].includes(lowered)) return true;
100 if (['0', 'false', 'off', 'no', ''].includes(lowered)) return false;
101 }
102 return undefined;
103}
104
105function asList(value: Raw): string[] | undefined {
106 if (Array.isArray(value)) return value.map(String).filter(Boolean);
107 if (typeof value === 'string') {
108 return value
109 .split(',')
110 .map((s) => s.trim())
111 .filter(Boolean);
112 }
113 return undefined;
114}
115
116function asString(value: Raw): string | undefined {
117 return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
118}
119
120/**
121 * The plugin's configuration: the manifest's `userConfig` values, overridden
122 * by `JEV_CONTEXT_*` and then `EVAL_JEV_CONTEXT_*` environment variables (the
123 * latter so a plugin eval can configure a run); a variable set to the empty
124 * string counts as unset. The API key comes from the
125 * `apiKey` option, else `OPENROUTER_API_KEY`, else `EVAL_OPENROUTER_API_KEY`.
126 */
127export function resolveConfig(
128 options: Readonly<Record<string, Raw>>,
129 env: Readonly<Record<string, string | undefined>>,
130): Config {
131 const set = (value: string | undefined): string | undefined =>
132 value !== undefined && value.trim() !== '' ? value : undefined;
133 const raw = (key: ConfigKey): Raw =>
134 set(env[envName(key, 'EVAL_')]) ?? set(env[envName(key)]) ?? options[key];
135 const config: Config = { ...DEFAULT_CONFIG, gateTools: [...DEFAULT_CONFIG.gateTools] };
136 const apiKey = asString(options.apiKey) ?? asString(env.OPENROUTER_API_KEY) ?? asString(env.EVAL_OPENROUTER_API_KEY);
137 if (apiKey) config.apiKey = apiKey;
138 config.model = asString(raw('model')) ?? config.model;
139 config.baseUrl = asString(raw('baseUrl')) ?? config.baseUrl;
140 const goal = asString(raw('goal'));
141 if (goal) config.goal = goal;
142 for (const key of [
143 'keepThreshold',
144 'preserveRecentMessages',
145 'minPairChars',
146 'truncateHeadChars',
147 'maxStateTokens',
148 'maxRequestTokens',
149 'compactAtPercent',
150 'minReductionRatio',
151 'gateMinChars',
152 'gateThreshold',
153 'gateHeadChars',
154 'gateTailChars',
155 ] as const) {
156 const value = asNumber(raw(key));
157 if (value !== undefined) config[key] = value;
158 }
159 config.gate = asBoolean(raw('gate')) ?? config.gate;
160 config.gateTools = asList(raw('gateTools')) ?? config.gateTools;
161 const log = asString(raw('log'));
162 if (log === 'transcript' || log === 'debug') config.log = log;
163 return config;
164}
165
166/** The configuration as one line, for `/jev`. */
167export function describeConfig(config: Config): string {
168 const parts = [
169 `model=${config.model}`,
170 `key=${config.apiKey ? 'set' : 'MISSING'}`,
171 `keepThreshold=${config.keepThreshold}`,
172 `preserveRecent=${config.preserveRecentMessages}`,
173 `compactAt=${config.compactAtPercent}%`,
174 `minReduction=${Math.round(config.minReductionRatio * 100)}%`,
175 `gate=${config.gate ? `on(${config.gateTools.join(',')} ≥${config.gateMinChars}ch <${config.gateThreshold})` : 'off'}`,
176 ];
177 return parts.join(' ');
178}
179src/gate.ts 129 lines1import { abridge, goalOf } from './state.ts';
2import type { JevQuestions, JevState, Message } from './types.ts';
3
4export const GATE_NOTE_PREFIX = '[jev-context cut';
5
6export type GateOptions = {
7 gateTools: readonly string[];
8 gateMinChars: number;
9 gateThreshold: number;
10 gateHeadChars: number;
11 gateTailChars: number;
12};
13
14export const GATE_QUESTION = 'need_full_output';
15
16/**
17 * The part of a fresh tool result the gate can cut, per tool: Bash's stdout,
18 * Read's file content. Undefined for any other tool or shape, so the gate
19 * never judges an output it could not cut.
20 */
21export function outputOf(tool: string, result: unknown): string | undefined {
22 if (result === null || typeof result !== 'object') return undefined;
23 const record = result as Record<string, unknown>;
24 if (tool === 'Bash') return typeof record.stdout === 'string' ? record.stdout : undefined;
25 if (tool === 'Read') {
26 const file = record.file;
27 if (file && typeof file === 'object') {
28 const content = (file as Record<string, unknown>).content;
29 if (typeof content === 'string') return content;
30 }
31 }
32 return undefined;
33}
34
35/** Head and tail of an output around a note saying what was cut. */
36export function cutOutput(output: string, head: number, tail: number, hint: string): string {
37 if (output.length <= head + tail) return output;
38 const removed = output.length - head - tail;
39 const note = `\n\n${GATE_NOTE_PREFIX} ${removed} chars from the middle of this output: judged not needed in full. ${hint}]\n\n`;
40 return `${output.slice(0, head)}${note}${tail > 0 ? output.slice(-tail) : ''}`;
41}
42
43/** The state the gate sends: the goal (explicit, else the last prompts), the recent conversation, and the call. */
44export function gateState(
45 recent: readonly Message[],
46 tool: string,
47 input: Record<string, unknown>,
48 output: string,
49 options: Pick<GateOptions, 'gateHeadChars' | 'gateTailChars'>,
50 goal?: string,
51): JevState {
52 const conversation = recent.slice(-12).map((message) => {
53 const entry: Record<string, unknown> = { role: message.role };
54 if (message.text.trim()) entry.text = abridge(message.text.trim(), 600);
55 if (message.toolUses.length > 0) {
56 entry.calls = message.toolUses.map(
57 (use) => `${use.tool} ${abridge(JSON.stringify(use.input ?? {}), 200)}`,
58 );
59 }
60 return entry;
61 });
62 const head = Math.min(1500, options.gateHeadChars);
63 const tail = Math.min(800, options.gateTailChars);
64 return {
65 goal: goalOf(recent, goal),
66 recent_conversation: conversation,
67 new_tool_result: {
68 tool,
69 input: abridge(JSON.stringify(input), 600),
70 output_chars: output.length,
71 output_head: output.slice(0, head),
72 output_tail: output.length > head ? output.slice(-tail) : '',
73 what_the_model_would_get_instead: `the first ${options.gateHeadChars} and last ${options.gateTailChars} characters, with a note saying how much was cut`,
74 },
75 };
76}
77
78export function gateQuestions(): JevQuestions {
79 return {
80 [GATE_QUESTION]: {
81 type: 'noul',
82 instructions:
83 'The assistant needs the complete output of this new tool result, verbatim, to continue the goal: its head and tail alone would lose information the assistant will use.',
84 criteria: {
85 true: 'The middle of the output holds specific content the assistant asked for or must inspect: file contents to edit, search hits, a stack trace, data to reason over.',
86 false: 'The output is repetitive or bulk material (a long listing, install or build logs, generated sequences) whose head and tail carry what matters, or the assistant only needs to know whether the command succeeded.',
87 },
88 },
89 };
90}
91
92/**
93 * The result record with its bulk cut, per tool: Bash keeps stdout head and
94 * tail, Read keeps the head of the file's content. Undefined when the tool is
95 * not handled or the record has no text to cut.
96 */
97export function cutResult(tool: string, result: unknown, options: GateOptions): unknown {
98 if (result === null || typeof result !== 'object') return undefined;
99 const record = result as Record<string, unknown>;
100 if (tool === 'Bash' && typeof record.stdout === 'string') {
101 if (record.stdout.length <= options.gateHeadChars + options.gateTailChars) return undefined;
102 return {
103 ...record,
104 stdout: cutOutput(
105 record.stdout,
106 options.gateHeadChars,
107 options.gateTailChars,
108 'Re-run the command, piped through head, tail or grep, to see a specific part.',
109 ),
110 };
111 }
112 if (tool === 'Read' && record.file && typeof record.file === 'object') {
113 const file = record.file as Record<string, unknown>;
114 if (typeof file.content !== 'string') return undefined;
115 const keep = options.gateHeadChars + options.gateTailChars;
116 if (file.content.length <= keep) return undefined;
117 const head = file.content.slice(0, keep);
118 const fullLines = head.split('\n').length - 1;
119 const removed = file.content.length - keep;
120 const startLine = typeof file.startLine === 'number' ? file.startLine : 1;
121 const content = `${head}\n${GATE_NOTE_PREFIX} ${removed} chars after this point: judged not needed in full. Read again with offset=${startLine + fullLines} to see the rest.]`;
122 return {
123 ...record,
124 file: { ...file, content, numLines: content.split('\n').length },
125 };
126 }
127 return undefined;
128}
129src/openrouter.ts 80 lines1import type { JevAsker, JevQuestions, JevResponse, JevState } from './types.ts';
2
3export const OPENROUTER_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
4export const DEFAULT_MODEL = 'typesafe/jev-1.13';
5
6export type JevRequest = {
7 url: string;
8 method: 'POST';
9 headers: Record<string, string>;
10 body: string;
11};
12
13export type TransportResponse = { status: number; ok: boolean; text: string };
14
15/** The shape of `$.http.fetch`, so the client runs over the engine or a fake. */
16export type Transport = (
17 url: string,
18 init: { method: string; headers: Record<string, string>; body: string },
19) => Promise<TransportResponse>;
20
21export type ClientConfig = {
22 apiKey: string;
23 model?: string;
24 baseUrl?: string;
25};
26
27/** The HTTP request for one Jev call on OpenRouter's decisions endpoint. */
28export function buildRequest(
29 config: ClientConfig,
30 state: JevState,
31 questions: JevQuestions,
32): JevRequest {
33 return {
34 url: config.baseUrl ?? OPENROUTER_DECISIONS_URL,
35 method: 'POST',
36 headers: {
37 authorization: `Bearer ${config.apiKey}`,
38 'content-type': 'application/json',
39 'x-title': 'jev-context (Claude Code mod)',
40 },
41 body: JSON.stringify({ model: config.model ?? DEFAULT_MODEL, state, questions }),
42 };
43}
44
45/** Validates a response body; throws on anything but an `answers` object. */
46export function parseResponse(status: number, ok: boolean, text: string): JevResponse {
47 if (!ok) throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
48 let parsed: unknown;
49 try {
50 parsed = JSON.parse(text);
51 } catch {
52 throw new Error('Jev returned malformed JSON');
53 }
54 if (
55 parsed === null ||
56 typeof parsed !== 'object' ||
57 !('answers' in parsed) ||
58 parsed.answers === null ||
59 typeof parsed.answers !== 'object'
60 ) {
61 throw new Error('Jev response is missing answers');
62 }
63 return parsed as JevResponse;
64}
65
66/** A `JevAsker` over any transport. */
67export function askerOver(transport: Transport, config: ClientConfig): JevAsker {
68 return {
69 async ask(state, questions) {
70 const request = buildRequest(config, state, questions);
71 const response = await transport(request.url, {
72 method: request.method,
73 headers: request.headers,
74 body: request.body,
75 });
76 return parseResponse(response.status, response.ok, response.text);
77 },
78 };
79}
80src/questions.ts 67 lines1import type { Decision, JevAnswer, JevQuestions, ToolCall } from './types.ts';
2import { pairChars } from './transcript.ts';
3
4export function callQuestionName(call: ToolCall): string {
5 return `call_${call.id}`;
6}
7
8export function resultQuestionName(call: ToolCall): string {
9 return `result_${call.id}`;
10}
11
12/**
13 * The two `noul` questions asked about one call, phrased as statements so a
14 * high probability means "keep": should the call stay, should its full
15 * output stay verbatim.
16 */
17export function questionsFor(call: ToolCall): JevQuestions {
18 return {
19 [callQuestionName(call)]: {
20 type: 'noul',
21 instructions: `Tool call ${call.id} (${call.tool}) should stay in the conversation history: knowing that this call was made, with its input, still matters for what the assistant does next on the goal.`,
22 criteria: {
23 true: 'The call is still relevant: later steps build on it, or the assistant needs to know it happened.',
24 false: 'The call is stale: superseded by a later call, finished with, or unrelated to the goal.',
25 },
26 },
27 [resultQuestionName(call)]: {
28 type: 'noul',
29 instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.resultChars} chars) should stay in the history verbatim: the assistant still needs its exact contents, and re-running the tool would not do.`,
30 criteria: {
31 true: 'The exact output is still needed: it holds details the assistant will refer back to and cannot cheaply reproduce.',
32 false: 'The output has served its purpose: it was acted on, superseded by a later result, or can be reproduced by re-running the tool.',
33 },
34 },
35 };
36}
37
38/** The `noul` probability of one answer; throws when it is missing or malformed. */
39export function noulOf(answers: Record<string, JevAnswer>, name: string): number {
40 const answer = answers[name];
41 if (!answer || typeof answer.noul !== 'number' || !Number.isFinite(answer.noul)) {
42 throw new Error(`Jev answer missing or malformed for ${name}`);
43 }
44 return answer.noul;
45}
46
47/**
48 * The decision for one call from its two probabilities and the threshold. A
49 * result no longer than `truncateHeadChars` has nothing to truncate, so it
50 * is kept whole instead.
51 */
52export function decide(
53 call: ToolCall,
54 keepCall: number,
55 keepResult: number,
56 keepThreshold: number,
57 truncateHeadChars = 0,
58): Decision {
59 const base = { id: call.id, tool: call.tool, keepCall, keepResult, chars: pairChars(call) };
60 if (keepResult >= keepThreshold) return { ...base, action: 'keep' };
61 if (keepCall >= keepThreshold) {
62 if (call.resultChars <= truncateHeadChars) return { ...base, action: 'keep' };
63 return { ...base, action: 'truncate_result' };
64 }
65 return { ...base, action: 'drop_call' };
66}
67src/types.ts 128 lines1/**
2 * The shapes the library works over. `Message` is a structural subset of
3 * Claude Code's `SessionMessage`, so a session transcript passes in as is.
4 */
5
6export type Role = 'user' | 'assistant';
7
8export type ToolUse = {
9 tool_use_id: string;
10 tool: string;
11 input: Record<string, unknown>;
12 /** The result as the model read it, once the transcript holds it. */
13 text?: string;
14 isError?: boolean;
15 result?: unknown;
16};
17
18export type ToolResult = {
19 tool_use_id: string;
20 text: string;
21 isError: boolean;
22 result?: unknown;
23};
24
25export type Message = {
26 role: Role;
27 text: string;
28 toolUses: ToolUse[];
29 toolResults?: ToolResult[];
30 /** The engine's opaque token on a message it handed `session.compact`. */
31 handle?: string;
32};
33
34/** One tool_use paired with its tool_result, as the pruning sees it. */
35export type ToolCall = {
36 /** A short label (`t1`, `t2`, ...) used in the state and the questions. */
37 id: string;
38 tool_use_id: string;
39 tool: string;
40 input: Record<string, unknown>;
41 /** Index of the assistant message holding the tool_use. */
42 useIndex: number;
43 /** Index of the user message holding the tool_result; null while in flight. */
44 resultIndex: number | null;
45 resultText: string;
46 resultChars: number;
47 isError: boolean;
48 /** Never touched: first message, newest messages, or a result in flight. */
49 pinned: boolean;
50};
51
52export type NoulQuestion = {
53 type: 'noul';
54 instructions: string;
55 criteria?: { true?: string; false?: string };
56};
57
58export type JevQuestions = Record<string, NoulQuestion>;
59export type JevState = Record<string, unknown>;
60
61export type JevAnswer = { type: 'noul'; noul: number };
62
63export type JevUsage = {
64 input_tokens?: number;
65 output_tokens?: number;
66 cost?: number;
67};
68
69export type JevResponse = {
70 model?: string;
71 answers: Record<string, JevAnswer>;
72 usage?: JevUsage;
73};
74
75/** One `ask` over any transport: the engine's `$.http.fetch`, or a fake. */
76export type JevAsker = {
77 ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
78};
79
80export type DecisionAction = 'keep' | 'truncate_result' | 'drop_call' | 'pinned' | 'small';
81
82export type Decision = {
83 id: string;
84 tool: string;
85 action: DecisionAction;
86 keepCall: number;
87 keepResult: number;
88 /** Characters the pair carries (input plus result). */
89 chars: number;
90};
91
92export type CompactOptions = {
93 /** The ongoing task, in the state; the last user prompts when absent. */
94 goal?: string;
95 keepThreshold: number;
96 preserveRecentMessages: number;
97 /** Pairs smaller than this many characters are kept without asking. */
98 minPairChars: number;
99 truncateHeadChars: number;
100 maxStateTokens: number;
101 maxRequestTokens: number;
102};
103
104export type CompactStats = {
105 messagesBefore: number;
106 messagesAfter: number;
107 charsBefore: number;
108 charsAfter: number;
109 calls: number;
110 candidates: number;
111 kept: number;
112 truncated: number;
113 dropped: number;
114 pinned: number;
115 small: number;
116 stateTokens: number;
117 stateStage: number;
118 requests: number;
119 jevInputTokens: number;
120 jevCostUsd: number;
121};
122
123export type CompactResult = {
124 messages: Message[];
125 decisions: Decision[];
126 stats: CompactStats;
127};
128src/apply.ts 84 lines1import type { Decision, Message, ToolCall, ToolResult } from './types.ts';
2
3export const NOTE_PREFIX = '[jev-context removed';
4
5/** The note left where a result was cut. */
6export function truncationNote(removedChars: number): string {
7 return `\n\n${NOTE_PREFIX} ${removedChars} chars of this result: no longer needed. Re-run the tool if you need them.]`;
8}
9
10function stripHandle(message: Message): Message {
11 const rebuilt: Message = { role: message.role, text: message.text, toolUses: message.toolUses };
12 if (message.toolResults && message.toolResults.length > 0) rebuilt.toolResults = message.toolResults;
13 return rebuilt;
14}
15
16/**
17 * Applies the decisions to the transcript: a dropped call disappears with its
18 * result, a truncated result keeps its head plus a note, everything else is
19 * returned as the same object (handle included). A message left with neither
20 * text nor tool blocks is removed. No result is ever left without its call.
21 */
22export function applyDecisions(
23 messages: readonly Message[],
24 calls: readonly ToolCall[],
25 decisions: readonly Decision[],
26 truncateHeadChars: number,
27): Message[] {
28 const byId = new Map(calls.map((call) => [call.id, call] as const));
29 const drop = new Set<string>();
30 const truncate = new Set<string>();
31 for (const decision of decisions) {
32 const call = byId.get(decision.id);
33 if (!call) continue;
34 if (decision.action === 'drop_call') drop.add(call.tool_use_id);
35 else if (decision.action === 'truncate_result') truncate.add(call.tool_use_id);
36 }
37 if (drop.size === 0 && truncate.size === 0) return [...messages];
38
39 const output: Message[] = [];
40 for (const message of messages) {
41 let changed = false;
42 const toolUses = message.toolUses.filter((use) => {
43 if (drop.has(use.tool_use_id)) {
44 changed = true;
45 return false;
46 }
47 return true;
48 });
49 let toolResults: ToolResult[] | undefined;
50 if (message.toolResults) {
51 toolResults = [];
52 for (const result of message.toolResults) {
53 if (drop.has(result.tool_use_id)) {
54 changed = true;
55 continue;
56 }
57 if (truncate.has(result.tool_use_id) && result.text.length > truncateHeadChars) {
58 changed = true;
59 const head = result.text.slice(0, truncateHeadChars);
60 toolResults.push({
61 tool_use_id: result.tool_use_id,
62 text: `${head}${truncationNote(result.text.length - truncateHeadChars)}`,
63 isError: result.isError,
64 });
65 continue;
66 }
67 toolResults.push(result);
68 }
69 }
70 if (!changed) {
71 output.push(message);
72 continue;
73 }
74 const empty =
75 message.text.trim().length === 0 && toolUses.length === 0 && (toolResults?.length ?? 0) === 0;
76 if (empty) continue;
77 const rebuilt = stripHandle({ ...message, toolUses });
78 if (toolResults && toolResults.length > 0) rebuilt.toolResults = toolResults;
79 else delete rebuilt.toolResults;
80 output.push(rebuilt);
81 }
82 return output;
83}
84src/state.ts 131 lines1import { estimateTokens } from './tokens.ts';
2import type { JevState, Message, ToolCall } from './types.ts';
3
4export type StateStage = 0 | 1 | 2 | 3 | 4;
5
6type StageLimits = {
7 /** Characters of a tool input kept. */
8 input: number;
9 /** Characters of a text kept; 0 replaces every text by its length. */
10 text: number;
11 /** Characters of a result's head shown beside its note; 0 shows none. */
12 head: number;
13 /** Fraction of the oldest messages whose texts collapse to their length. */
14 collapse: number;
15 /** Calls become one line each instead of an object. */
16 collapseCalls: boolean;
17};
18
19/**
20 * The fitting stages, each applied only when the previous one was not
21 * enough: inputs and texts shrink, result heads disappear, old texts collapse
22 * to their length, and finally calls become one line each.
23 */
24const STAGES: readonly StageLimits[] = [
25 { input: 1000, text: 2000, head: 120, collapse: 0, collapseCalls: false },
26 { input: 300, text: 800, head: 80, collapse: 0, collapseCalls: false },
27 { input: 100, text: 300, head: 0, collapse: 0, collapseCalls: false },
28 { input: 60, text: 120, head: 0, collapse: 0.75, collapseCalls: false },
29 { input: 40, text: 0, head: 0, collapse: 1, collapseCalls: true },
30];
31
32/** Head plus tail of a text within `max` characters, with an omission note. */
33export function abridge(text: string, max: number): string {
34 if (max <= 0) return text.length > 0 ? `[${text.length} chars]` : '';
35 if (text.length <= max) return text;
36 const head = Math.ceil(max * 0.6);
37 const tail = max - head;
38 const omitted = text.length - max;
39 const end = tail > 0 ? text.slice(-tail) : '';
40 return `${text.slice(0, head)} [… ${omitted} chars omitted …] ${end}`;
41}
42
43/** The ongoing task: `explicit`, else the last `count` user prompts. */
44export function goalOf(messages: readonly Message[], explicit?: string, count = 3): string {
45 if (explicit && explicit.trim()) return explicit.trim();
46 const prompts = messages
47 .filter((m) => m.role === 'user' && m.text.trim().length > 0 && !(m.toolResults?.length))
48 .map((m) => abridge(m.text.trim(), 500));
49 return prompts.slice(-count).join('\n---\n') || '(no user prompt yet)';
50}
51
52function resultNote(call: ToolCall): string {
53 if (call.resultIndex === null) return 'pending';
54 return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars`;
55}
56
57function callEntry(call: ToolCall, limits: StageLimits): Record<string, unknown> {
58 const entry: Record<string, unknown> = {
59 id: call.id,
60 tool: call.tool,
61 input: abridge(JSON.stringify(call.input), limits.input),
62 result: resultNote(call),
63 };
64 if (limits.head > 0 && call.resultChars > 0) {
65 entry.head = call.resultText.replace(/\s+/g, ' ').trim().slice(0, limits.head);
66 }
67 return entry;
68}
69
70function callLine(call: ToolCall, limits: StageLimits): string {
71 return `${call.id} ${call.tool} ${abridge(JSON.stringify(call.input), limits.input)} → ${resultNote(call)}`;
72}
73
74/**
75 * The state Jev reads: the goal and the whole conversation oldest first, tool
76 * results replaced by a note (and a short head at the early stages), texts
77 * abridged to the stage's limits. Nothing is summarised.
78 */
79export function buildState(
80 messages: readonly Message[],
81 calls: readonly ToolCall[],
82 stage: StateStage,
83 goal: string,
84): JevState {
85 const limits = STAGES[stage] ?? STAGES[STAGES.length - 1]!;
86 const byUse = new Map<number, ToolCall[]>();
87 for (const call of calls) {
88 const own = byUse.get(call.useIndex);
89 if (own) own.push(call);
90 else byUse.set(call.useIndex, [call]);
91 }
92 const collapseBefore = Math.floor(messages.length * limits.collapse);
93 const conversation: unknown[] = [];
94 messages.forEach((message, index) => {
95 const collapse = index < collapseBefore || limits.text === 0;
96 const text = message.text.trim();
97 const shown = text.length === 0 ? '' : collapse ? `[${text.length} chars]` : abridge(text, limits.text);
98 if (message.role === 'user') {
99 if (shown) conversation.push({ user: shown });
100 return;
101 }
102 const entry: Record<string, unknown> = {};
103 if (shown) entry.assistant = shown;
104 const own = byUse.get(index);
105 if (own && own.length > 0) {
106 entry.calls = own.map((call) =>
107 limits.collapseCalls ? callLine(call, limits) : callEntry(call, limits),
108 );
109 }
110 if (Object.keys(entry).length > 0) conversation.push(entry);
111 });
112 return { goal, conversation };
113}
114
115export type FittedState = { state: JevState; tokens: number; stage: StateStage };
116
117/** The first stage whose state fits `maxStateTokens`; throws when none does. */
118export function fitState(
119 messages: readonly Message[],
120 calls: readonly ToolCall[],
121 goal: string,
122 maxStateTokens: number,
123): FittedState {
124 for (let stage = 0; stage < STAGES.length; stage++) {
125 const state = buildState(messages, calls, stage as StateStage, goal);
126 const tokens = estimateTokens(JSON.stringify(state));
127 if (tokens <= maxStateTokens) return { state, tokens, stage: stage as StateStage };
128 }
129 throw new Error(`the conversation does not fit the Jev state budget of ${maxStateTokens} tokens`);
130}
131src/tokens.ts 25 lines1import type { Message } from './types.ts';
2
3/**
4 * Estimated tokens for a text without a tokenizer: 3.2 characters per token,
5 * rounded up. Calibrated against the `input_tokens` Jev reports for JSON
6 * state, and kept a little high on purpose.
7 */
8export function estimateTokens(text: string): number {
9 return Math.ceil(text.length / 3.2);
10}
11
12/** The characters one message carries: its text, tool inputs and tool results. */
13export function messageChars(message: Message): number {
14 let chars = message.text.length;
15 for (const use of message.toolUses) chars += JSON.stringify(use.input ?? {}).length;
16 for (const result of message.toolResults ?? []) chars += result.text.length;
17 return chars;
18}
19
20export function transcriptChars(messages: readonly Message[]): number {
21 let chars = 0;
22 for (const message of messages) chars += messageChars(message);
23 return chars;
24}
25src/transcript.ts 48 lines1import type { Message, ToolCall, ToolResult } from './types.ts';
2
3/**
4 * Pairs every tool_use with its tool_result by `tool_use_id`, oldest first,
5 * labelled `t1`, `t2`, ... A call is pinned when it sits in the first message,
6 * in the newest `preserveRecentMessages` messages, or has no result yet.
7 */
8export function collectToolCalls(
9 messages: readonly Message[],
10 preserveRecentMessages: number,
11): ToolCall[] {
12 const results = new Map<string, { index: number; result: ToolResult }>();
13 messages.forEach((message, index) => {
14 for (const result of message.toolResults ?? []) {
15 results.set(result.tool_use_id, { index, result });
16 }
17 });
18 const recentFrom = Math.max(1, messages.length - Math.max(0, preserveRecentMessages));
19 const calls: ToolCall[] = [];
20 messages.forEach((message, index) => {
21 if (message.role !== 'assistant') return;
22 for (const use of message.toolUses) {
23 const found = results.get(use.tool_use_id);
24 const resultText = found?.result.text ?? use.text ?? '';
25 const pinned =
26 index === 0 || index >= recentFrom || found === undefined || found.index >= recentFrom;
27 calls.push({
28 id: `t${calls.length + 1}`,
29 tool_use_id: use.tool_use_id,
30 tool: use.tool,
31 input: use.input ?? {},
32 useIndex: index,
33 resultIndex: found?.index ?? null,
34 resultText,
35 resultChars: resultText.length,
36 isError: found?.result.isError ?? use.isError ?? false,
37 pinned,
38 });
39 }
40 });
41 return calls;
42}
43
44/** The characters a pair carries: its input as JSON plus its result text. */
45export function pairChars(call: ToolCall): number {
46 return JSON.stringify(call.input).length + call.resultChars;
47}
48