Verbatim Kev-guided compaction for Claude Code sessions.

<h1 align="center">fast-kev-compaction</h1>
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a> <img alt="Claude Code 2.1.274+" src="https://img.shields.io/badge/Claude%20Code-2.1.274%2B-d97757"> <img alt="Desktop app and CLI" src="https://img.shields.io/badge/works%20in-desktop%20app%20%2B%20CLI-444">
A fork of fast-jev-compaction that scores with Kev, an open model you host yourself on Modal.
You need Claude Code 2.1.274 or newer (desktop app or CLI) and a Modal account.
sh claude plugin marketplace add 1njx/fast-kev-compaction claude plugin install fast-kev-compaction@fast-kev-compaction ``baseUrl, apiKey and model, and turn on function hooks in ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } } ``/kevcompact in a chat that has some tool calls.Step by step with troubleshooting: docs/MANUAL_SETUP.md. Or paste docs/AGENT_PROMPT.md into Claude, Codex or another coding agent and let it do the setup.
/kevcompact wakes the server if needed and compacts with Kev. A plain /compact stays Claude Code's built-in summary, and so do subagent compactions.compactAtPercent (for example 60) and the plugin starts the same flow on its own when the context reaches that percentage, at most once every 10 minutes.Options and client differences: hooks/README.md.
npm install fast-kev-compaction
import { compactMessages } from 'fast-kev-compaction';
const result = await compactMessages(transcript, { preserveRecentMessages: 4 });
console.log(result.messages, result.decisions, result.stats);
API and options: docs/LIBRARY.md. How the scoring works: docs/HOW_IT_WORKS.md.
npm install
npm run typecheck
npm test
npm run validate:plugin
The unit tests use a fake model and make no network calls.
Paste this into Claude Code, Claude, Codex or another coding agent that can run commands on your computer. It asks before anything that costs money or needs you to sign in, and it keeps your key secret. The plugin itself runs only inside Claude Code; another agent can still do the setup for you.
You are setting up the "fast-kev-compaction" Claude Code plugin for me, with my own Kev scoring server on Modal. The project is https://github.com/1njx/fast-kev-compaction. Read its README (Quick start) and docs/MANUAL_SETUP.md first, then do the setup step by step on my computer. Tell me briefly what you are doing before each step and check that it worked before the next one.
Rules you must follow:
- Ask me before anything that costs money, signs me in to an account, or needs a browser approval. Never create accounts, and never enter my passwords or payment details. Tell me exactly what to click and wait for me.
- Never print, log, commit or paste the endpoint API key. Generate it with a secure random generator, save it to a file only I can read (for example ~/.kev-modal-key), and pass it to commands through an environment variable or stdin.
- Show me the exact change before you edit ~/.claude/settings.json, and keep every existing setting.
- If something fails, show me the real error and what you will try next. Do not skip a step or pretend it worked.
Steps:
1. Preflight: check that Claude Code is 2.1.274 or newer (`claude --version`), Python 3.10+ and pip work, and git is installed.
2. Modal: `pip install modal==1.5.5`, then `modal setup` (I approve it in the browser). Tell me to confirm in Modal's billing settings that a payment method is on file (needed for GPU access and the $30 monthly free credit) and that the spend limit is $0, so it stops instead of billing. Wait for my confirmation.
3. Clone the repo and generate the key (`python -c "import secrets; print(secrets.token_hex(24))"`). Deploy: `KEV_API_KEY=<key> KEV_MODEL=jaredpalmer/kev-27b modal deploy deploy/kev_serve.py` (on Windows PowerShell set `$env:KEV_API_KEY` and `$env:KEV_MODEL` first). The first start downloads about 51 GB and takes about 6 minutes.
4. Verify: GET <endpoint>/v1/models returns 200 with the key and 401 without it (use `curl -L --max-time 900`).
5. Install the plugin: `claude plugin marketplace add 1njx/fast-kev-compaction`, then `claude plugin install fast-kev-compaction@fast-kev-compaction`.
6. Configure the plugin options: baseUrl = <endpoint>/v1/systemone, apiKey = the key, model = kev-latest (`claude plugin configure fast-kev-compaction@fast-kev-compaction --values-stdin`, JSON on stdin, from a file so the key stays out of shell history).
7. Add "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } to ~/.claude/settings.json (merge it, show me the diff).
8. Tell me to fully quit and reopen Claude (including the tray icon), then in a chat that has some tool calls type /kevcompact. A cold server takes 1 to 5 minutes; I should see progress messages and a result.
9. Finish with a short summary: what you installed, where the key file is, how to stop the server (`modal app stop kev`), and what it can cost.
If I use Codex or another agent that cannot run Claude Code plugins: the plugin itself only runs inside Claude Code, but you can still do every step above for me.
deploy/kev_serve.py is copied from it with a shorter idle timeout./kevcompact, the progress messages and CLI support by @1njx.Released under the MIT license.
hooks/fast-kev.ts 551 lines1import type {
2 On,
3 PluginOptions,
4 Register,
5 SessionMessage,
6 ToolResultSummary,
7 ToolUseSummary,
8 TurnCompleteInput,
9} from 'claude-code';
10
11import { compact, reductionRatio, resolveOptions } from '../src/compact.js';
12import { buildKevRequest, DEFAULT_MODEL, parseKevResponse } from '../src/request.js';
13import type {
14 CompactOptions,
15 CompactResult,
16 KevAsker,
17 Message,
18 ToolResult,
19 ToolUse,
20} from '../src/types.js';
21
22const HOOK_DEFAULTS = {
23 compactAtPercent: 0, // 0 = automatic compaction is off
24 minReductionRatio: 0.25,
25 model: DEFAULT_MODEL,
26};
27
28export type HookFetchInit = {
29 method?: string;
30 headers?: Record<string, string>;
31 body?: string;
32};
33
34export type HookFetchResponse = {
35 status: number;
36 ok: boolean;
37 text: string;
38};
39
40/** The shape of `$.http.fetch`, so the hook can be driven without an engine. */
41export type HookFetch = (url: string, init?: HookFetchInit) => Promise<HookFetchResponse>;
42
43export type HookConfig = CompactOptions & {
44 apiKey?: string;
45 /** Full URL of your Kev endpoint (https://<workspace>--kev-api.modal.run/v1/systemone). Required. */
46 baseUrl?: string;
47 compactAtPercent: number;
48 /** Print one line per scored call to the chat. Off by default: a long session produces pages of it. */
49 logDecisions?: boolean;
50 minReductionRatio: number;
51 model: string;
52};
53
54function optionNumber(options: PluginOptions, key: string, fallback: number): number {
55 const value = options[key];
56 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
57}
58
59function optionString(options: PluginOptions, key: string): string | undefined {
60 const value = options[key];
61 return typeof value === 'string' && value.length > 0 ? value : undefined;
62}
63
64/** Reads the plugin's `userConfig` values; anything missing takes the defaults. */
65export function resolveHookConfig(options: PluginOptions): HookConfig {
66 const numbers: Partial<Omit<CompactOptions, 'goal'>> = {};
67 for (const key of [
68 'keepThreshold',
69 'preserveRecentMessages',
70 'maxStateTokens',
71 'maxRequestTokens',
72 'truncateHeadChars',
73 'requestConcurrency',
74 'requestRetries',
75 ] as const) {
76 const value = options[key];
77 if (typeof value === 'number' && Number.isFinite(value)) numbers[key] = value;
78 }
79 const config: HookConfig = {
80 ...numbers,
81 compactAtPercent: optionNumber(options, 'compactAtPercent', HOOK_DEFAULTS.compactAtPercent),
82 minReductionRatio: optionNumber(
83 options,
84 'minReductionRatio',
85 HOOK_DEFAULTS.minReductionRatio,
86 ),
87 model: optionString(options, 'model') ?? HOOK_DEFAULTS.model,
88 };
89 const apiKey = optionString(options, 'apiKey');
90 if (apiKey) config.apiKey = apiKey;
91 const baseUrl = optionString(options, 'baseUrl');
92 if (baseUrl) config.baseUrl = baseUrl;
93 if (options['logDecisions'] === true) config.logDecisions = true;
94 const goal = optionString(options, 'goal');
95 if (goal) config.goal = goal;
96 return config;
97}
98
99/** A `KevAsker` over the engine's `$.http.fetch`. */
100export function kevAsker(
101 fetchFn: HookFetch,
102 apiKey: string,
103 model: string,
104 baseUrl: string,
105): KevAsker {
106 return {
107 async ask(state, questions) {
108 const request = buildKevRequest({ apiKey, model, baseUrl }, state, questions);
109 const response = await fetchFn(request.url, {
110 method: request.method,
111 headers: request.headers,
112 body: request.body,
113 });
114 return parseKevResponse(response.status, response.ok, response.text);
115 },
116 };
117}
118
119function toolUseSummary(tool: ToolUse): ToolUseSummary {
120 const summary: ToolUseSummary = {
121 tool_use_id: tool.tool_use_id,
122 tool: tool.tool,
123 input: tool.input,
124 };
125 if (tool.text !== undefined) summary.text = tool.text;
126 if (tool.isError) summary.isError = true;
127 return summary;
128}
129
130function toolResultSummary(result: ToolResult): ToolResultSummary {
131 return {
132 tool_use_id: result.tool_use_id,
133 text: result.text,
134 isError: result.isError ?? false,
135 };
136}
137
138/**
139 * Maps the library's output back onto session messages. Whatever came back
140 * unchanged (a message, a tool use, a tool result) is the engine's own object,
141 * handle included; anything rebuilt is a fresh message without a handle, so the
142 * engine takes the edited content instead of its original.
143 */
144export function toSessionMessages(
145 input: readonly SessionMessage[],
146 output: readonly Message[],
147): SessionMessage[] {
148 const messages = new Map<Message, SessionMessage>();
149 const uses = new Map<ToolUse, ToolUseSummary>();
150 const results = new Map<ToolResult, ToolResultSummary>();
151 for (const message of input) {
152 messages.set(message, message);
153 for (const tool of message.toolUses) uses.set(tool, tool);
154 for (const result of message.toolResults ?? []) results.set(result, result);
155 }
156 return output.map((message) => {
157 const own = messages.get(message);
158 if (own) return own;
159 const rebuilt: SessionMessage = {
160 role: message.role,
161 text: message.text,
162 toolUses: message.toolUses.map((tool) => uses.get(tool) ?? toolUseSummary(tool)),
163 };
164 if (message.toolResults && message.toolResults.length > 0) {
165 rebuilt.toolResults = message.toolResults.map(
166 (result) => results.get(result) ?? toolResultSummary(result),
167 );
168 }
169 return rebuilt;
170 });
171}
172
173export type SessionCompaction = {
174 result: CompactResult;
175 messages: SessionMessage[];
176};
177
178/** Runs the library over a session transcript; throws when the key is missing or Kev fails. */
179export async function compactSession(
180 messages: readonly SessionMessage[],
181 config: HookConfig,
182 fetchFn: HookFetch,
183): Promise<SessionCompaction> {
184 if (!config.baseUrl) throw new Error('no Kev endpoint: set the baseUrl plugin option');
185 if (!config.apiKey) throw new Error('no API key: set the apiKey plugin option (or the KEV_API_KEY environment variable)');
186 const result = await compact(
187 messages,
188 kevAsker(fetchFn, config.apiKey, config.model, config.baseUrl),
189 config,
190 );
191 return { result, messages: toSessionMessages(messages, result.messages) };
192}
193
194// The hook tsconfig has no DOM or Node typings; the hook runtime has setTimeout.
195declare const setTimeout: (callback: () => void, ms: number) => unknown;
196
197/**
198 * Waits for a scale-to-zero server (Kev on Modal) to wake: GET /v1/models until it answers.
199 * A cold GPU takes 2-6 minutes, longer than one request lives, so each try can fail and the next one lands on the warming container.
200 * shortcut: 6 tries of up to ~150 s each, then give up with an error.
201 */
202export async function warmUp(
203 fetchFn: HookFetch,
204 config: HookConfig,
205 sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
206 onTry?: (attempt: number, total: number) => void,
207): Promise<void> {
208 if (!config.baseUrl) throw new Error('no Kev endpoint: set the baseUrl plugin option');
209 const url = config.baseUrl.replace(/\/v1\/systemone\/?$/, '/v1/models');
210 const total = 6;
211 for (let attempt = 0; attempt < total; attempt++) {
212 if (attempt > 0) onTry?.(attempt + 1, total);
213 try {
214 const response = await fetchFn(url, { headers: { authorization: `Bearer ${config.apiKey ?? ''}` } });
215 if (response.ok) return;
216 if (response.status === 401 || response.status === 403) throw new Error(`endpoint rejected the key (${response.status})`);
217 } catch (error) {
218 if (error instanceof Error && error.message.startsWith('endpoint rejected')) throw error;
219 }
220 await sleep(3000);
221 }
222 throw new Error('the Kev server did not wake up');
223}
224
225function percent(ratio: number): string {
226 return `${Math.round(ratio * 100)}%`;
227}
228
229export function summarize(result: CompactResult): string {
230 const { stats } = result;
231 const parts = [
232 stats.kept > 0 ? `${stats.kept} kept` : '',
233 stats.resultsDropped > 0 ? `${stats.resultsDropped} results truncated` : '',
234 stats.callsDropped > 0 ? `${stats.callsDropped} call_dropped` : '',
235 stats.pinned > 0 ? `${stats.pinned} pinned` : '',
236 ].filter(Boolean);
237 return `${percent(reductionRatio(result))} reduction; ${
238 parts.join(', ') || 'no tool calls'
239 }; state ~${stats.stateTokens} tokens (${stats.stateStage}) in ${stats.requests} request(s)`;
240}
241
242const UI_LOG_MAX_CHARS = 4096;
243
244export function decisionLog(result: CompactResult): string {
245 return result.decisions
246 .filter((d) => d.reason !== 'pinned')
247 .map(
248 (d) =>
249 `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`,
250 )
251 .join(' ');
252}
253
254export function decisionLogLines(
255 result: CompactResult,
256 maxChars: number = UI_LOG_MAX_CHARS,
257): string[] {
258 const entries = decisionLog(result).split(' ').filter(Boolean);
259 if (entries.length === 0) return ['decisions: (none)'];
260 const chunks: string[] = [];
261 let current = '';
262 for (const entry of entries) {
263 const next = current ? `${current} ${entry}` : entry;
264 if (current && next.length > maxChars - 24) {
265 chunks.push(current);
266 current = entry;
267 } else current = next;
268 }
269 chunks.push(current);
270 return chunks.map((chunk, index) =>
271 chunks.length === 1
272 ? `decisions: ${chunk}`
273 : `decisions (${index + 1}/${chunks.length}): ${chunk}`,
274 );
275}
276
277export async function getApiKey(
278 $: { env: { get: (name: string) => Promise<string | undefined> } },
279 config: HookConfig,
280): Promise<string | undefined> {
281 return config.apiKey || (await $.env.get('KEV_API_KEY')) || undefined;
282}
283
284function notify(
285 $: {
286 ui: {
287 log: (text: string) => void;
288 toast: (text: string, options?: { timeoutMs?: number }) => void;
289 };
290 },
291 text: string,
292 timeoutMs = 15_000,
293): void {
294 $.ui.log(text);
295 $.ui.toast(text, { timeoutMs });
296}
297
298/** "52 s", "2 min 05 s". */
299export function formatDuration(ms: number): string {
300 if (ms < 1000) return 'under 1 s';
301 const seconds = Math.round(ms / 1000);
302 if (seconds < 60) return `${seconds} s`;
303 return `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, '0')} s`;
304}
305
306function plural(count: number, noun: string): string {
307 return `${count} ${noun}${count === 1 ? '' : 's'}`;
308}
309
310/** Plain-English report for a Kev compaction that replaced the built-in summary. */
311export function explainWorked(result: CompactResult, scoringMs: number): string {
312 const { stats } = result;
313 const freed = Math.round((stats.charsBefore - stats.charsAfter) / 4);
314 const dropped = [
315 stats.callsDropped > 0 ? `removed ${plural(stats.callsDropped, 'tool call')} that no longer mattered` : '',
316 stats.resultsDropped > 0 ? `shortened ${plural(stats.resultsDropped, 'tool output')}` : '',
317 ].filter(Boolean);
318 return (
319 `It worked. Kev kept ${stats.messagesAfter} of ${stats.messagesBefore} messages and made the conversation about ` +
320 `${percent(reductionRatio(result))} smaller (roughly ${freed.toLocaleString('en-US')} tokens freed). ` +
321 `${dropped.length > 0 ? `It ${dropped.join(' and ')}. ` : ''}` +
322 `${stats.pinned > 0 ? `${plural(stats.pinned, 'message')} (your first and the newest) were left alone. ` : ''}` +
323 `${stats.unscored > 0 ? `${plural(stats.unscored, 'tool call')} from the far past were too many to score at once and were left as they are. ` : ''}` +
324 `Nothing was summarized or reworded: everything kept is word for word. Kev scoring took ${formatDuration(scoringMs)}.`
325 );
326}
327
328/** Plain-English report when Kev ran but too little could be dropped. */
329export function explainTooSmall(result: CompactResult, minRatio: number): string {
330 return (
331 `Kev checked the conversation but found little it could safely drop (about ${percent(reductionRatio(result))} smaller; ` +
332 `the plugin needs at least ${percent(minRatio)}). Claude's normal summary was used instead, so the session is still ` +
333 `compacted and nothing was lost. This is normal for short chats.`
334 );
335}
336
337/** Turns a raw error into a sentence that says what to check. */
338export function friendlyError(message: string): string {
339 if (/rejected the key|\b(401|403)\b/.test(message)) {
340 return 'the Kev server rejected the API key. Check the apiKey plugin option against the key you deployed with';
341 }
342 if (/did not wake/.test(message)) {
343 return 'the Kev server never started. The Modal app may be stopped or out of free credit: check modal.com/apps';
344 }
345 if (/no API key/.test(message)) return message;
346 if (/\b5\d\d\b|timed? ?out|abort/i.test(message)) {
347 return `the Kev server was too slow or errored (${message}). This is usually a cold start that outlasted the hook's time limit; try /kevcompact again in a minute`;
348 }
349 return message;
350}
351
352type Outcome = { kind: 'worked' | 'fallback' | 'error'; text: string };
353
354let compacting = false;
355// What the session.compact hook last did, so startKevCompaction can report it when the built-in /compact returns.
356let outcome: Outcome | undefined;
357let lastAutoAt = 0;
358const AUTO_COOLDOWN_MS = 10 * 60 * 1000; // a fallback leaves the context full: do not wake the GPU again every turn
359// After /kevcompact wakes the server, the next manual /compact is scored by Kev until this time (ms since epoch).
360let armedUntil = 0;
361const ARM_MS = 60 * 1000; // the Modal container sleeps after 60 s idle
362
363function hookFetch($: { http: { fetch: (url: string, init?: HookFetchInit) => Promise<HookFetchResponse> } }): HookFetch {
364 return async (url, init) => {
365 const response = await $.http.fetch(url, init);
366 return { status: response.status, ok: response.ok, text: response.text };
367 };
368}
369
370type KevEngine = Parameters<typeof getApiKey>[0] &
371 Parameters<typeof notify>[0] & {
372 http: { fetch: (url: string, init?: HookFetchInit) => Promise<HookFetchResponse> };
373 command: { run: (args: { command: string }) => Promise<unknown> };
374 session: { compact: () => Promise<unknown> };
375 };
376
377/**
378 * Starts the compaction. The interactive CLI allows $.session.compact() once the turn that ran /kevcompact is over (a
379 * rejection inside it is retried); the desktop app's headless sessions refuse it, so there the built-in /compact is run
380 * instead, and armedUntil makes that one scored by Kev.
381 */
382async function runCompaction($: KevEngine): Promise<void> {
383 for (let attempt = 1; ; attempt++) {
384 try {
385 await $.session.compact();
386 return;
387 } catch (error) {
388 const message = error instanceof Error ? error.message : String(error);
389 if (/headless/i.test(message)) {
390 await $.command.run({ command: 'compact' });
391 return;
392 }
393 if (attempt >= 10) throw error;
394 await new Promise<void>((resolve) => setTimeout(resolve, 1000));
395 }
396 }
397}
398
399/** Posts "still waking" toasts every 30 s until the returned stop function is called. */
400function heartbeat($: Parameters<typeof notify>[0], startedAt: number): () => void {
401 let stopped = false;
402 const beat = (): void => {
403 setTimeout(() => {
404 if (stopped) return;
405 notify(
406 $,
407 `Kev compaction 1/3: still waking the Kev server (${formatDuration(Date.now() - startedAt)} so far; cold starts usually take 1 to 5 minutes)`,
408 );
409 beat();
410 }, 30_000);
411 };
412 beat();
413 return () => {
414 stopped = true;
415 };
416}
417
418/**
419 * Wakes the Kev server, then asks Claude Code to compact, narrating each step and reporting the result. Runs detached from
420 * the hook that started it: a hook may run only about 10 s, a cold GPU needs minutes, and the host refuses
421 * session.compact inside a command.run hook.
422 */
423async function startKevCompaction($: KevEngine, configured: HookConfig, automatic?: string): Promise<void> {
424 if (compacting) return;
425 compacting = true;
426 outcome = undefined;
427 const started = Date.now();
428 try {
429 const config = { ...configured, apiKey: await getApiKey($, configured) };
430 if (!config.baseUrl) throw new Error('no Kev endpoint: set the baseUrl plugin option');
431 notify(
432 $,
433 `${automatic ? `Kev compaction (automatic: ${automatic}) ` : 'Kev compaction '}1/3: waking the Kev server. A cold start takes 1 to 5 minutes, a warm one a few seconds. I will post updates.`,
434 );
435 const stopBeat = heartbeat($, started);
436 try {
437 await warmUp(hookFetch($), config, undefined, (attempt, total) =>
438 notify($, `Kev compaction 1/3: still waiting for the server, checking again (check ${attempt} of ${total}; this is normal during a cold start)`),
439 );
440 } finally {
441 stopBeat();
442 }
443 armedUntil = Date.now() + ARM_MS;
444 notify(
445 $,
446 `Kev compaction 2/3: the Kev server is up (took ${formatDuration(Date.now() - started)}). Handing your conversation to Kev to score.`,
447 );
448 try {
449 await runCompaction($);
450 armedUntil = 0;
451 } catch (error) {
452 $.ui.log(`/compact could not run: ${error instanceof Error ? error.message : String(error)}`);
453 notify(
454 $,
455 'The Kev server is up, but I could not start /compact for you. Type /compact within a minute and Kev will score it.',
456 45_000,
457 );
458 return;
459 }
460 const total = formatDuration(Date.now() - started);
461 const result = outcome as Outcome | undefined;
462 if (result?.kind === 'worked') notify($, `Kev compaction finished. ${result.text} Total time ${total}.`, 60_000);
463 else if (result)
464 notify($, `Kev compaction finished, but Claude's normal summary did the work. ${result.text} Total time ${total}.`, 60_000);
465 else
466 notify(
467 $,
468 `Kev compaction finished after ${total}, but Kev was never asked to score anything (the conversation may have been too short to compact).`,
469 45_000,
470 );
471 } catch (error) {
472 const message = error instanceof Error ? error.message : String(error);
473 notify(
474 $,
475 `Kev compaction failed after ${formatDuration(Date.now() - started)}: ${friendlyError(message)}. Your conversation was not changed. Run /compact for Claude's normal summary, or try /kevcompact again.`,
476 60_000,
477 );
478 } finally {
479 compacting = false;
480 }
481}
482
483export const register: Register = (on: On, options: PluginOptions) => {
484 const configured = resolveHookConfig(options);
485
486 on('session.start', ($, event, next) =>
487 $.command
488 .register({ name: 'kevcompact', description: 'Compact with Kev/Kev: keep messages verbatim, drop tool output no longer needed' })
489 .then(() => next(event)),
490 );
491
492 on('command.run', { command: 'kevcompact' }, async ($) => {
493 if (compacting) return { text: 'a Kev compaction is already running' };
494 void startKevCompaction($, configured);
495 return {
496 text: 'Kev compaction started. Progress messages follow: 1) waking the server (seconds if warm, 1 to 5 minutes if cold), 2) server up, 3) scoring, then the result.',
497 };
498 });
499
500 on('session.compact', async ($, event, next) => {
501 // shortcut: Kev scores plugin-triggered compactions and the one /compact that follows /kevcompact; other /compact runs stay built-in
502 if (event.agentId) return next(event);
503 const viaKev = event.trigger === 'plugin' || (event.trigger === 'manual' && Date.now() < armedUntil);
504 if (event.trigger === 'manual') armedUntil = 0;
505 if (!viaKev) return next(event);
506 const started = Date.now();
507 // With no /kevcompact flow narrating (a plugin-triggered or hand-typed /compact), this hook reports on its own.
508 const alone = !compacting;
509 if (!alone) notify($, `Kev compaction 3/3: scoring ${plural(event.messages.length, 'message')} (usually 1 to 10 seconds)`);
510 try {
511 const config = { ...configured, apiKey: await getApiKey($, configured) };
512 const { result, messages } = await compactSession(event.messages, config, hookFetch($));
513 if (config.logDecisions) for (const line of decisionLogLines(result)) $.ui.log(line);
514 if (reductionRatio(result) < config.minReductionRatio) {
515 outcome = { kind: 'fallback', text: explainTooSmall(result, config.minReductionRatio) };
516 if (alone) notify($, `Kev compaction: ${outcome.text}`, 45_000);
517 return next(event);
518 }
519 outcome = { kind: 'worked', text: explainWorked(result, Date.now() - started) };
520 if (alone) notify($, `Kev compaction: ${outcome.text}`, 45_000);
521 return { messages };
522 } catch (error) {
523 const message = error instanceof Error ? error.message : String(error);
524 outcome = {
525 kind: 'error',
526 text: `Kev could not score this conversation: ${friendlyError(message)}. Claude's normal summary was used instead, so the session is still compacted and nothing was lost.`,
527 };
528 if (alone) notify($, `Kev compaction: ${outcome.text}`, 45_000);
529 return next(event);
530 }
531 });
532
533 on('turn.complete', async ($, event: TurnCompleteInput, next) => {
534 if (configured.compactAtPercent <= 0 || compacting || Date.now() - lastAutoAt < AUTO_COOLDOWN_MS) return next(event);
535 try {
536 const { context } = await $.session.usage();
537 if ((context.percent ?? 0) >= configured.compactAtPercent) {
538 lastAutoAt = Date.now();
539 void startKevCompaction($, configured, `context is at ${Math.round(context.percent ?? 0)}%`);
540 }
541 } catch (error) {
542 $.ui.log(
543 `auto-compact skipped (${error instanceof Error ? error.message : String(error)})`,
544 );
545 }
546 return next(event);
547 });
548};
549
550export { resolveOptions };
551src/compact.ts 361 lines1import { noulAnswer } from './request.js';
2import { collectToolCalls, estimateTokens, fitState } from './state.js';
3import type {
4 CallAnswer,
5 CallDecision,
6 CompactOptions,
7 CompactResult,
8 CompactionState,
9 KevAsker,
10 KevQuestions,
11 Message,
12 ResolvedCompactOptions,
13 ToolCall,
14 ToolUse,
15} from './types.js';
16
17export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
18 goal: '',
19 keepThreshold: 0.5,
20 preserveRecentMessages: 6,
21 maxStateTokens: 25_000,
22 maxRequestTokens: 30_000,
23 truncateHeadChars: 300,
24 requestConcurrency: 1,
25 requestRetries: 2,
26};
27
28/** Tokens the request envelope (`model`, key names) adds around state and questions. */
29const REQUEST_OVERHEAD_TOKENS = 20;
30
31function finite(value: number | undefined, fallback: number): number {
32 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
33}
34
35export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
36 return {
37 goal: options.goal ?? DEFAULT_OPTIONS.goal,
38 keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
39 preserveRecentMessages: Math.max(
40 0,
41 Math.floor(
42 finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
43 ),
44 ),
45 maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
46 maxRequestTokens: Math.max(
47 1,
48 finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
49 ),
50 truncateHeadChars: Math.max(
51 0,
52 Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
53 ),
54 requestConcurrency: Math.max(
55 1,
56 Math.floor(finite(options.requestConcurrency, DEFAULT_OPTIONS.requestConcurrency)),
57 ),
58 requestRetries: Math.max(
59 0,
60 Math.floor(finite(options.requestRetries, DEFAULT_OPTIONS.requestRetries)),
61 ),
62 };
63}
64
65/** The two `noul` questions asked about one call: keep the call, keep its result. */
66export function questionsFor(call: ToolCall): KevQuestions {
67 return {
68 [`call_${call.id}`]: {
69 type: 'noul',
70 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`,
71 },
72 [`result_${call.id}`]: {
73 type: 'noul',
74 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`,
75 },
76 };
77}
78
79/**
80 * Splits the candidate calls into batches whose questions, together with the
81 * (always complete) state, fit one request.
82 */
83export function batchCalls(
84 calls: readonly ToolCall[],
85 stateTokens: number,
86 options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
87): ToolCall[][] {
88 const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
89 const batches: ToolCall[][] = [];
90 let current: ToolCall[] = [];
91 let currentTokens = 0;
92 for (const call of calls) {
93 const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
94 if (current.length > 0 && currentTokens + tokens > budget) {
95 batches.push(current);
96 current = [];
97 currentTokens = 0;
98 }
99 if (current.length === 0 && tokens > budget) {
100 throw new Error(
101 `state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
102 );
103 }
104 current.push(call);
105 currentTokens += tokens;
106 }
107 if (current.length > 0) batches.push(current);
108 return batches;
109}
110
111export function decideCall(
112 call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
113 answer: CallAnswer,
114 options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
115): CallDecision {
116 const base = { id: call.id, tool: call.tool, ...answer };
117 if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
118 if (answer.keepResult >= options.keepThreshold) {
119 return { ...base, action: 'keep', reason: 'kept' };
120 }
121 if (answer.keepCall >= options.keepThreshold) {
122 return { ...base, action: 'drop_result', reason: 'result_dropped' };
123 }
124 return { ...base, action: 'drop_call', reason: 'call_dropped' };
125}
126
127/** A 4xx answer will not change on a retry; anything else (timeout, 5xx, dropped connection) might. */
128function retryable(error: unknown): boolean {
129 return !/\(4\d\d\)/.test(error instanceof Error ? error.message : String(error));
130}
131
132/** Runs `fn` over `items` with at most `limit` in flight, keeping the results in order. */
133export async function mapPool<T, R>(
134 items: readonly T[],
135 limit: number,
136 fn: (item: T) => Promise<R>,
137): Promise<R[]> {
138 const out = new Array<R>(items.length);
139 let next = 0;
140 const worker = async (): Promise<void> => {
141 while (next < items.length) {
142 const index = next++;
143 out[index] = await fn(items[index] as T);
144 }
145 };
146 await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
147 return out;
148}
149
150async function askBatch(
151 asker: KevAsker,
152 state: CompactionState,
153 batch: readonly ToolCall[],
154 retries: number,
155): Promise<Map<string, CallAnswer>> {
156 const questions: KevQuestions = Object.assign({}, ...batch.map(questionsFor));
157 let response: Awaited<ReturnType<KevAsker['ask']>> | undefined;
158 for (let attempt = 0; ; attempt++) {
159 try {
160 response = await asker.ask(state, questions);
161 break;
162 } catch (error) {
163 if (attempt >= retries || !retryable(error)) throw error;
164 }
165 }
166 const { answers } = response;
167 return new Map(
168 batch.map((call) => [
169 call.id,
170 {
171 keepCall: noulAnswer(answers, `call_${call.id}`),
172 keepResult: noulAnswer(answers, `result_${call.id}`),
173 },
174 ]),
175 );
176}
177
178function truncatedResultText(text: string, isError: boolean, headChars: number): string {
179 if (text.length <= headChars + 120) return text;
180 const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
181 return `${head}[fast-kev-compaction truncated ${text.length - headChars} chars of this tool result${
182 isError ? ' (error)' : ''
183 }; re-run the tool if needed]`;
184}
185
186/**
187 * Rebuilds the conversation from the decisions. A dropped call disappears
188 * together with its result; a dropped result keeps a bounded head and note.
189 * Messages that lose all their content are removed; untouched messages are
190 * returned as the same objects they came in as.
191 */
192export function applyDecisions(
193 messages: readonly Message[],
194 decisions: readonly CallDecision[],
195 calls: readonly ToolCall[],
196 headChars: number,
197): Message[] {
198 const byId = new Map(calls.map((call) => [call.id, call]));
199 const actions = new Map<string, CallDecision['action']>();
200 for (const decision of decisions) {
201 const call = byId.get(decision.id);
202 if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
203 }
204 const kept: Message[] = [];
205 for (const message of messages) {
206 const touched =
207 message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
208 (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
209 if (!touched) {
210 kept.push(message);
211 continue;
212 }
213 const toolUses = message.toolUses
214 .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
215 .map((tool) => {
216 if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
217 const text = truncatedResultText(
218 tool.text ?? '',
219 tool.isError ?? false,
220 headChars,
221 );
222 if ((tool.text ?? '') === text) return tool;
223 const copy: ToolUse = {
224 tool_use_id: tool.tool_use_id,
225 tool: tool.tool,
226 input: tool.input,
227 text,
228 };
229 if (tool.isError) copy.isError = true;
230 return copy;
231 });
232 const toolResults = (message.toolResults ?? [])
233 .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
234 .map((result) => {
235 if (actions.get(result.tool_use_id) !== 'drop_result') return result;
236 const text = truncatedResultText(result.text, result.isError ?? false, headChars);
237 return text === result.text
238 ? result
239 : {
240 tool_use_id: result.tool_use_id,
241 text,
242 isError: result.isError,
243 };
244 });
245 if (
246 !message.toolUses.some(
247 (tool) => actions.get(tool.tool_use_id) === 'drop_call',
248 ) &&
249 !(message.toolResults ?? []).some(
250 (result) => actions.get(result.tool_use_id) === 'drop_call',
251 ) &&
252 toolUses.every((tool, index) => tool === message.toolUses[index]) &&
253 toolResults.every(
254 (result, index) => result === message.toolResults?.[index],
255 )
256 ) {
257 kept.push(message);
258 continue;
259 }
260 if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
261 continue;
262 }
263 const rebuilt: Message = { role: message.role, text: message.text, toolUses };
264 if (toolResults.length > 0) rebuilt.toolResults = toolResults;
265 kept.push(rebuilt);
266 }
267 return kept;
268}
269
270/** Characters of text, tool input and tool output a message holds. */
271export function messageChars(message: Message): number {
272 let total = message.text.length;
273 for (const tool of message.toolUses) {
274 try {
275 total += JSON.stringify(tool.input).length;
276 } catch {
277 total += 20;
278 }
279 }
280 for (const result of message.toolResults ?? []) total += result.text.length;
281 return total;
282}
283
284export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
285 const { charsBefore, charsAfter } = result.stats;
286 return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
287}
288
289function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
290 return decisions.filter((decision) => decision.reason === reason).length;
291}
292
293/**
294 * Compacts a transcript by asking Kev, for every tool call outside the pinned
295 * first and newest messages, whether the call and whether its result must
296 * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
297 * sent as state with every batch of questions. Throws when Kev fails or the
298 * history cannot be fitted; the caller decides whether to fall back.
299 */
300export async function compact(
301 messages: readonly Message[],
302 asker: KevAsker,
303 options: CompactOptions = {},
304): Promise<CompactResult> {
305 const started = Date.now();
306 const resolved = resolveOptions(options);
307 const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
308 const candidates = calls.filter((call) => !call.pinned);
309 const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
310
311 let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
312 let batches: ToolCall[][] = [];
313 let unscoredCount = 0;
314 const answers = new Map<string, CallAnswer>();
315 if (candidates.length > 0) {
316 const state = fitState(messages, calls, resolved);
317 fitted = state;
318 const unscored = new Set(state.unscored ?? []);
319 unscoredCount = unscored.size;
320 batches = batchCalls(
321 candidates.filter((call) => !unscored.has(call.id)),
322 state.tokens,
323 resolved,
324 );
325 const answered = await mapPool(batches, resolved.requestConcurrency, (batch) =>
326 askBatch(asker, state.state, batch, resolved.requestRetries),
327 );
328 for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
329 }
330
331 const decisions = calls.map((call) =>
332 decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
333 );
334 const kept = applyDecisions(
335 messages,
336 decisions,
337 calls,
338 resolved.truncateHeadChars,
339 );
340 return {
341 messages: kept,
342 decisions,
343 stats: {
344 messagesBefore: messages.length,
345 messagesAfter: kept.length,
346 charsBefore,
347 charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
348 calls: calls.length,
349 kept: count(decisions, 'kept'),
350 resultsDropped: count(decisions, 'result_dropped'),
351 callsDropped: count(decisions, 'call_dropped'),
352 pinned: count(decisions, 'pinned'),
353 stateTokens: fitted.tokens,
354 stateStage: fitted.stage,
355 requests: batches.length,
356 unscored: unscoredCount,
357 ms: Date.now() - started,
358 },
359 };
360}
361src/request.ts 81 lines1import type { KevAnswer, KevQuestions, KevResponse, KevState } from './types.js';
2
3export const DEFAULT_MODEL = 'kev-latest';
4
5export interface KevRequest {
6 url: string;
7 method: 'POST';
8 headers: Record<string, string>;
9 body: string;
10}
11
12/** The HTTP request for one Kev call, for any fetch-like transport. */
13export function buildKevRequest(
14 params: {
15 apiKey: string;
16 model?: string;
17 /** Full URL of the Kev endpoint, e.g. https://<workspace>--kev-api.modal.run/v1/systemone. */
18 baseUrl: string;
19 },
20 state: KevState,
21 questions: KevQuestions,
22): KevRequest {
23 return {
24 url: params.baseUrl,
25 method: 'POST',
26 headers: {
27 authorization: `Bearer ${params.apiKey}`,
28 'content-type': 'application/json',
29 },
30 body: JSON.stringify({
31 model: params.model ?? DEFAULT_MODEL,
32 state,
33 questions,
34 }),
35 };
36}
37
38/** Validates a Kev response body; throws on anything but an `answers` object. */
39export function parseKevResponse(
40 status: number,
41 ok: boolean,
42 text: string,
43): KevResponse {
44 if (!ok) {
45 throw new Error(`Kev request failed (${status}): ${text.slice(0, 200)}`);
46 }
47 let parsed: unknown;
48 try {
49 parsed = JSON.parse(text);
50 } catch {
51 throw new Error('Kev returned malformed JSON');
52 }
53 if (
54 parsed === null ||
55 typeof parsed !== 'object' ||
56 !('answers' in parsed) ||
57 parsed.answers === null ||
58 typeof parsed.answers !== 'object'
59 ) {
60 throw new Error('Kev response is missing answers');
61 }
62 return parsed as KevResponse;
63}
64
65/** The `noul` probability of one answer; throws when it is not there. */
66export function noulAnswer(
67 answers: Record<string, KevAnswer>,
68 name: string,
69): number {
70 const answer = answers[name];
71 if (
72 !answer ||
73 !('noul' in answer) ||
74 typeof answer.noul !== 'number' ||
75 !Number.isFinite(answer.noul)
76 ) {
77 throw new Error(`Invalid Kev answer for ${name}`);
78 }
79 return answer.noul;
80}
81src/types.ts 213 lines1export type Role = 'user' | 'assistant';
2
3/**
4 * A tool_use block of an assistant message. `text` and `isError` mirror the
5 * outcome once the transcript holds it (Claude Code attaches them).
6 */
7export interface ToolUse {
8 tool_use_id: string;
9 tool: string;
10 input: Record<string, unknown>;
11 text?: string;
12 isError?: boolean;
13}
14
15/** A tool_result block of a user message. */
16export interface ToolResult {
17 tool_use_id: string;
18 text: string;
19 isError?: boolean;
20}
21
22/**
23 * One transcript message. The shape is a subset of Claude Code's
24 * `SessionMessage`, so a session transcript can be passed in as is.
25 */
26export interface Message {
27 role: Role;
28 text: string;
29 toolUses: ToolUse[];
30 toolResults?: ToolResult[];
31}
32
33/** A tool call paired with its result by `tool_use_id`. */
34export interface ToolCall {
35 /** Short id used in the Kev state and question names (`t1`, `t2`, ...). */
36 id: string;
37 tool_use_id: string;
38 tool: string;
39 input: Record<string, unknown>;
40 /** Index of the message holding the tool_use block. */
41 callIndex: number;
42 /** Index of the message holding the tool_result block. */
43 resultIndex: number;
44 resultChars: number;
45 isError: boolean;
46 /** In the first or the newest preserved messages; never a candidate. */
47 pinned: boolean;
48}
49
50export interface CallAnswer {
51 /** Kev's probability that the call itself still matters. */
52 keepCall: number;
53 /** Kev's probability that the full result still needs to stay verbatim. */
54 keepResult: number;
55}
56
57export type CallAction = 'keep' | 'drop_result' | 'drop_call';
58
59export interface CallDecision extends CallAnswer {
60 id: string;
61 tool: string;
62 action: CallAction;
63 reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
64}
65
66export interface HistoryToolCall {
67 id: string;
68 tool: string;
69 input: string;
70 result: string;
71}
72
73export interface HistoryEntry {
74 i: number;
75 role: Role;
76 text: string;
77 /** Structured per call, or one compact line per call once the state has to shrink. */
78 tool_calls?: HistoryToolCall[] | string[];
79}
80
81/** The state sent with every Kev request: the whole history, results omitted. */
82export interface CompactionState {
83 context: string;
84 goal: string;
85 history: HistoryEntry[];
86}
87
88export interface FittedState {
89 state: CompactionState;
90 tokens: number;
91 /** Which fitting stage produced the state, for diagnostics. */
92 stage: string;
93 /** Ids of calls too far back to fit in the state. They are not scored and stay as they are. */
94 unscored?: string[];
95}
96
97export interface CompactOptions {
98 /** Ongoing task description; defaults to the last few user prompts. */
99 goal?: string;
100 /** Minimum keep probability for a call or result to stay. Default 0.5. */
101 keepThreshold?: number;
102 /** Newest messages never touched (the first message is always kept). Default 6. */
103 preserveRecentMessages?: number;
104 /** Estimated token ceiling for the state. Default 25000. */
105 maxStateTokens?: number;
106 /** Estimated token ceiling for state plus one batch of questions. Default 30000. */
107 maxRequestTokens?: number;
108 /** Characters of a dropped tool result to retain. Default 300. */
109 truncateHeadChars?: number;
110 /** Requests in flight at once. Default 1: a big history needs many requests, and a GPU answers them one after another, so sending them all at once makes each wait past a host's per-request time limit. */
111 requestConcurrency?: number;
112 /** Extra tries for a request that fails with a timeout or server error. Default 2. */
113 requestRetries?: number;
114}
115
116export interface ResolvedCompactOptions {
117 goal: string;
118 keepThreshold: number;
119 preserveRecentMessages: number;
120 maxStateTokens: number;
121 maxRequestTokens: number;
122 truncateHeadChars: number;
123 requestConcurrency: number;
124 requestRetries: number;
125}
126
127export interface CompactResult {
128 /** The compacted transcript; untouched messages are the input objects. */
129 messages: Message[];
130 decisions: CallDecision[];
131 stats: {
132 messagesBefore: number;
133 messagesAfter: number;
134 charsBefore: number;
135 charsAfter: number;
136 calls: number;
137 kept: number;
138 resultsDropped: number;
139 callsDropped: number;
140 pinned: number;
141 stateTokens: number;
142 /** Which fitting stage the state needed, '' when no request was made. */
143 stateStage: string;
144 requests: number;
145 /** Old calls that did not fit in the state, left unscored and untouched. */
146 unscored: number;
147 ms: number;
148 };
149}
150
151/** The `state` of a Kev request: a string or any JSON-serialisable object. */
152export type KevState = string | object;
153
154export interface NoulQuestion {
155 type: 'noul';
156 instructions: string;
157 criteria?: {
158 true?: string;
159 false?: string;
160 };
161}
162
163export interface ChoiceQuestion {
164 type: 'choice';
165 instructions: string;
166 criteria: Record<string, string | null>;
167}
168
169export interface ScoreQuestion {
170 type: 'score';
171 instructions: string;
172 criteria: string[];
173}
174
175export type KevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
176export type KevQuestions = Record<string, KevQuestion>;
177
178export interface NoulAnswer {
179 type?: 'noul';
180 noul: number;
181}
182
183export interface ChoiceAnswer {
184 type?: 'choice';
185 choice: string;
186 confidence: number;
187 probabilities: Record<string, number>;
188}
189
190export interface ScoreAnswer {
191 type?: 'score';
192 score: number;
193 confidence: number;
194 probabilities: Record<string, number>;
195}
196
197export type KevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
198
199export interface KevResponse {
200 model?: string;
201 answers: Record<string, KevAnswer>;
202 usage?: {
203 input_tokens?: number;
204 output_tokens?: number;
205 };
206 [key: string]: unknown;
207}
208
209/** Anything that can answer Kev questions: `KevClient`, or a host-provided adapter. */
210export interface KevAsker {
211 ask(state: KevState, questions: KevQuestions): Promise<KevResponse>;
212}
213src/state.ts 344 lines1import type {
2 CompactionState,
3 FittedState,
4 HistoryEntry,
5 Message,
6 ResolvedCompactOptions,
7 ToolCall,
8 ToolResult,
9} from './types.js';
10
11export const STATE_CONTEXT =
12 '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.';
13
14/** Successive caps on the serialised tool input included per call. */
15const INPUT_CHARS = [1000, 200, 60] as const;
16const TEXT_HEAD = 400;
17const TEXT_TAIL = 150;
18
19const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
20
21/**
22 * Estimates tokens without a tokenizer: a word costs one token per six
23 * letters, a digit half a token, any other symbol nine tenths. Calibrated
24 * against real transcripts, where it lands 2–18% above the true count; a plain characters-per-token ratio undercounts the
25 * JSON-heavy states by up to 40%.
26 */
27export function estimateTokens(text: string): number {
28 let tokens = 0;
29 for (const [piece] of text.matchAll(TOKEN_PIECES)) {
30 const first = piece.charCodeAt(0);
31 if (first >= 48 && first <= 57) tokens += piece.length / 2;
32 else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
33 tokens += 1 + Math.floor((piece.length - 1) / 6);
34 } else tokens += 0.9;
35 }
36 return Math.ceil(tokens);
37}
38
39export function truncate(text: string, limit: number): string {
40 return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
41}
42
43function abridge(text: string, head: number, tail: number): string {
44 if (text.length <= head + tail + 40) return text;
45 const omitted = text.length - head - tail;
46 return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
47}
48
49export function isPinned(
50 index: number,
51 total: number,
52 preserveRecentMessages: number,
53): boolean {
54 return index === 0 || index >= total - preserveRecentMessages;
55}
56
57/**
58 * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
59 * result are not candidates (there is nothing to drop yet).
60 */
61export function collectToolCalls(
62 messages: readonly Message[],
63 preserveRecentMessages: number,
64): ToolCall[] {
65 const results = new Map<string, { index: number; result: ToolResult }>();
66 messages.forEach((message, index) => {
67 for (const result of message.toolResults ?? []) {
68 results.set(result.tool_use_id, { index, result });
69 }
70 });
71 const calls: ToolCall[] = [];
72 messages.forEach((message, callIndex) => {
73 for (const tool of message.toolUses) {
74 const found = results.get(tool.tool_use_id);
75 if (!found) continue;
76 calls.push({
77 id: `t${calls.length + 1}`,
78 tool_use_id: tool.tool_use_id,
79 tool: tool.tool,
80 input: tool.input,
81 callIndex,
82 resultIndex: found.index,
83 resultChars: found.result.text.length,
84 isError: found.result.isError ?? false,
85 pinned:
86 isPinned(callIndex, messages.length, preserveRecentMessages) ||
87 isPinned(found.index, messages.length, preserveRecentMessages),
88 });
89 }
90 });
91 return calls;
92}
93
94function inputText(input: Record<string, unknown>, limit: number): string {
95 let json = '';
96 try {
97 json = JSON.stringify(input);
98 } catch {
99 json = '[unserializable input]';
100 }
101 return truncate(json, limit);
102}
103
104function resultNote(call: ToolCall): string {
105 return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
106}
107
108/** One call as a single line, for when the structured form is too costly. */
109function compactCall(call: ToolCall): string {
110 const input = Object.entries(call.input)
111 .map(([key, value]) => {
112 const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
113 return `${key}=${text.replace(/\s+/g, ' ')}`;
114 })
115 .join(' ');
116 return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
117 call.isError ? 'error' : 'ok'
118 } ${call.resultChars}ch`;
119}
120
121/**
122 * Folds runs of adjacent call-only entries into one entry each, so the
123 * per-entry envelope is paid once per run; the call lines keep their ids.
124 */
125function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
126 const merged: HistoryEntry[] = [];
127 for (const entry of history) {
128 const previous = merged[merged.length - 1];
129 const foldable = (e: HistoryEntry): boolean =>
130 !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
131 if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
132 previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
133 continue;
134 }
135 merged.push({ ...entry });
136 }
137 return merged;
138}
139
140function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
141 const byMessage = new Map<number, ToolCall[]>();
142 for (const call of calls) {
143 const list = byMessage.get(call.callIndex) ?? [];
144 list.push(call);
145 byMessage.set(call.callIndex, list);
146 }
147 return byMessage;
148}
149
150function historyEntries(
151 messages: readonly Message[],
152 calls: readonly ToolCall[],
153 inputChars: number,
154): HistoryEntry[] {
155 const byMessage = callsByMessage(calls);
156 const entries: HistoryEntry[] = [];
157 messages.forEach((message, i) => {
158 const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
159 id: call.id,
160 tool: call.tool,
161 input: inputText(call.input, inputChars),
162 result: resultNote(call),
163 }));
164 if (message.text.trim().length === 0 && toolCalls.length === 0) return;
165 const entry: HistoryEntry = { i, role: message.role, text: message.text };
166 if (toolCalls.length > 0) entry.tool_calls = toolCalls;
167 entries.push(entry);
168 });
169 return entries;
170}
171
172/** The last three user prompts, as the default `goal`. */
173/** Slash commands and the wrappers Claude Code adds around them are not the task. */
174function isRealPrompt(text: string): boolean {
175 const t = text.trim();
176 return t.length > 0 && !t.startsWith('/') && !/^<(local-command|command-|system-reminder|task-notification)/.test(t);
177}
178
179export function goalFromMessages(messages: readonly Message[]): string {
180 return messages
181 .filter(
182 (message) =>
183 message.role === 'user' &&
184 isRealPrompt(message.text) &&
185 (message.toolResults ?? []).length === 0,
186 )
187 .slice(-3)
188 .map((message) => truncate(message.text, 500))
189 .join('\n');
190}
191
192/**
193 * Builds the Kev state from the whole conversation and shrinks it in stages
194 * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
195 * are abridged oldest-first (pinned messages last), then old messages collapse
196 * to a one-line note, then old tool calls shrink to one line each, then old
197 * messages that carry no call are left out, then runs of old call-only
198 * messages are folded into one entry. Throws when even that is too big.
199 */
200export function fitState(
201 messages: readonly Message[],
202 calls: readonly ToolCall[],
203 options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
204): FittedState {
205 const goal = options.goal || goalFromMessages(messages);
206 const stateOf = (history: HistoryEntry[]): CompactionState => ({
207 context: STATE_CONTEXT,
208 goal,
209 history,
210 });
211 const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
212 const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
213 const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
214 state: stateOf(history),
215 tokens,
216 stage,
217 });
218
219 let history: HistoryEntry[] = [];
220 let perEntry: number[] = [];
221 let tokens = 0;
222 const rebuild = (inputChars: number): void => {
223 history = historyEntries(messages, calls, inputChars);
224 perEntry = history.map(entryTokens);
225 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
226 };
227 const fits = (): boolean => tokens <= options.maxStateTokens;
228 const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
229 const entry = history[index];
230 if (!entry) return;
231 change(entry);
232 const now = entryTokens(entry);
233 tokens += now - (perEntry[index] ?? 0);
234 perEntry[index] = now;
235 };
236
237 rebuild(INPUT_CHARS[0]);
238 if (fits()) return fitted(history, tokens, 'full');
239
240 for (const limit of INPUT_CHARS.slice(1)) {
241 rebuild(limit);
242 if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
243 }
244
245 const pinned = (entry: HistoryEntry): boolean =>
246 isPinned(entry.i, messages.length, options.preserveRecentMessages);
247 const indices = history.map((_, index) => index);
248 const order = [
249 ...indices.filter((index) => !pinned(history[index]!)),
250 ...indices.filter((index) => pinned(history[index]!)),
251 ];
252
253 for (const index of order) {
254 const entry = history[index]!;
255 if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
256 shrink(index, (e) => {
257 e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
258 });
259 if (fits()) return fitted(history, tokens, 'texts abridged');
260 }
261
262 for (const index of order) {
263 const entry = history[index]!;
264 if (pinned(entry) || entry.text.length === 0) continue;
265 const original = messages[entry.i]?.text.length ?? entry.text.length;
266 shrink(index, (e) => {
267 e.text = `[… ${original} chars omitted …]`;
268 });
269 if (fits()) return fitted(history, tokens, 'old messages collapsed');
270 }
271
272 const byMessage = callsByMessage(calls);
273 for (const index of order) {
274 const entry = history[index]!;
275 const own = byMessage.get(entry.i);
276 if (pinned(entry) || !own) continue;
277 shrink(index, (e) => {
278 e.tool_calls = own.map(compactCall);
279 });
280 if (fits()) return fitted(history, tokens, 'old calls compacted');
281 }
282
283 const left = new Set<number>();
284 for (const index of order) {
285 const entry = history[index]!;
286 if (pinned(entry) || entry.tool_calls) continue;
287 left.add(index);
288 tokens -= perEntry[index] ?? 0;
289 if (fits()) {
290 return fitted(
291 history.filter((_, i) => !left.has(i)),
292 tokens,
293 'old messages left out',
294 );
295 }
296 }
297
298 history = mergeCallRuns(
299 history.filter((_, i) => !left.has(i)),
300 pinned,
301 );
302 perEntry = history.map(entryTokens);
303 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
304 if (fits()) return fitted(history, tokens, 'old calls merged');
305
306 // Last resort: take the oldest call lines (and then whole entries) out of the state until it fits. Calls that are no
307 // longer in the state are not scored and stay as they are, so a very long history is compacted as far as it fits
308 // instead of not at all.
309 const cut = new Set<number>();
310 for (let index = 0; index < history.length && tokens > options.maxStateTokens; index++) {
311 const entry = history[index]!;
312 if (pinned(entry)) continue;
313 const before = entryTokens(entry);
314 if (Array.isArray(entry.tool_calls) && typeof entry.tool_calls[0] === 'string') {
315 const lines = [...(entry.tool_calls as string[])];
316 while (lines.length > 0 && tokens - before + entryTokens({ ...entry, tool_calls: lines }) > options.maxStateTokens) {
317 lines.shift();
318 }
319 if (lines.length > 0 || entry.text.length > 0) {
320 entry.tool_calls = lines;
321 tokens += entryTokens(entry) - before;
322 continue;
323 }
324 }
325 cut.add(index);
326 tokens -= before;
327 }
328 if (fits()) {
329 const rest = history.filter((_, index) => !cut.has(index));
330 const seen = new Set<string>();
331 for (const entry of rest) {
332 for (const call of entry.tool_calls ?? []) {
333 seen.add(typeof call === 'string' ? (call.split(' ')[0] ?? '') : call.id);
334 }
335 }
336 const unscored = calls.filter((call) => !call.pinned && !seen.has(call.id)).map((call) => call.id);
337 return { ...fitted(rest, tokens, 'oldest entries left out'), unscored };
338 }
339
340 throw new Error(
341 `history too large for Kev (~${tokens} tokens even with only the newest messages, limit ${options.maxStateTokens})`,
342 );
343}
344