SLOPSHOPPER

jev

Cheap, fast, typed AI judgments inside Claude Code via the jevctl CLI (verify, screen, find, classify, extract, rerank, match, route, ask, batch), plus…

toastnetwork
★ 22v0.2.0MITupdated 2026-10-23Nasrallah-AL/jev-cli/plugin
A shopper browsing a rack in a slop shop
README

jev plugin for Claude Code

Gives Claude Code the jev skill and four slash commands so it can get cheap, fast, calibrated judgments from TypeSafe's Jev model instead of reasoning them out at length.

CommandDoes
/jev:verifyFact-check claims against evidence
/jev:screenCheck a URL, file, or text for prompt injection before reading it
/jev:findPick the files or lines that best answer a question, by meaning
/jev:askAsk yes/no, multiple-choice, or rating questions about text
/jev:classify, /jev:extract, /jev:routeLabel text, pull values out of documents, route requests to handlers
/jev:compactCompact a session transcript offline and show what would be dropped

The skill also loads automatically when Claude decides a judgment fits the task, for example screening a fetched page or checking its own summary against a source.

Requirements

Install

claude plugin marketplace add Nasrallah-AL/jev-cli
claude plugin install jev@jev-cli

Then in a session: /jev:screen https://example.com for "extract pricing".

Compaction hook

The plugin also replaces Claude Code's compaction summary with Jev-guided verbatim compaction: old tool calls and results Jev judges stale are dropped or truncated, everything else stays exactly as written. It needs the early-access function-hooks flag:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1", "TYPESAFE_API_KEY": "<your key>" } }

in ~/.claude/settings.json. On by default; see hooks/README.md for options and behavior, including the fallback to the built-in summary when Jev is unavailable.

What the plugin contains

plugin/
  .claude-plugin/plugin.json   manifest
  skills/jev/SKILL.md          when and how Claude should use jev; command reference; how to read results
  commands/verify.md           /jev:verify
  commands/screen.md           /jev:screen
  commands/find.md             /jev:find
  commands/ask.md              /jev:ask
  commands/{classify,extract,route,compact}.md
  hooks/hooks.json             registers the compaction hook
  hooks/fast-jev.ts            session.compact + turn.complete function hook
  hooks/compaction/            self-contained copy of the compaction library

One function hook (compaction, optional), no MCP servers, no background processes. Skills and commands run through the jev CLI in Bash, so what Claude sends to the API is exactly what jev --dry-run would show.

Permissions

The skill and commands declare allowed-tools limited to jev, npx jevctl, and read-only file tools (plus curl for /jev:screen URLs). Claude Code still asks for permission the first time a command runs unless you have pre-approved Bash(jev:*).

