SLOPSHOPPER

fast-kev-compaction

Verbatim Kev-guided compaction for Claude Code sessions.

commandtoastnetwork
v0.6.4MITupdated 2026-10-161njx/fast-kev-compaction
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fast-kev-compaction
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ fast-kev-compaction │ ● fast-kev-compaction: Kev compaction failed after under 1 s: no Kev en│ Kev compaction failed after under 1 s: no │ ⏺ Read(src/auth.ts) │ Kev endpoint: set the baseUrl plugin │ ⎿ Read 6 lines │ option. Your conversation was not changed. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /kevcompact ⎿ fast-kev-compaction: Kev compaction started. Progress messages follow: 1) waking the server (seconds if warm, 1 to 5 minutes if col ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

Quick start

You need Claude Code 2.1.274 or newer (desktop app or CLI) and a Modal account.

  1. Deploy Kev to Modal: deploy/README.md.
  2. Install the plugin: ``sh claude plugin marketplace add 1njx/fast-kev-compaction claude plugin install fast-kev-compaction@fast-kev-compaction ``
  3. Set the options baseUrl, apiKey and model, and turn on function hooks in ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } } ``
  4. Restart Claude fully, then type /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.

Behavior

  • /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.
  • Automatic compaction is off by default. Set 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.
  • A hook can run only about 10 seconds, so very long chats may fall back to the normal summary.
  • The server sleeps 60 seconds after the last request. A cold start costs roughly $0.25 to $0.40 of GPU time, so set a $0 spend limit in Modal.

Options and client differences: hooks/README.md.

As a library

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.

Development

npm install
npm run typecheck
npm test
npm run validate:plugin

The unit tests use a fake model and make no network calls.

Install with your AI agent

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.

Credits

  • fast-jev-compaction by Tamara Tran (@tamaratran), MIT. The compaction library and the Claude Code hooks are theirs, and this fork keeps their history and license.
  • Kev by Jared Palmer. deploy/kev_serve.py is copied from it with a shorter idle timeout.
  • Kev support, /kevcompact, the progress messages and CLI support by @1njx.

Released under the MIT license.

Source 5 files
hooks/fast-kev.ts 551 lines
1import 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 };
551
src/compact.ts 361 lines
1import { 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}
361
src/request.ts 81 lines
1import 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}
81
src/types.ts 213 lines
1export 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}
213
src/state.ts 344 lines
1import 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