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

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.
| Command | Does |
|---|---|
/jev:verify | Fact-check claims against evidence |
/jev:screen | Check a URL, file, or text for prompt injection before reading it |
/jev:find | Pick the files or lines that best answer a question, by meaning |
/jev:ask | Ask yes/no, multiple-choice, or rating questions about text |
/jev:classify, /jev:extract, /jev:route | Label text, pull values out of documents, route requests to handlers |
/jev:compact | Compact 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.
jevctl npm package: npm install -g jevctl (provides the jev command). Node 20.12+.TYPESAFE_API_KEY. Get one at https://console.typesafe.ai/settings/keys.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".
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.
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.
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:*).
hooks/fast-jev.ts 306 lines1// 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};
306hooks/compaction/compact.ts 313 lines1// 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}
313hooks/compaction/request.ts 84 lines1// 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}
84hooks/compaction/types.ts 206 lines1// 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}
206hooks/compaction/state.ts 308 lines1// 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