import type { On, PluginOptions, Register, SessionMessage, ToolResultSummary, ToolUseSummary, TurnCompleteInput, } from 'claude-code'; import { buildGatewayJevRequest, parseGatewayJevResponse, } from '../caliber/gateway.js'; import { MISSING_JEV_KEY_MESSAGE, resolveJevCredentials, type JevTransportKind, type ResolvedJevCredentials, } from '../caliber/transport.js'; import { compact, reductionRatio, resolveOptions } from '../lib/compact.js'; import { buildJevRequest, DEFAULT_MODEL, parseJevResponse } from '../lib/request.js'; import type { CompactOptions, CompactResult, JevAsker, JevResponse, Message, ToolResult, ToolUse, } from '../lib/types.js'; const HOOK_DEFAULTS = { compactAtPercent: 60, minReductionRatio: 0.25, model: DEFAULT_MODEL, }; export type HookFetchInit = { method?: string; headers?: Record; body?: string; }; export type HookFetchResponse = { status: number; ok: boolean; text: string; }; /** The shape of `$.http.fetch`, so the hook can be driven without an engine. */ export type HookFetch = (url: string, init?: HookFetchInit) => Promise; export type HookConfig = CompactOptions & { apiKey?: string; gatewayApiKey?: string; gatewayBaseUrl?: string; compactAtPercent: number; minReductionRatio: number; model: string; }; function optionNumber(options: PluginOptions, key: string, fallback: number): number { const value = options[key]; return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function optionString(options: PluginOptions, key: string): string | undefined { const value = options[key]; return typeof value === 'string' && value.length > 0 ? value : undefined; } /** Reads the plugin's `userConfig` values; anything missing takes the defaults. */ export function resolveHookConfig(options: PluginOptions): HookConfig { const numbers: Partial> = {}; for (const key of [ 'keepThreshold', 'preserveRecentMessages', 'maxStateTokens', 'maxRequestTokens', 'truncateHeadChars', ] as const) { const value = options[key]; if (typeof value === 'number' && Number.isFinite(value)) numbers[key] = value; } const config: HookConfig = { ...numbers, compactAtPercent: optionNumber(options, 'compactAtPercent', HOOK_DEFAULTS.compactAtPercent), minReductionRatio: optionNumber( options, 'minReductionRatio', HOOK_DEFAULTS.minReductionRatio, ), model: optionString(options, 'model') ?? HOOK_DEFAULTS.model, }; const apiKey = optionString(options, 'apiKey'); if (apiKey) config.apiKey = apiKey; const gatewayApiKey = optionString(options, 'gatewayApiKey'); if (gatewayApiKey) config.gatewayApiKey = gatewayApiKey; const gatewayBaseUrl = optionString(options, 'gatewayBaseUrl'); if (gatewayBaseUrl) config.gatewayBaseUrl = gatewayBaseUrl; const goal = optionString(options, 'goal'); if (goal) config.goal = goal; return config; } export type JevAskerTransport = { kind?: JevTransportKind; baseUrl?: string; }; /** A `JevAsker` over the engine's `$.http.fetch`. Defaults to direct TypeSafe. */ export function jevAsker( fetchFn: HookFetch, apiKey: string, model: string, transport: JevAskerTransport = {}, ): JevAsker { return { async ask(state, questions) { const request = transport.kind === 'gateway' ? buildGatewayJevRequest( { apiKey, model, baseUrl: transport.baseUrl }, state, questions, ) : buildJevRequest({ apiKey, model, baseUrl: transport.baseUrl }, state, questions); const response = await fetchFn(request.url, { method: request.method, headers: request.headers, body: request.body, }); return transport.kind === 'gateway' ? (parseGatewayJevResponse(response.status, response.ok, response.text) as JevResponse) : parseJevResponse(response.status, response.ok, response.text); }, }; } function toolUseSummary(tool: ToolUse): ToolUseSummary { const summary: ToolUseSummary = { tool_use_id: tool.tool_use_id, tool: tool.tool, input: tool.input, }; if (tool.text !== undefined) summary.text = tool.text; if (tool.isError) summary.isError = true; return summary; } function toolResultSummary(result: ToolResult): ToolResultSummary { return { tool_use_id: result.tool_use_id, text: result.text, isError: result.isError ?? false, }; } /** * Maps the library's output back onto session messages. Whatever came back * unchanged (a message, a tool use, a tool result) is the engine's own object, * handle included; anything rebuilt is a fresh message without a handle, so the * engine takes the edited content instead of its original. */ export function toSessionMessages( input: readonly SessionMessage[], output: readonly Message[], ): SessionMessage[] { const messages = new Map(); const uses = new Map(); const results = new Map(); for (const message of input) { messages.set(message, message); for (const tool of message.toolUses) uses.set(tool, tool); for (const result of message.toolResults ?? []) results.set(result, result); } return output.map((message) => { const own = messages.get(message); if (own) return own; const rebuilt: SessionMessage = { role: message.role, text: message.text, toolUses: message.toolUses.map((tool) => uses.get(tool) ?? toolUseSummary(tool)), }; if (message.toolResults && message.toolResults.length > 0) { rebuilt.toolResults = message.toolResults.map( (result) => results.get(result) ?? toolResultSummary(result), ); } return rebuilt; }); } export type SessionCompaction = { result: CompactResult; messages: SessionMessage[]; }; /** Runs the library over a session transcript; throws when the key is missing or Jev fails. */ export async function compactSession( messages: readonly SessionMessage[], config: HookConfig, fetchFn: HookFetch, ): Promise { const creds = config.gatewayApiKey || config.apiKey ? resolveJevCredentials({ apiKey: config.apiKey, gatewayApiKey: config.gatewayApiKey, gatewayBaseUrl: config.gatewayBaseUrl, model: config.model, }) : undefined; if (!creds) throw new Error(MISSING_JEV_KEY_MESSAGE); const result = await compact( messages, jevAsker(fetchFn, creds.apiKey, creds.model ?? config.model, { kind: creds.kind, baseUrl: creds.baseUrl, }), config, ); return { result, messages: toSessionMessages(messages, result.messages) }; } function percent(ratio: number): string { return `${Math.round(ratio * 100)}%`; } export function summarize(result: CompactResult): string { const { stats } = result; const parts = [ stats.kept > 0 ? `${stats.kept} kept` : '', stats.resultsDropped > 0 ? `${stats.resultsDropped} results truncated` : '', stats.callsDropped > 0 ? `${stats.callsDropped} call_dropped` : '', stats.pinned > 0 ? `${stats.pinned} pinned` : '', ].filter(Boolean); return `${percent(reductionRatio(result))} reduction; ${ parts.join(', ') || 'no tool calls' }; state ~${stats.stateTokens} tokens (${stats.stateStage}) in ${stats.requests} request(s)`; } const UI_LOG_MAX_CHARS = 4096; export function decisionLog(result: CompactResult): string { return result.decisions .filter((d) => d.reason !== 'pinned') .map( (d) => `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`, ) .join(' '); } export function decisionLogLines( result: CompactResult, maxChars: number = UI_LOG_MAX_CHARS, ): string[] { const entries = decisionLog(result).split(' ').filter(Boolean); if (entries.length === 0) return ['decisions: (none)']; const chunks: string[] = []; let current = ''; for (const entry of entries) { const next = current ? `${current} ${entry}` : entry; if (current && next.length > maxChars - 24) { chunks.push(current); current = entry; } else current = next; } chunks.push(current); return chunks.map((chunk, index) => chunks.length === 1 ? `decisions: ${chunk}` : `decisions (${index + 1}/${chunks.length}): ${chunk}`, ); } function settingsEnvString( settings: Readonly>, name: string, ): string | undefined { const env = settings['env']; if (!env || typeof env !== 'object') return undefined; const value = (env as Record)[name]; return typeof value === 'string' && value.length > 0 ? value : undefined; } async function resolveHookCredentials( $: { env: { get: (name: string) => Promise }; settings: { read: () => Promise>> }; }, config: HookConfig, ): Promise { const settings = await $.settings.read(); return resolveJevCredentials( { apiKey: config.apiKey, gatewayApiKey: config.gatewayApiKey, gatewayBaseUrl: config.gatewayBaseUrl, model: config.model, }, { AI_GATEWAY_API_KEY: (await $.env.get('AI_GATEWAY_API_KEY')) ?? settingsEnvString(settings, 'AI_GATEWAY_API_KEY'), TYPESAFE_API_KEY: (await $.env.get('TYPESAFE_API_KEY')) ?? settingsEnvString(settings, 'TYPESAFE_API_KEY'), }, ); } function notify( $: { ui: { log: (text: string) => void; toast: (text: string, options?: { timeoutMs?: number }) => void; }; }, text: string, ): void { $.ui.log(text); $.ui.toast(text, { timeoutMs: 15_000 }); } export const register: Register = (on: On, options: PluginOptions) => { const configured = resolveHookConfig(options); let compacting = false; on('session.compact', async ($, event, next) => { try { const creds = await resolveHookCredentials($, configured); const config: HookConfig = { ...configured, ...(creds?.kind === 'gateway' ? { gatewayApiKey: creds.apiKey, gatewayBaseUrl: creds.baseUrl, apiKey: undefined } : { apiKey: creds?.apiKey, gatewayApiKey: undefined }), ...(creds?.model ? { model: creds.model } : {}), }; const { result, messages } = await compactSession(event.messages, config, async (url, init) => { const response = await $.http.fetch(url, init); return { status: response.status, ok: response.ok, text: response.text }; }); for (const line of decisionLogLines(result)) $.ui.log(line); if (reductionRatio(result) < config.minReductionRatio) { notify( $, `fallback to built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`, ); return next(event); } notify( $, `kept ${messages.length}/${event.messages.length} messages, no summary (${summarize(result)})`, ); return { messages }; } catch (error) { notify( $, `fallback to built-in summary (${error instanceof Error ? error.message : String(error)})`, ); return next(event); } }); on('turn.complete', async ($, event: TurnCompleteInput, next) => { if (compacting) return next(event); try { const { context } = await $.session.usage(); if ((context.percent ?? 0) < configured.compactAtPercent) return next(event); compacting = true; await $.session.compact(); } catch (error) { $.ui.log( `auto-compact skipped (${error instanceof Error ? error.message : String(error)})`, ); } finally { compacting = false; } return next(event); }); }; export { resolveOptions };