Source 5 files
hooks/fast-jev.ts 306 lines
1// Claude Code function hook: replace the built-in compaction summary with
2// verbatim Jev-guided compaction. Adapted from fast-jev-compaction
3// (https://github.com/tamaratran/fast-jev-compaction, MIT) to run inside the
4// jev plugin. Imports the self-contained copy in ./compaction/ (kept in sync
5// with src/vendor/compaction/ by `npm run sync:hooks`).
6//
7// Function hooks are an early-access Claude Code feature. Enable with
8// CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment or in settings.json
9// `env`. The checked-in type reference in ./types/claude-code.d.ts was written
10// by Claude Code 2.1.274; regenerate with /plugin-types after upgrades.
11
12import type {
13  On,
14  PluginOptions,
15  Register,
16  SessionMessage,
17  ToolResultSummary,
18  ToolUseSummary,
19  TurnCompleteInput,
20} from 'claude-code';
21
22import { compact, reductionRatio } from './compaction/compact.ts';
23import { buildJevRequest, DEFAULT_MODEL, parseJevResponse } from './compaction/request.ts';
24import type { CompactOptions, CompactResult, JevAsker, Message, ToolResult, ToolUse } from './compaction/types.ts';
25
26const HOOK_DEFAULTS = {
27  compactAtPercent: 60,
28  minReductionRatio: 0.25,
29  model: DEFAULT_MODEL,
30  concurrency: 4,
31};
32
33const MAX_CONCURRENCY = 64;
34
35export type HookFetchInit = { method?: string; headers?: Record<string, string>; body?: string };
36export type HookFetchResponse = { status: number; ok: boolean; text: string };
37/** The shape of `$.http.fetch`, so the hook can be driven without an engine. */
38export type HookFetch = (url: string, init?: HookFetchInit) => Promise<HookFetchResponse>;
39
40export type HookConfig = CompactOptions & {
41  enabled: boolean;
42  /** Question batches in flight at once; each request carries the whole state. */
43  concurrency: number;
44  apiKey?: string;
45  compactAtPercent: number;
46  minReductionRatio: number;
47  model: string;
48};
49
50function optionNumber(options: PluginOptions, key: string, fallback: number): number {
51  const value = options[key];
52  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
53}
54
55function optionString(options: PluginOptions, key: string): string | undefined {
56  const value = options[key];
57  return typeof value === 'string' && value.length > 0 ? value : undefined;
58}
59
60/** Reads the plugin's `userConfig` values; anything missing takes the defaults. */
61export function resolveHookConfig(options: PluginOptions): HookConfig {
62  const numbers: Partial<Omit<CompactOptions, 'goal'>> = {};
63  for (const key of [
64    'keepThreshold',
65    'preserveRecentMessages',
66    'maxStateTokens',
67    'maxRequestTokens',
68    'truncateHeadChars',
69  ] as const) {
70    const value = options[key];
71    if (typeof value === 'number' && Number.isFinite(value)) numbers[key] = value;
72  }
73  const config: HookConfig = {
74    ...numbers,
75    enabled: options['compaction'] !== false,
76    compactAtPercent: optionNumber(options, 'compactAtPercent', HOOK_DEFAULTS.compactAtPercent),
77    minReductionRatio: optionNumber(options, 'minReductionRatio', HOOK_DEFAULTS.minReductionRatio),
78    concurrency: Math.min(
79      MAX_CONCURRENCY,
80      Math.max(1, Math.round(optionNumber(options, 'concurrency', HOOK_DEFAULTS.concurrency))),
81    ),
82    model: optionString(options, 'model') ?? HOOK_DEFAULTS.model,
83  };
84  const apiKey = optionString(options, 'apiKey');
85  if (apiKey) config.apiKey = apiKey;
86  const goal = optionString(options, 'goal');
87  if (goal) config.goal = goal;
88  return config;
89}
90
91/** A `JevAsker` over the engine's `$.http.fetch`. */
92/**
93 * The library splits the questions into batches and awaits them together, and
94 * every batch resends the whole fitted state, so a long session would put one
95 * request per batch on the wire at once. Hold the extras in call order.
96 */
97export function limitAsker(asker: JevAsker, limit: number): JevAsker {
98  let active = 0;
99  const waiting: Array<() => void> = [];
100  const acquire = (): Promise<void> => {
101    if (active < limit) {
102      active += 1;
103      return Promise.resolve();
104    }
105    return new Promise<void>((resolve) => {
106      waiting.push(() => {
107        active += 1;
108        resolve();
109      });
110    });
111  };
112  return {
113    async ask(state, questions) {
114      await acquire();
115      try {
116        return await asker.ask(state, questions);
117      } finally {
118        active -= 1;
119        waiting.shift()?.();
120      }
121    },
122  };
123}
124
125export function jevAsker(fetchFn: HookFetch, apiKey: string, model: string): JevAsker {
126  return {
127    async ask(state, questions) {
128      const request = buildJevRequest({ apiKey, model }, state, questions);
129      const response = await fetchFn(request.url, {
130        method: request.method,
131        headers: request.headers,
132        body: request.body,
133      });
134      return parseJevResponse(response.status, response.ok, response.text);
135    },
136  };
137}
138
139function toolUseSummary(tool: ToolUse): ToolUseSummary {
140  const summary: ToolUseSummary = { tool_use_id: tool.tool_use_id, tool: tool.tool, input: tool.input };
141  if (tool.text !== undefined) summary.text = tool.text;
142  if (tool.isError) summary.isError = true;
143  return summary;
144}
145
146function toolResultSummary(result: ToolResult): ToolResultSummary {
147  return { tool_use_id: result.tool_use_id, text: result.text, isError: result.isError ?? false };
148}
149
150/**
151 * Maps the library's output back onto session messages. Untouched objects are
152 * returned as the engine's own (handle included); rebuilt ones are fresh.
153 */
154export function toSessionMessages(
155  input: readonly SessionMessage[],
156  output: readonly Message[],
157): SessionMessage[] {
158  const messages = new Map<Message, SessionMessage>();
159  const uses = new Map<ToolUse, ToolUseSummary>();
160  const results = new Map<ToolResult, ToolResultSummary>();
161  for (const message of input) {
162    messages.set(message, message);
163    for (const tool of message.toolUses) uses.set(tool, tool);
164    for (const result of message.toolResults ?? []) results.set(result, result);
165  }
166  return output.map((message) => {
167    const own = messages.get(message);
168    if (own) return own;
169    const rebuilt: SessionMessage = {
170      role: message.role,
171      text: message.text,
172      toolUses: message.toolUses.map((tool) => uses.get(tool) ?? toolUseSummary(tool)),
173    };
174    if (message.toolResults && message.toolResults.length > 0) {
175      rebuilt.toolResults = message.toolResults.map((result) => results.get(result) ?? toolResultSummary(result));
176    }
177    return rebuilt;
178  });
179}
180
181export type SessionCompaction = { result: CompactResult; messages: SessionMessage[] };
182
183/** Runs the library over a session transcript; throws when the key is missing or Jev fails. */
184export async function compactSession(
185  messages: readonly SessionMessage[],
186  config: HookConfig,
187  fetchFn: HookFetch,
188): Promise<SessionCompaction> {
189  if (!config.apiKey) throw new Error('TYPESAFE_API_KEY is not configured');
190  const asker = limitAsker(jevAsker(fetchFn, config.apiKey, config.model), config.concurrency);
191  const result = await compact(messages, asker, config);
192  return { result, messages: toSessionMessages(messages, result.messages) };
193}
194
195function percent(ratio: number): string {
196  return `${Math.round(ratio * 100)}%`;
197}
198
199export function summarize(result: CompactResult): string {
200  const { stats } = result;
201  const parts = [
202    stats.kept > 0 ? `${stats.kept} kept` : '',
203    stats.resultsDropped > 0 ? `${stats.resultsDropped} results truncated` : '',
204    stats.callsDropped > 0 ? `${stats.callsDropped} calls dropped` : '',
205    stats.pinned > 0 ? `${stats.pinned} pinned` : '',
206  ].filter(Boolean);
207  return `${percent(reductionRatio(result))} reduction; ${parts.join(', ') || 'no tool calls'}; state ~${stats.stateTokens} tokens (${stats.stateStage}) in ${stats.requests} request(s)`;
208}
209
210const UI_LOG_MAX_CHARS = 4096;
211
212export function decisionLog(result: CompactResult): string {
213  return result.decisions
214    .filter((d) => d.reason !== 'pinned')
215    .map((d) => `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`)
216    .join(' ');
217}
218
219export function decisionLogLines(result: CompactResult, maxChars: number = UI_LOG_MAX_CHARS): string[] {
220  const entries = decisionLog(result).split(' ').filter(Boolean);
221  if (entries.length === 0) return ['jev compact decisions: (none)'];
222  const chunks: string[] = [];
223  let current = '';
224  for (const entry of entries) {
225    const next = current ? `${current} ${entry}` : entry;
226    if (current && next.length > maxChars - 32) {
227      chunks.push(current);
228      current = entry;
229    } else current = next;
230  }
231  chunks.push(current);
232  return chunks.map((chunk, index) =>
233    chunks.length === 1 ? `jev compact decisions: ${chunk}` : `jev compact decisions (${index + 1}/${chunks.length}): ${chunk}`,
234  );
235}
236
237async function getApiKey(
238  $: {
239    env: { get: (name: string) => Promise<string | undefined> };
240    settings: { read: () => Promise<Readonly<Record<string, unknown>>> };
241  },
242  config: HookConfig,
243): Promise<string | undefined> {
244  if (config.apiKey) return config.apiKey;
245  const fromEnv = await $.env.get('TYPESAFE_API_KEY');
246  if (fromEnv) return fromEnv;
247  const settings = await $.settings.read();
248  const env = settings['env'];
249  if (env && typeof env === 'object') {
250    const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY'];
251    if (typeof value === 'string' && value) return value;
252  }
253  return undefined;
254}
255
256function notify(
257  $: { ui: { log: (text: string) => void; toast: (text: string, options?: { timeoutMs?: number }) => void } },
258  text: string,
259): void {
260  $.ui.log(text);
261  $.ui.toast(text, { timeoutMs: 15_000 });
262}
263
264export const register: Register = (on: On, options: PluginOptions) => {
265  const configured = resolveHookConfig(options);
266  if (!configured.enabled) return;
267  let compacting = false;
268
269  on('session.compact', async ($, event, next) => {
270    try {
271      const config = { ...configured, apiKey: await getApiKey($, configured) };
272      const { result, messages } = await compactSession(event.messages, config, async (url, init) => {
273        const response = await $.http.fetch(url, init);
274        return { status: response.status, ok: response.ok, text: response.text };
275      });
276      for (const line of decisionLogLines(result)) $.ui.log(line);
277      if (reductionRatio(result) < config.minReductionRatio) {
278        notify($, `jev compact: falling back to the built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`);
279        return next(event);
280      }
281      notify($, `jev compact: kept ${messages.length}/${event.messages.length} messages verbatim, no summary (${summarize(result)})`);
282      return { messages };
283    } catch (error) {
284      notify($, `jev compact: falling back to the built-in summary (${error instanceof Error ? error.message : String(error)})`);
285      return next(event);
286    }
287  });
288
289  if (configured.compactAtPercent > 0) {
290    on('turn.complete', async ($, event: TurnCompleteInput, next) => {
291      if (compacting) return next(event);
292      try {
293        const { context } = await $.session.usage();
294        if ((context.percent ?? 0) < configured.compactAtPercent) return next(event);
295        compacting = true;
296        await $.session.compact();
297      } catch (error) {
298        $.ui.log(`jev compact: auto-compact skipped (${error instanceof Error ? error.message : String(error)})`);
299      } finally {
300        compacting = false;
301      }
302      return next(event);
303    });
304  }
305};
306
hooks/compaction/compact.ts 313 lines
1// Vendored from fast-jev-compaction (https://github.com/tamaratran/fast-jev-compaction), MIT.
2// Local changes: import paths only. Keep in sync with the upstream file of the same name.
3
4import { noulAnswer } from './request.js';
5import { collectToolCalls, estimateTokens, fitState } from './state.js';
6import type {
7  CallAnswer,
8  CallDecision,
9  CompactOptions,
10  CompactResult,
11  CompactionState,
12  JevAsker,
13  JevQuestions,
14  Message,
15  ResolvedCompactOptions,
16  ToolCall,
17  ToolUse,
18} from './types.js';
19
20export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
21  goal: '',
22  keepThreshold: 0.5,
23  preserveRecentMessages: 6,
24  maxStateTokens: 25_000,
25  maxRequestTokens: 30_000,
26  truncateHeadChars: 300,
27};
28
29/** Tokens the request envelope (`model`, key names) adds around state and questions. */
30const REQUEST_OVERHEAD_TOKENS = 20;
31
32function finite(value: number | undefined, fallback: number): number {
33  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
34}
35
36export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
37  return {
38    goal: options.goal ?? DEFAULT_OPTIONS.goal,
39    keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
40    preserveRecentMessages: Math.max(
41      0,
42      Math.floor(
43        finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
44      ),
45    ),
46    maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
47    maxRequestTokens: Math.max(
48      1,
49      finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
50    ),
51    truncateHeadChars: Math.max(
52      0,
53      Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
54    ),
55  };
56}
57
58/** The two `noul` questions asked about one call: keep the call, keep its result. */
59export function questionsFor(call: ToolCall): JevQuestions {
60  return {
61    [`call_${call.id}`]: {
62      type: 'noul',
63      instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
64    },
65    [`result_${call.id}`]: {
66      type: 'noul',
67      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 contents and re-running the tool would not do`,
68    },
69  };
70}
71
72/**
73 * Splits the candidate calls into batches whose questions, together with the
74 * (always complete) state, fit one request.
75 */
76export function batchCalls(
77  calls: readonly ToolCall[],
78  stateTokens: number,
79  options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
80): ToolCall[][] {
81  const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
82  const batches: ToolCall[][] = [];
83  let current: ToolCall[] = [];
84  let currentTokens = 0;
85  for (const call of calls) {
86    const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
87    if (current.length > 0 && currentTokens + tokens > budget) {
88      batches.push(current);
89      current = [];
90      currentTokens = 0;
91    }
92    if (current.length === 0 && tokens > budget) {
93      throw new Error(
94        `state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
95      );
96    }
97    current.push(call);
98    currentTokens += tokens;
99  }
100  if (current.length > 0) batches.push(current);
101  return batches;
102}
103
104export function decideCall(
105  call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
106  answer: CallAnswer,
107  options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
108): CallDecision {
109  const base = { id: call.id, tool: call.tool, ...answer };
110  if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
111  if (answer.keepResult >= options.keepThreshold) {
112    return { ...base, action: 'keep', reason: 'kept' };
113  }
114  if (answer.keepCall >= options.keepThreshold) {
115    return { ...base, action: 'drop_result', reason: 'result_dropped' };
116  }
117  return { ...base, action: 'drop_call', reason: 'call_dropped' };
118}
119
120async function askBatch(
121  asker: JevAsker,
122  state: CompactionState,
123  batch: readonly ToolCall[],
124): Promise<Map<string, CallAnswer>> {
125  const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
126  const { answers } = await asker.ask(state, questions);
127  return new Map(
128    batch.map((call) => [
129      call.id,
130      {
131        keepCall: noulAnswer(answers, `call_${call.id}`),
132        keepResult: noulAnswer(answers, `result_${call.id}`),
133      },
134    ]),
135  );
136}
137
138function truncatedResultText(text: string, isError: boolean, headChars: number): string {
139  if (text.length <= headChars + 120) return text;
140  const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
141  return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
142    isError ? ' (error)' : ''
143  }; re-run the tool if needed]`;
144}
145
146/**
147 * Rebuilds the conversation from the decisions. A dropped call disappears
148 * together with its result; a dropped result keeps a bounded head and note.
149 * Messages that lose all their content are removed; untouched messages are
150 * returned as the same objects they came in as.
151 */
152export function applyDecisions(
153  messages: readonly Message[],
154  decisions: readonly CallDecision[],
155  calls: readonly ToolCall[],
156  headChars: number,
157): Message[] {
158  const byId = new Map(calls.map((call) => [call.id, call]));
159  const actions = new Map<string, CallDecision['action']>();
160  for (const decision of decisions) {
161    const call = byId.get(decision.id);
162    if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
163  }
164  const kept: Message[] = [];
165  for (const message of messages) {
166    const touched =
167      message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
168      (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
169    if (!touched) {
170      kept.push(message);
171      continue;
172    }
173    const toolUses = message.toolUses
174      .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
175      .map((tool) => {
176        if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
177        const text = truncatedResultText(
178          tool.text ?? '',
179          tool.isError ?? false,
180          headChars,
181        );
182        if ((tool.text ?? '') === text) return tool;
183        const copy: ToolUse = {
184          tool_use_id: tool.tool_use_id,
185          tool: tool.tool,
186          input: tool.input,
187          text,
188        };
189        if (tool.isError) copy.isError = true;
190        return copy;
191      });
192    const toolResults = (message.toolResults ?? [])
193      .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
194      .map((result) => {
195        if (actions.get(result.tool_use_id) !== 'drop_result') return result;
196        const text = truncatedResultText(result.text, result.isError ?? false, headChars);
197        return text === result.text
198          ? result
199          : {
200              tool_use_id: result.tool_use_id,
201              text,
202              isError: result.isError,
203            };
204      });
205    if (
206      !message.toolUses.some(
207        (tool) => actions.get(tool.tool_use_id) === 'drop_call',
208      ) &&
209      !(message.toolResults ?? []).some(
210        (result) => actions.get(result.tool_use_id) === 'drop_call',
211      ) &&
212      toolUses.every((tool, index) => tool === message.toolUses[index]) &&
213      toolResults.every(
214        (result, index) => result === message.toolResults?.[index],
215      )
216    ) {
217      kept.push(message);
218      continue;
219    }
220    if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
221      continue;
222    }
223    const rebuilt: Message = { role: message.role, text: message.text, toolUses };
224    if (toolResults.length > 0) rebuilt.toolResults = toolResults;
225    kept.push(rebuilt);
226  }
227  return kept;
228}
229
230/** Characters of text, tool input and tool output a message holds. */
231export function messageChars(message: Message): number {
232  let total = message.text.length;
233  for (const tool of message.toolUses) {
234    try {
235      total += JSON.stringify(tool.input).length;
236    } catch {
237      total += 20;
238    }
239  }
240  for (const result of message.toolResults ?? []) total += result.text.length;
241  return total;
242}
243
244export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
245  const { charsBefore, charsAfter } = result.stats;
246  return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
247}
248
249function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
250  return decisions.filter((decision) => decision.reason === reason).length;
251}
252
253/**
254 * Compacts a transcript by asking Jev, for every tool call outside the pinned
255 * first and newest messages, whether the call and whether its result must
256 * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
257 * sent as state with every batch of questions. Throws when Jev fails or the
258 * history cannot be fitted; the caller decides whether to fall back.
259 */
260export async function compact(
261  messages: readonly Message[],
262  asker: JevAsker,
263  options: CompactOptions = {},
264): Promise<CompactResult> {
265  const started = Date.now();
266  const resolved = resolveOptions(options);
267  const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
268  const candidates = calls.filter((call) => !call.pinned);
269  const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
270
271  let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
272  let batches: ToolCall[][] = [];
273  const answers = new Map<string, CallAnswer>();
274  if (candidates.length > 0) {
275    const state = fitState(messages, calls, resolved);
276    fitted = state;
277    batches = batchCalls(candidates, state.tokens, resolved);
278    const answered = await Promise.all(
279      batches.map((batch) => askBatch(asker, state.state, batch)),
280    );
281    for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
282  }
283
284  const decisions = calls.map((call) =>
285    decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
286  );
287  const kept = applyDecisions(
288    messages,
289    decisions,
290    calls,
291    resolved.truncateHeadChars,
292  );
293  return {
294    messages: kept,
295    decisions,
296    stats: {
297      messagesBefore: messages.length,
298      messagesAfter: kept.length,
299      charsBefore,
300      charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
301      calls: calls.length,
302      kept: count(decisions, 'kept'),
303      resultsDropped: count(decisions, 'result_dropped'),
304      callsDropped: count(decisions, 'call_dropped'),
305      pinned: count(decisions, 'pinned'),
306      stateTokens: fitted.tokens,
307      stateStage: fitted.stage,
308      requests: batches.length,
309      ms: Date.now() - started,
310    },
311  };
312}
313
hooks/compaction/request.ts 84 lines
1// Vendored from fast-jev-compaction (https://github.com/tamaratran/fast-jev-compaction), MIT.
2// Local changes: import paths only. Keep in sync with the upstream file of the same name.
3
4import type { JevAnswer, JevQuestions, JevResponse, JevState } from './types.js';
5
6export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
7export const DEFAULT_MODEL = 'jev-latest';
8
9export interface JevRequest {
10  url: string;
11  method: 'POST';
12  headers: Record<string, string>;
13  body: string;
14}
15
16/** The HTTP request for one Jev call, for any fetch-like transport. */
17export function buildJevRequest(
18  params: {
19    apiKey: string;
20    model?: string;
21    baseUrl?: string;
22  },
23  state: JevState,
24  questions: JevQuestions,
25): JevRequest {
26  return {
27    url: params.baseUrl ?? SYSTEM_ONE_URL,
28    method: 'POST',
29    headers: {
30      authorization: `Bearer ${params.apiKey}`,
31      'content-type': 'application/json',
32    },
33    body: JSON.stringify({
34      model: params.model ?? DEFAULT_MODEL,
35      state,
36      questions,
37    }),
38  };
39}
40
41/** Validates a Jev response body; throws on anything but an `answers` object. */
42export function parseJevResponse(
43  status: number,
44  ok: boolean,
45  text: string,
46): JevResponse {
47  if (!ok) {
48    throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
49  }
50  let parsed: unknown;
51  try {
52    parsed = JSON.parse(text);
53  } catch {
54    throw new Error('Jev returned malformed JSON');
55  }
56  if (
57    parsed === null ||
58    typeof parsed !== 'object' ||
59    !('answers' in parsed) ||
60    parsed.answers === null ||
61    typeof parsed.answers !== 'object'
62  ) {
63    throw new Error('Jev response is missing answers');
64  }
65  return parsed as JevResponse;
66}
67
68/** The `noul` probability of one answer; throws when it is not there. */
69export function noulAnswer(
70  answers: Record<string, JevAnswer>,
71  name: string,
72): number {
73  const answer = answers[name];
74  if (
75    !answer ||
76    !('noul' in answer) ||
77    typeof answer.noul !== 'number' ||
78    !Number.isFinite(answer.noul)
79  ) {
80    throw new Error(`Invalid Jev answer for ${name}`);
81  }
82  return answer.noul;
83}
84
hooks/compaction/types.ts 206 lines
1// Vendored from fast-jev-compaction (https://github.com/tamaratran/fast-jev-compaction), MIT.
2// Local changes: import paths only. Keep in sync with the upstream file of the same name.
3
4export type Role = 'user' | 'assistant';
5
6/**
7 * A tool_use block of an assistant message. `text` and `isError` mirror the
8 * outcome once the transcript holds it (Claude Code attaches them).
9 */
10export interface ToolUse {
11  tool_use_id: string;
12  tool: string;
13  input: Record<string, unknown>;
14  text?: string;
15  isError?: boolean;
16}
17
18/** A tool_result block of a user message. */
19export interface ToolResult {
20  tool_use_id: string;
21  text: string;
22  isError?: boolean;
23}
24
25/**
26 * One transcript message. The shape is a subset of Claude Code's
27 * `SessionMessage`, so a session transcript can be passed in as is.
28 */
29export interface Message {
30  role: Role;
31  text: string;
32  toolUses: ToolUse[];
33  toolResults?: ToolResult[];
34}
35
36/** A tool call paired with its result by `tool_use_id`. */
37export interface ToolCall {
38  /** Short id used in the Jev state and question names (`t1`, `t2`, ...). */
39  id: string;
40  tool_use_id: string;
41  tool: string;
42  input: Record<string, unknown>;
43  /** Index of the message holding the tool_use block. */
44  callIndex: number;
45  /** Index of the message holding the tool_result block. */
46  resultIndex: number;
47  resultChars: number;
48  isError: boolean;
49  /** In the first or the newest preserved messages; never a candidate. */
50  pinned: boolean;
51}
52
53export interface CallAnswer {
54  /** Jev's probability that the call itself still matters. */
55  keepCall: number;
56  /** Jev's probability that the full result still needs to stay verbatim. */
57  keepResult: number;
58}
59
60export type CallAction = 'keep' | 'drop_result' | 'drop_call';
61
62export interface CallDecision extends CallAnswer {
63  id: string;
64  tool: string;
65  action: CallAction;
66  reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
67}
68
69export interface HistoryToolCall {
70  id: string;
71  tool: string;
72  input: string;
73  result: string;
74}
75
76export interface HistoryEntry {
77  i: number;
78  role: Role;
79  text: string;
80  /** Structured per call, or one compact line per call once the state has to shrink. */
81  tool_calls?: HistoryToolCall[] | string[];
82}
83
84/** The state sent with every Jev request: the whole history, results omitted. */
85export interface CompactionState {
86  context: string;
87  goal: string;
88  history: HistoryEntry[];
89}
90
91export interface FittedState {
92  state: CompactionState;
93  tokens: number;
94  /** Which fitting stage produced the state, for diagnostics. */
95  stage: string;
96}
97
98export interface CompactOptions {
99  /** Ongoing task description; defaults to the last few user prompts. */
100  goal?: string;
101  /** Minimum keep probability for a call or result to stay. Default 0.5. */
102  keepThreshold?: number;
103  /** Newest messages never touched (the first message is always kept). Default 6. */
104  preserveRecentMessages?: number;
105  /** Estimated token ceiling for the state. Default 25000. */
106  maxStateTokens?: number;
107  /** Estimated token ceiling for state plus one batch of questions. Default 30000. */
108  maxRequestTokens?: number;
109  /** Characters of a dropped tool result to retain. Default 300. */
110  truncateHeadChars?: number;
111}
112
113export interface ResolvedCompactOptions {
114  goal: string;
115  keepThreshold: number;
116  preserveRecentMessages: number;
117  maxStateTokens: number;
118  maxRequestTokens: number;
119  truncateHeadChars: number;
120}
121
122export interface CompactResult {
123  /** The compacted transcript; untouched messages are the input objects. */
124  messages: Message[];
125  decisions: CallDecision[];
126  stats: {
127    messagesBefore: number;
128    messagesAfter: number;
129    charsBefore: number;
130    charsAfter: number;
131    calls: number;
132    kept: number;
133    resultsDropped: number;
134    callsDropped: number;
135    pinned: number;
136    stateTokens: number;
137    /** Which fitting stage the state needed, '' when no request was made. */
138    stateStage: string;
139    requests: number;
140    ms: number;
141  };
142}
143
144/** The `state` of a Jev request: a string or any JSON-serialisable object. */
145export type JevState = string | object;
146
147export interface NoulQuestion {
148  type: 'noul';
149  instructions: string;
150  criteria?: {
151    true?: string;
152    false?: string;
153  };
154}
155
156export interface ChoiceQuestion {
157  type: 'choice';
158  instructions: string;
159  criteria: Record<string, string | null>;
160}
161
162export interface ScoreQuestion {
163  type: 'score';
164  instructions: string;
165  criteria: string[];
166}
167
168export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
169export type JevQuestions = Record<string, JevQuestion>;
170
171export interface NoulAnswer {
172  type?: 'noul';
173  noul: number;
174}
175
176export interface ChoiceAnswer {
177  type?: 'choice';
178  choice: string;
179  confidence: number;
180  probabilities: Record<string, number>;
181}
182
183export interface ScoreAnswer {
184  type?: 'score';
185  score: number;
186  confidence: number;
187  probabilities: Record<string, number>;
188}
189
190export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
191
192export interface JevResponse {
193  model?: string;
194  answers: Record<string, JevAnswer>;
195  usage?: {
196    input_tokens?: number;
197    output_tokens?: number;
198  };
199  [key: string]: unknown;
200}
201
202/** Anything that can answer Jev questions: `JevClient`, or a host-provided adapter. */
203export interface JevAsker {
204  ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
205}
206
hooks/compaction/state.ts 308 lines
1// Vendored from fast-jev-compaction (https://github.com/tamaratran/fast-jev-compaction), MIT.
2// Local changes: import paths only. Keep in sync with the upstream file of the same name.
3
4import type {
5  CompactionState,
6  FittedState,
7  HistoryEntry,
8  Message,
9  ResolvedCompactOptions,
10  ToolCall,
11  ToolResult,
12} from './types.js';
13
14export const STATE_CONTEXT =
15  'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
16
17/** Successive caps on the serialised tool input included per call. */
18const INPUT_CHARS = [1000, 200, 60] as const;
19const TEXT_HEAD = 400;
20const TEXT_TAIL = 150;
21
22const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
23
24/**
25 * Estimates tokens without a tokenizer: a word costs one token per six
26 * letters, a digit half a token, any other symbol nine tenths. Calibrated
27 * against the usage Jev reports for real transcripts, where it lands 2–18%
28 * above the true count; a plain characters-per-token ratio undercounts the
29 * JSON-heavy states by up to 40%.
30 */
31export function estimateTokens(text: string): number {
32  let tokens = 0;
33  for (const [piece] of text.matchAll(TOKEN_PIECES)) {
34    const first = piece.charCodeAt(0);
35    if (first >= 48 && first <= 57) tokens += piece.length / 2;
36    else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
37      tokens += 1 + Math.floor((piece.length - 1) / 6);
38    } else tokens += 0.9;
39  }
40  return Math.ceil(tokens);
41}
42
43export function truncate(text: string, limit: number): string {
44  return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
45}
46
47function abridge(text: string, head: number, tail: number): string {
48  if (text.length <= head + tail + 40) return text;
49  const omitted = text.length - head - tail;
50  return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
51}
52
53export function isPinned(
54  index: number,
55  total: number,
56  preserveRecentMessages: number,
57): boolean {
58  return index === 0 || index >= total - preserveRecentMessages;
59}
60
61/**
62 * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
63 * result are not candidates (there is nothing to drop yet).
64 */
65export function collectToolCalls(
66  messages: readonly Message[],
67  preserveRecentMessages: number,
68): ToolCall[] {
69  const results = new Map<string, { index: number; result: ToolResult }>();
70  messages.forEach((message, index) => {
71    for (const result of message.toolResults ?? []) {
72      results.set(result.tool_use_id, { index, result });
73    }
74  });
75  const calls: ToolCall[] = [];
76  messages.forEach((message, callIndex) => {
77    for (const tool of message.toolUses) {
78      const found = results.get(tool.tool_use_id);
79      if (!found) continue;
80      calls.push({
81        id: `t${calls.length + 1}`,
82        tool_use_id: tool.tool_use_id,
83        tool: tool.tool,
84        input: tool.input,
85        callIndex,
86        resultIndex: found.index,
87        resultChars: found.result.text.length,
88        isError: found.result.isError ?? false,
89        pinned:
90          isPinned(callIndex, messages.length, preserveRecentMessages) ||
91          isPinned(found.index, messages.length, preserveRecentMessages),
92      });
93    }
94  });
95  return calls;
96}
97
98function inputText(input: Record<string, unknown>, limit: number): string {
99  let json = '';
100  try {
101    json = JSON.stringify(input);
102  } catch {
103    json = '[unserializable input]';
104  }
105  return truncate(json, limit);
106}
107
108function resultNote(call: ToolCall): string {
109  return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
110}
111
112/** One call as a single line, for when the structured form is too costly. */
113function compactCall(call: ToolCall): string {
114  const input = Object.entries(call.input)
115    .map(([key, value]) => {
116      const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
117      return `${key}=${text.replace(/\s+/g, ' ')}`;
118    })
119    .join(' ');
120  return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
121    call.isError ? 'error' : 'ok'
122  } ${call.resultChars}ch`;
123}
124
125/**
126 * Folds runs of adjacent call-only entries into one entry each, so the
127 * per-entry envelope is paid once per run; the call lines keep their ids.
128 */
129function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
130  const merged: HistoryEntry[] = [];
131  for (const entry of history) {
132    const previous = merged[merged.length - 1];
133    const foldable = (e: HistoryEntry): boolean =>
134      !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
135    if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
136      previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
137      continue;
138    }
139    merged.push({ ...entry });
140  }
141  return merged;
142}
143
144function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
145  const byMessage = new Map<number, ToolCall[]>();
146  for (const call of calls) {
147    const list = byMessage.get(call.callIndex) ?? [];
148    list.push(call);
149    byMessage.set(call.callIndex, list);
150  }
151  return byMessage;
152}
153
154function historyEntries(
155  messages: readonly Message[],
156  calls: readonly ToolCall[],
157  inputChars: number,
158): HistoryEntry[] {
159  const byMessage = callsByMessage(calls);
160  const entries: HistoryEntry[] = [];
161  messages.forEach((message, i) => {
162    const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
163      id: call.id,
164      tool: call.tool,
165      input: inputText(call.input, inputChars),
166      result: resultNote(call),
167    }));
168    if (message.text.trim().length === 0 && toolCalls.length === 0) return;
169    const entry: HistoryEntry = { i, role: message.role, text: message.text };
170    if (toolCalls.length > 0) entry.tool_calls = toolCalls;
171    entries.push(entry);
172  });
173  return entries;
174}
175
176/** The last three user prompts, as the default `goal`. */
177export function goalFromMessages(messages: readonly Message[]): string {
178  return messages
179    .filter(
180      (message) =>
181        message.role === 'user' &&
182        message.text.trim().length > 0 &&
183        (message.toolResults ?? []).length === 0,
184    )
185    .slice(-3)
186    .map((message) => truncate(message.text, 500))
187    .join('\n');
188}
189
190/**
191 * Builds the Jev state from the whole conversation and shrinks it in stages
192 * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
193 * are abridged oldest-first (pinned messages last), then old messages collapse
194 * to a one-line note, then old tool calls shrink to one line each, then old
195 * messages that carry no call are left out, then runs of old call-only
196 * messages are folded into one entry. Throws when even that is too big.
197 */
198export function fitState(
199  messages: readonly Message[],
200  calls: readonly ToolCall[],
201  options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
202): FittedState {
203  const goal = options.goal || goalFromMessages(messages);
204  const stateOf = (history: HistoryEntry[]): CompactionState => ({
205    context: STATE_CONTEXT,
206    goal,
207    history,
208  });
209  const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
210  const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
211  const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
212    state: stateOf(history),
213    tokens,
214    stage,
215  });
216
217  let history: HistoryEntry[] = [];
218  let perEntry: number[] = [];
219  let tokens = 0;
220  const rebuild = (inputChars: number): void => {
221    history = historyEntries(messages, calls, inputChars);
222    perEntry = history.map(entryTokens);
223    tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
224  };
225  const fits = (): boolean => tokens <= options.maxStateTokens;
226  const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
227    const entry = history[index];
228    if (!entry) return;
229    change(entry);
230    const now = entryTokens(entry);
231    tokens += now - (perEntry[index] ?? 0);
232    perEntry[index] = now;
233  };
234
235  rebuild(INPUT_CHARS[0]);
236  if (fits()) return fitted(history, tokens, 'full');
237
238  for (const limit of INPUT_CHARS.slice(1)) {
239    rebuild(limit);
240    if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
241  }
242
243  const pinned = (entry: HistoryEntry): boolean =>
244    isPinned(entry.i, messages.length, options.preserveRecentMessages);
245  const indices = history.map((_, index) => index);
246  const order = [
247    ...indices.filter((index) => !pinned(history[index]!)),
248    ...indices.filter((index) => pinned(history[index]!)),
249  ];
250
251  for (const index of order) {
252    const entry = history[index]!;
253    if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
254    shrink(index, (e) => {
255      e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
256    });
257    if (fits()) return fitted(history, tokens, 'texts abridged');
258  }
259
260  for (const index of order) {
261    const entry = history[index]!;
262    if (pinned(entry) || entry.text.length === 0) continue;
263    const original = messages[entry.i]?.text.length ?? entry.text.length;
264    shrink(index, (e) => {
265      e.text = `[… ${original} chars omitted …]`;
266    });
267    if (fits()) return fitted(history, tokens, 'old messages collapsed');
268  }
269
270  const byMessage = callsByMessage(calls);
271  for (const index of order) {
272    const entry = history[index]!;
273    const own = byMessage.get(entry.i);
274    if (pinned(entry) || !own) continue;
275    shrink(index, (e) => {
276      e.tool_calls = own.map(compactCall);
277    });
278    if (fits()) return fitted(history, tokens, 'old calls compacted');
279  }
280
281  const left = new Set<number>();
282  for (const index of order) {
283    const entry = history[index]!;
284    if (pinned(entry) || entry.tool_calls) continue;
285    left.add(index);
286    tokens -= perEntry[index] ?? 0;
287    if (fits()) {
288      return fitted(
289        history.filter((_, i) => !left.has(i)),
290        tokens,
291        'old messages left out',
292      );
293    }
294  }
295
296  history = mergeCallRuns(
297    history.filter((_, i) => !left.has(i)),
298    pinned,
299  );
300  perEntry = history.map(entryTokens);
301  tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
302  if (fits()) return fitted(history, tokens, 'old calls merged');
303
304  throw new Error(
305    `history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
306  );
307}
308