SLOPSHOPPER

jev-context

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…

newguardcommandtoastnetwork
★ 1v0.1.2MITupdated 2026-09-19JayDoubleu/cc-mod-jev
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-context
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /jev ⎿ jev-context: model=typesafe/jev-1.13 key=MISSING keepThreshold=0.5 preserveRecent=6 compactAt=60% minReduction=20% gate=off ⎿ jev-context: no compaction yet this session; /compact runs a Jev-scored one ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jev-context

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.

Requirements

  • Claude Code 2.1.278 or later with function hooks enabled: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Function hooks (mods) are early access and the API may change between releases.
  • An OpenRouter API key in OPENROUTER_API_KEY, or the apiKey plugin option.

Install

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-..." } }

What it does

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.

How a compaction is scored

  1. Every 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.
  2. The state sent to Jev is the goal (the last three user prompts) and the whole conversation oldest first: texts abridged, tool inputs truncated, every tool result replaced by 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.
  3. For every candidate, two 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.
  4. Against 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.
  5. Untouched messages go back as the engine's own objects. A message that loses all its content disappears. No result is left without its call.

Measured

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 it11,399 tokens (96% fewer)
Messages kept47 of 51, none rewritten
Calls scored19: 0 kept whole, 17 truncated to their head, 2 dropped
Jev1 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.

Configuration

Plugin options (set at install, or in settings.json under pluginConfigs["jev-context"].options):

OptionDefaultMeaning
apiKeyunsetOpenRouter key; else OPENROUTER_API_KEY
modeltypesafe/jev-1.13Model id on OpenRouter's decisions endpoint
keepThreshold0.5Minimum probability to keep a call or its result
preserveRecentMessages6Newest messages never pruned
compactAtPercent60Context percent at which the plugin requests a compaction; 0 disables
minReductionRatio0.2Pruning must remove this fraction of characters, else fall back or skip
truncateHeadChars300Head kept of a truncated result
gatefalseGate oversized tool outputs
gateToolsBashComma-separated tools the gate applies to (Bash, Read)
gateMinChars8000Outputs shorter than this are never gated
gateThreshold0.3Cut only when P(full output needed) is below this
logtranscripttranscript 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.

Development

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.

Evals

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

Limitations

  • Only tool calls and results are pruned. User and assistant text is never removed or shortened.
  • The state Jev sees is abridged to fit 20k tokens, so decisions on a very long session are made from a coarse view. Token sizes are estimated from character counts.
  • A probability is not a proof that a result is safe to delete. The model can re-run a tool, and the truncation notes say so.
  • The gate predicts what the model will need before the model has read the output. Keep it off for tools where the middle of the output is the point.
  • Subagent transcripts are pruned when the engine compacts them, but the plugin only triggers compactions for the main loop.

Related work

Source 11 files
hooks/jev-context.ts 373 lines
1import 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};
373
src/compact.ts 185 lines
1import { 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}
185
src/config.ts 179 lines
1import { 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}
179
src/gate.ts 129 lines
1import { 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}
129
src/openrouter.ts 80 lines
1import 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}
80
src/questions.ts 67 lines
1import 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}
67
src/types.ts 128 lines
1/**
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};
128
src/apply.ts 84 lines
1import 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}
84
src/state.ts 131 lines
1import { 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}
131
src/tokens.ts 25 lines
1import 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}
25
src/transcript.ts 48 lines
1import 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