Verbatim Jev-guided compaction for Claude Code sessions.

Verbatim context compaction for Claude Code. Instead of replacing your history with a lossy summary, fast-jev-plus removes only the tool calls and results that are no longer needed. Every message that stays is kept word for word.
Live dashboard · Quick start · How it works · Benchmarks · Privacy

When a Claude Code session fills up, /compact asks a model to summarize the old turns. Summaries are lossy: an exact file path, error message, command, or constraint can disappear right when it matters.
fast-jev-plus never rewrites anything. It scores every tool call and tool result, deletes the stale ones (an outdated file read, a superseded test run), and hands Claude Code the original messages. If it can't save enough, it steps aside and lets the built-in summary run.
fast-jev-plus: kept 20/140 messages, no summary (98% reduction; 60 call_dropped, 3 pinned; …)
| Verbatim pruning | Removes whole tool calls or truncates their results. Kept messages are the original objects, unchanged. |
| Two modes | jev scores every call with the Jev model in one fast request. local uses offline, deterministic rules with no API key and no network. |
| Outbound redaction | Bearer tokens, API keys, passwords, private keys, emails, and phone numbers are stripped from anything sent to Jev. Your transcript is untouched. |
| Safe fallback | Errors, timeouts, a missing key, or savings below the minimum (15% by default) hand off to Claude Code's built-in summary. |
| Metrics | Tokens before and after, reduction, latency, request count, and provider usage when reported. Nothing is invented. |
| Retention benchmark | npm run bench checks that labelled critical calls survive and that tasks still pass, in both modes. |
| Run history | Every run is appended to .jev/runs.jsonl with decisions, never prompts or tool output. |
| Dashboards | A private dashboard on 127.0.0.1, and a public one with anonymous community totals. |
Requires Claude Code 2.1.274+ (function hooks are an early-access feature).
1. Enable function hooks. Add this to ~/.claude/settings.json. The API key is only needed for jev mode.
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"TYPESAFE_API_KEY": "<your TypeSafe key>"
}
}
Put these in
settings.json, not only in a shell. WithoutCLAUDE_CODE_ENABLE_FUNCTION_HOOKS, Claude Code does not load the hook and/compactsilently uses the built-in summary.
2. Install the plugin from a terminal, or run the same commands as slash commands inside Claude Code:
claude plugin marketplace add gaaurav03/Jev-Project
claude plugin install fast-jev-plus@fast-jev-plus
3. Choose a mode. Open /config and set mode:
jev (default): the most savings; needs TYPESAFE_API_KEY.local: offline and the most conservative; no key needed.4. Restart Claude Code (or run /reload-plugins), then run /compact or let it trigger automatically at 60% context. The toast reports what happened:
kept N/M messages, no summary (…): the pruned, verbatim history was applied.fallback to built-in summary (…): not enough to remove (common in short sessions) or an error; Claude Code's own summary ran instead.Update or remove
claude plugin marketplace update fast-jev-plus
claude plugin update fast-jev-plus@fast-jev-plus
claude plugin uninstall fast-jev-plus@fast-jev-plus
Plugin options are set with /config (stored in Claude Code's settings).
| Option | Default | What it does |
|---|---|---|
mode | jev | jev for model scoring, local for offline deterministic rules |
apiKey | – | TypeSafe key; leave unset to use TYPESAFE_API_KEY |
telemetry | true | Share anonymous usage counts (details) |
compactAtPercent | 60 | Context percentage at which compaction is requested automatically |
minReductionRatio | 0.15 | Minimum estimated reduction required to replace the history |
keepThreshold | 0.5 | Minimum Jev probability for a call or result to stay |
preserveRecentMessages | 6 | Newest messages that are never touched (the first message is always kept) |
truncateHeadChars | 300 | Characters kept from a truncated tool result, followed by a note |
maxStateTokens | 25000 | Estimated token budget for the conversation state sent to Jev |
maxRequestTokens | 30000 | Estimated budget for state plus questions in one Jev request |
model | jev-latest | Jev model name |
Environment variables:
| Variable | Purpose |
|---|---|
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 | Required for the plugin to load |
TYPESAFE_API_KEY | Jev mode API key |
JEV_TELEMETRY=0 / DO_NOT_TRACK=1 | Disable anonymous telemetry |
JEV_CLOUD_UPLOAD, JEV_CLOUD_INGEST_URL, JEV_CLOUD_INGEST_TOKEN | Optional authenticated upload to a dashboard you host (self-hosting) |
/compact, or the turn.complete hook when context reaches compactAtPercent.tool_use is paired with its tool_result. Calls in the first message and the newest preserveRecentMessages messages are pinned.jev: the whole conversation is sent as a compact state (tool results replaced by short notes, fitted into maxStateTokens), with two questions per call: should the call stay, and should its result stay verbatim? Questions are batched under maxRequestTokens and sent concurrently.local: old, successful duplicate Read calls are removed; recent calls, errors, edits, and anything uncertain are kept.keepThreshold: keep call and result; keep the call but truncate the result to truncateHeadChars; or remove the call with its result. No result is ever left without its call.minReductionRatio, the rebuilt message list replaces the history. Untouched messages are the engine's own objects. Otherwise Claude Code's built-in compaction runs..jev/runs.jsonl, then optional uploads run in parallel with a 3-second timeout.The state is fitted into maxStateTokens in stages, each applied only if the previous one was not enough: tool inputs truncated to 1000, then 200, then 60 characters; long texts abridged to head and tail, oldest non-pinned messages first; old non-pinned messages collapsed to a [… N chars omitted …] note; old tool calls reduced to one line each (t12 Read file_path=src/a.ts → ok 480ch); old call-less messages left out; runs of old call-only messages folded into one entry. If it still does not fit, compaction throws and the hook falls back. Tokens are estimated without a tokenizer (a word per six letters, half a token per digit, about one per other symbol), calibrated to land slightly above the counts Jev reports.
Four labelled cases, each marking the calls and results that must survive and running a deterministic check that the task still passes. Recorded with npm run bench -- --mode both on 2026-09-30:
| Case | Jev reduction | Jev critical kept | Local reduction | Local critical kept | Task (jev · local) |
|---|---|---|---|---|---|
| obsolete-read | 40.3% | 100% | 40.3% | 100% | pass · pass |
| required-error | 69.6% | 0% | 0.0% | 100% | pass · pass |
| repeated-test | 42.4% | 100% | 0.0% | 100% | pass · pass |
| protected-constraint | 64.7% | 0% | 0.0% | 100% | pass · pass |
| Total | 52.1% | 50% | 12.7% | 100% | 4/4 · 4/4 |
Jev saves about four times more context but, in two of four cases, removed information the benchmark marks as critical. Local mode saves less and kept everything critical. Choose local when exact errors and constraints must survive; the assistant can always re-run a removed tool.
npm run bench # both modes, table output
npm run bench -- --mode local # no key, no network
npm run bench -- --json # full machine-readable report
Benchmark JSON cannot supply executable commands; it can only select one from the reviewed catalog in src/benchmark.ts. Cost is reported only when verified pricing is available.
Private (local). From a clone of this repository:
npm install
npm run dashboard # http://127.0.0.1:4310
It binds only to 127.0.0.1 and reads .jev/runs.jsonl: overview, Jev versus local comparison, trends, filterable run history, and per-run decision details. GET /api/summary, GET /api/runs (filters mode, status, from, to, limit ≤ 200) and GET /api/runs/:id never return prompts, tool inputs, keys, or raw output.
Public. jev-project-bay.vercel.app shows community totals from anonymous telemetry plus the maintainer's uploaded runs and benchmarks, refreshed every 30 seconds.
Telemetry is on by default and powers the public community totals.
{ "v": 1, "install_id": "<random 32 hex>", "plugin_version": "0.6.0",
"mode": "local", "status": "applied",
"tokens_before": 1000, "tokens_after": 600, "latency_ms": 13 }
Prompts, messages, code, file names, paths, tool names, tool output, run IDs, and API keys are never sent, and benchmark runs are never sent. The server stores no IP addresses; a daily-rotating keyed hash of the sender is used only for rate limiting. Sending times out after 3 seconds and never changes the compaction result.
Opt out with JEV_TELEMETRY=0, DO_NOT_TRACK=1, or the telemetry option set to false in /config. Once opted out, no install ID is created and nothing is sent. Self-hosters can redirect events with JEV_TELEMETRY_URL.
| Symptom | Fix |
|---|---|
/compact shows no fast-jev-plus toast | Function hooks are off. Put CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json and restart Claude Code. |
fallback to built-in summary (TYPESAFE_API_KEY is not configured) | Add the key to settings.json or /config, or switch mode to local. |
fallback to built-in summary (below 15% minimum …) | Expected in short sessions. Lower minReductionRatio in /config if you want it applied more often. |
| An update is not picked up | Run claude plugin marketplace update fast-jev-plus, then claude plugin update fast-jev-plus@fast-jev-plus, and restart. |
| Where is my run history? | .jev/runs.jsonl at the root of the repository (or working directory) the session was started in. |
npm install github:gaaurav03/Jev-Project
export TYPESAFE_API_KEY=...
import { compactMessages, reductionRatio, type Message } from 'fast-jev-plus';
const transcript: Message[] = [
{ role: 'user', text: 'Fix the failing test. Never edit src/generated.', toolUses: [] },
{
role: 'assistant',
text: '',
toolUses: [{ tool_use_id: 'toolu_1', tool: 'Read', input: { file_path: 'src/a.ts' } }],
},
{ role: 'user', text: '', toolUses: [], toolResults: [{ tool_use_id: 'toolu_1', text: '…file…' }] },
// …
];
const result = await compactMessages(transcript, { preserveRecentMessages: 4 });
// Offline alternative: compactMessages(transcript, { mode: 'local' })
console.log(result.messages, result.decisions, result.stats);
if (reductionRatio(result) < 0.15) {
// not worth it: keep the original transcript, or summarize instead
}
Message is a subset of Claude Code's SessionMessage, so a session transcript can be passed in as is. The library accepts the same options as the plugin plus baseUrl (default https://api.typesafe.ai/v1/systemone), fetch (injectable for tests), and goal (defaults to the last three user prompts). apiKey defaults to process.env.TYPESAFE_API_KEY; never commit it.
To bring your own transport, implement JevAsker (one ask(state, questions) method) and call compact(messages, asker, options). buildJevRequest and parseJevResponse build and validate the HTTP exchange, and the building blocks (collectToolCalls, fitState, batchCalls, decideCall, applyDecisions) are exported too. result.stats reports counts before and after, per-reason decisions, state size, fitting stage, and requests.
supabase/migrations/ in order. They create constrained tables with RLS enabled and no browser-role access. See supabase/README.md.vercel.json builds the TypeScript, serves dashboard/, and sets security headers. Add these environment variables: SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-sb_secret-key
JEV_CLOUD_INGEST_TOKEN=a-random-token-of-32-or-more-characters
env block of ~/.claude/settings.json: "JEV_CLOUD_UPLOAD": "1",
"JEV_CLOUD_INGEST_URL": "https://your-project.vercel.app/api/ingest",
"JEV_CLOUD_INGEST_TOKEN": "the same token as in Vercel"
Point community telemetry at your deployment with JEV_TELEMETRY_URL=https://your-project.vercel.app/api/telemetry.
Endpoints: GET /api/summary, GET /api/runs, and GET /api/community are public and return sanitized data only; POST /api/ingest requires the token; POST /api/telemetry accepts only the strict anonymous event above and is rate limited. The Supabase key never leaves Vercel.
npm install
npm run typecheck # library + hook
npm test # fake Jev; never contacts TypeSafe
npm run build
npm run validate:plugin # claude plugin validate
npm run demo # live network check; needs TYPESAFE_API_KEY
To run the plugin from a checkout without installing: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
| Path | Contents |
|---|---|
src/ | Library: compaction, Jev client, redaction, local mode, metrics, benchmark, dashboard server, telemetry |
hooks/ | Claude Code function-hook adapter (details) |
dashboard/ | Website and dashboard (static HTML, CSS, JS) |
api/ | Vercel functions for the public dashboard |
supabase/ | Database migrations |
bench/ | Labelled benchmark cases |
demo/JevDemo | Scripted SwiftUI animation of the flow for screen recording (macOS) |
MIT. See LICENSE.
hooks/fast-jev.ts 504 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 {
13 CLOUD_UPLOAD_TIMEOUT_MS,
14 cloudUploadConfig,
15 uploadCloudRun,
16} from '../src/cloud.js';
17import { LocalAsker } from '../src/local.js';
18import {
19 isInstallId,
20 newInstallId,
21 TELEMETRY_NOTICE,
22 TELEMETRY_TIMEOUT_MS,
23 telemetryEnabled,
24 telemetryUrl,
25 toTelemetryEvent,
26} from '../src/telemetry.js';
27import {
28 compactRunMetrics,
29 createRunRecord,
30 DEFAULT_RUN_HISTORY_PATH,
31 serializeRunRecord,
32 type RunFallbackReason,
33 type RunRecord,
34 type RunStatus,
35} from '../src/run-record.js';
36import {
37 buildJevRequest,
38 DEFAULT_MODEL,
39 JEV_REQUEST_TIMEOUT_MS,
40 parseJevResponse,
41} from '../src/request.js';
42import type {
43 CompactOptions,
44 CompactResult,
45 CompactionMode,
46 JevAsker,
47 Message,
48 ToolResult,
49 ToolUse,
50} from '../src/types.js';
51
52const HOOK_DEFAULTS = {
53 mode: 'jev' as CompactionMode,
54 compactAtPercent: 60,
55 minReductionRatio: 0.15,
56 model: DEFAULT_MODEL,
57};
58
59export type HookFetchInit = {
60 method?: string;
61 headers?: Record<string, string>;
62 body?: string;
63};
64
65export type HookFetchResponse = {
66 status: number;
67 ok: boolean;
68 text: string;
69};
70
71/** The shape of `$.http.fetch`, so the hook can be driven without an engine. */
72export type HookFetch = (url: string, init?: HookFetchInit) => Promise<HookFetchResponse>;
73
74type HookTimer = (ms: number, fn: () => void) => { cancel: () => void };
75
76export function withHookTimeout<T>(
77 request: Promise<T>,
78 after: HookTimer,
79 timeoutMs = JEV_REQUEST_TIMEOUT_MS,
80 message = 'Jev request timed out',
81): Promise<T> {
82 return new Promise((resolve, reject) => {
83 const timer = after(timeoutMs, () => reject(new Error(message)));
84 request.then(
85 (value) => { timer.cancel(); resolve(value); },
86 (error: unknown) => { timer.cancel(); reject(error); },
87 );
88 });
89}
90
91export type HookConfig = CompactOptions & {
92 mode: CompactionMode;
93 apiKey?: string;
94 compactAtPercent: number;
95 minReductionRatio: number;
96 model: string;
97 telemetry: boolean;
98};
99
100function optionNumber(options: PluginOptions, key: string, fallback: number): number {
101 const value = options[key];
102 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
103}
104
105function optionString(options: PluginOptions, key: string): string | undefined {
106 const value = options[key];
107 return typeof value === 'string' && value.length > 0 ? value : undefined;
108}
109
110/** Reads the plugin's `userConfig` values; anything missing takes the defaults. */
111export function resolveHookConfig(options: PluginOptions): HookConfig {
112 const numbers: Partial<Omit<CompactOptions, 'goal'>> = {};
113 for (const key of [
114 'keepThreshold',
115 'preserveRecentMessages',
116 'maxStateTokens',
117 'maxRequestTokens',
118 'truncateHeadChars',
119 ] as const) {
120 const value = options[key];
121 if (typeof value === 'number' && Number.isFinite(value)) numbers[key] = value;
122 }
123 const mode = optionString(options, 'mode');
124 const config: HookConfig = {
125 ...numbers,
126 mode: mode === 'local' ? 'local' : HOOK_DEFAULTS.mode,
127 compactAtPercent: optionNumber(options, 'compactAtPercent', HOOK_DEFAULTS.compactAtPercent),
128 minReductionRatio: optionNumber(
129 options,
130 'minReductionRatio',
131 HOOK_DEFAULTS.minReductionRatio,
132 ),
133 model: optionString(options, 'model') ?? HOOK_DEFAULTS.model,
134 telemetry: options['telemetry'] !== false,
135 };
136 const apiKey = optionString(options, 'apiKey');
137 if (apiKey) config.apiKey = apiKey;
138 const goal = optionString(options, 'goal');
139 if (goal) config.goal = goal;
140 return config;
141}
142
143/** A `JevAsker` over the engine's `$.http.fetch`. */
144export function jevAsker(fetchFn: HookFetch, apiKey: string, model: string): JevAsker {
145 return {
146 async ask(state, questions) {
147 const request = buildJevRequest({ apiKey, model }, state, questions);
148 const response = await fetchFn(request.url, {
149 method: request.method,
150 headers: request.headers,
151 body: request.body,
152 });
153 return parseJevResponse(response.status, response.ok, response.text);
154 },
155 };
156}
157
158function toolUseSummary(tool: ToolUse): ToolUseSummary {
159 const summary: ToolUseSummary = {
160 tool_use_id: tool.tool_use_id,
161 tool: tool.tool,
162 input: tool.input,
163 };
164 if (tool.text !== undefined) summary.text = tool.text;
165 if (tool.isError) summary.isError = true;
166 return summary;
167}
168
169function toolResultSummary(result: ToolResult): ToolResultSummary {
170 return {
171 tool_use_id: result.tool_use_id,
172 text: result.text,
173 isError: result.isError ?? false,
174 };
175}
176
177/**
178 * Maps the library's output back onto session messages. Whatever came back
179 * unchanged (a message, a tool use, a tool result) is the engine's own object,
180 * handle included; anything rebuilt is a fresh message without a handle, so the
181 * engine takes the edited content instead of its original.
182 */
183export function toSessionMessages(
184 input: readonly SessionMessage[],
185 output: readonly Message[],
186): SessionMessage[] {
187 const messages = new Map<Message, SessionMessage>();
188 const uses = new Map<ToolUse, ToolUseSummary>();
189 const results = new Map<ToolResult, ToolResultSummary>();
190 for (const message of input) {
191 messages.set(message, message);
192 for (const tool of message.toolUses) uses.set(tool, tool);
193 for (const result of message.toolResults ?? []) results.set(result, result);
194 }
195 return output.map((message) => {
196 const own = messages.get(message);
197 if (own) return own;
198 const rebuilt: SessionMessage = {
199 role: message.role,
200 text: message.text,
201 toolUses: message.toolUses.map((tool) => uses.get(tool) ?? toolUseSummary(tool)),
202 };
203 if (message.toolResults && message.toolResults.length > 0) {
204 rebuilt.toolResults = message.toolResults.map(
205 (result) => results.get(result) ?? toolResultSummary(result),
206 );
207 }
208 return rebuilt;
209 });
210}
211
212export type SessionCompaction = {
213 result: CompactResult;
214 messages: SessionMessage[];
215};
216
217/** Runs the selected compactor over a session transcript. */
218export async function compactSession(
219 messages: readonly SessionMessage[],
220 config: HookConfig,
221 fetchFn: HookFetch,
222): Promise<SessionCompaction> {
223 let asker: JevAsker;
224 if (config.mode === 'local') asker = new LocalAsker();
225 else {
226 if (!config.apiKey) throw new Error('TYPESAFE_API_KEY is not configured');
227 asker = jevAsker(fetchFn, config.apiKey, config.model);
228 }
229 const result = await compact(messages, asker, config);
230 return { result, messages: toSessionMessages(messages, result.messages) };
231}
232
233function percent(ratio: number): string {
234 return `${Math.round(ratio * 100)}%`;
235}
236
237export function summarize(result: CompactResult): string {
238 const { stats } = result;
239 const parts = [
240 stats.kept > 0 ? `${stats.kept} kept` : '',
241 stats.resultsDropped > 0 ? `${stats.resultsDropped} results truncated` : '',
242 stats.callsDropped > 0 ? `${stats.callsDropped} call_dropped` : '',
243 stats.pinned > 0 ? `${stats.pinned} pinned` : '',
244 ].filter(Boolean);
245 return `${percent(reductionRatio(result))} reduction; ${
246 parts.join(', ') || 'no tool calls'
247 }; state ~${stats.stateTokens} tokens (${stats.stateStage}) in ${stats.requests} request(s)`;
248}
249
250const UI_LOG_MAX_CHARS = 4096;
251
252export function decisionLog(result: CompactResult): string {
253 return result.decisions
254 .filter((d) => d.reason !== 'pinned')
255 .map(
256 (d) =>
257 `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`,
258 )
259 .join(' ');
260}
261
262export function decisionLogLines(
263 result: CompactResult,
264 maxChars: number = UI_LOG_MAX_CHARS,
265): string[] {
266 const entries = decisionLog(result).split(' ').filter(Boolean);
267 if (entries.length === 0) return ['decisions: (none)'];
268 const chunks: string[] = [];
269 let current = '';
270 for (const entry of entries) {
271 const next = current ? `${current} ${entry}` : entry;
272 if (current && next.length > maxChars - 24) {
273 chunks.push(current);
274 current = entry;
275 } else current = next;
276 }
277 chunks.push(current);
278 return chunks.map((chunk, index) =>
279 chunks.length === 1
280 ? `decisions: ${chunk}`
281 : `decisions (${index + 1}/${chunks.length}): ${chunk}`,
282 );
283}
284
285async function getApiKey(
286 $: {
287 env: { get: (name: string) => Promise<string | undefined> };
288 settings: { read: () => Promise<Readonly<Record<string, unknown>>> };
289 },
290 config: HookConfig,
291): Promise<string | undefined> {
292 if (config.apiKey) return config.apiKey;
293 const fromEnv = await $.env.get('TYPESAFE_API_KEY');
294 if (fromEnv) return fromEnv;
295 const settings = await $.settings.read();
296 const env = settings['env'];
297 if (env && typeof env === 'object') {
298 const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY'];
299 if (typeof value === 'string' && value) return value;
300 }
301 return undefined;
302}
303
304function notify(
305 $: {
306 ui: {
307 log: (text: string) => void;
308 toast: (text: string, options?: { timeoutMs?: number }) => void;
309 };
310 },
311 text: string,
312): void {
313 $.ui.log(text);
314 $.ui.toast(text, { timeoutMs: 15_000 });
315}
316
317function fallbackReason(error: unknown): RunFallbackReason {
318 const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
319 if (message.includes('typesafe_api_key')) return 'missing_api_key';
320 if (message.includes('timed out')) return 'request_timeout';
321 if (message.includes('jev request') || message.includes('jev response')) return 'remote_error';
322 return 'compaction_error';
323}
324
325type RecordHost = {
326 fs: {
327 exists: (path: string) => Promise<boolean>;
328 read: (path: string) => Promise<string>;
329 write: (path: string, text: string) => Promise<void>;
330 };
331 session: {
332 cwd: () => Promise<string>;
333 repo: () => Promise<{ root: string } | null>;
334 };
335 ui: { log: (text: string) => void };
336 env: { get: (name: string) => Promise<string | undefined> };
337 http: { fetch: HookFetch };
338 clock: { after: HookTimer };
339 store: {
340 get: (key: string) => Promise<unknown>;
341 set: (key: string, value: unknown) => Promise<void>;
342 };
343};
344
345/**
346 * Sends one anonymous community event unless the user opted out. The first
347 * call only creates the install ID and shows the notice; nothing is sent.
348 */
349export async function sendTelemetry(
350 $: RecordHost,
351 config: HookConfig,
352 record: RunRecord,
353): Promise<void> {
354 const enabled = telemetryEnabled({
355 option: config.telemetry,
356 jevTelemetry: await $.env.get('JEV_TELEMETRY'),
357 doNotTrack: await $.env.get('DO_NOT_TRACK'),
358 });
359 if (!enabled) return;
360 const installId = await $.store.get('installId');
361 if (!isInstallId(installId)) {
362 await $.store.set('installId', newInstallId());
363 $.ui.log(TELEMETRY_NOTICE);
364 return;
365 }
366 const event = toTelemetryEvent(record, installId);
367 if (!event) return;
368 const url = telemetryUrl(await $.env.get('JEV_TELEMETRY_URL'));
369 await withHookTimeout(
370 $.http.fetch(url, {
371 method: 'POST',
372 headers: { 'content-type': 'application/json' },
373 body: JSON.stringify(event),
374 }),
375 (ms, fn) => $.clock.after(ms, fn),
376 TELEMETRY_TIMEOUT_MS,
377 'Telemetry timed out',
378 );
379}
380
381async function uploadOwnRun($: RecordHost, record: RunRecord): Promise<void> {
382 try {
383 const enabled = await $.env.get('JEV_CLOUD_UPLOAD');
384 if (enabled !== '1') return;
385 const cloud = cloudUploadConfig(
386 enabled,
387 await $.env.get('JEV_CLOUD_INGEST_URL'),
388 await $.env.get('JEV_CLOUD_INGEST_TOKEN'),
389 );
390 if (!cloud) return;
391 await withHookTimeout(
392 uploadCloudRun(record, cloud, (url, init) => $.http.fetch(url, init)),
393 (ms, fn) => $.clock.after(ms, fn),
394 CLOUD_UPLOAD_TIMEOUT_MS,
395 'Cloud upload timed out',
396 );
397 } catch {
398 $.ui.log('cloud upload unavailable; local history was saved');
399 }
400}
401
402export async function recordLiveRun(
403 $: RecordHost,
404 config: HookConfig,
405 status: RunStatus,
406 reason: RunFallbackReason | null,
407 result?: CompactResult,
408): Promise<void> {
409 let record: RunRecord;
410 try {
411 record = createRunRecord({
412 source: 'live',
413 mode: config.mode,
414 status,
415 fallbackReason: reason,
416 metrics: result ? compactRunMetrics(result) : null,
417 decisions: result?.decisions ?? [],
418 benchmark: null,
419 });
420 const root = (await $.session.repo())?.root ?? await $.session.cwd();
421 const path = root.replace(/[\\/]+$/, '') + '/' + DEFAULT_RUN_HISTORY_PATH;
422 const previous = (await $.fs.exists(path)) ? await $.fs.read(path) : '';
423 // ponytail: the hook API has whole-file writes only; use append when the host exposes it.
424 await $.fs.write(path, previous + serializeRunRecord(record));
425 } catch {
426 $.ui.log('run history unavailable');
427 return;
428 }
429
430 // Both uploads run together after the local write; telemetry failures stay silent.
431 await Promise.all([
432 uploadOwnRun($, record),
433 sendTelemetry($, config, record).catch(() => undefined),
434 ]);
435}
436
437export const register: Register = (on: On, options: PluginOptions) => {
438 const configured = resolveHookConfig(options);
439 let compacting = false;
440
441 on('session.compact', async ($, event, next) => {
442 try {
443 const config =
444 configured.mode === 'local'
445 ? configured
446 : { ...configured, apiKey: await getApiKey($, configured) };
447 const { result, messages } = await compactSession(event.messages, config, async (url, init) => {
448 const response = await withHookTimeout(
449 $.http.fetch(url, init),
450 (ms, fn) => $.clock.after(ms, fn),
451 );
452 return { status: response.status, ok: response.ok, text: response.text };
453 });
454 for (const line of decisionLogLines(result)) $.ui.log(line);
455 if (reductionRatio(result) < config.minReductionRatio) {
456 await recordLiveRun(
457 $,
458 config,
459 'fallback',
460 'below_minimum_reduction',
461 result,
462 );
463 notify(
464 $,
465 `fallback to built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`,
466 );
467 return next(event);
468 }
469 notify(
470 $,
471 `kept ${messages.length}/${event.messages.length} messages, no summary (${summarize(result)})`,
472 );
473 await recordLiveRun($, config, 'applied', null, result);
474 return { messages };
475 } catch (error) {
476 await recordLiveRun($, configured, 'fallback', fallbackReason(error));
477 notify(
478 $,
479 `fallback to built-in summary (${error instanceof Error ? error.message : String(error)})`,
480 );
481 return next(event);
482 }
483 });
484
485 on('turn.complete', async ($, event: TurnCompleteInput, next) => {
486 if (compacting) return next(event);
487 try {
488 const { context } = await $.session.usage();
489 if ((context.percent ?? 0) < configured.compactAtPercent) return next(event);
490 compacting = true;
491 await $.session.compact();
492 } catch (error) {
493 $.ui.log(
494 `auto-compact skipped (${error instanceof Error ? error.message : String(error)})`,
495 );
496 } finally {
497 compacting = false;
498 }
499 return next(event);
500 });
501};
502
503export { resolveOptions };
504src/compact.ts 344 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 JevAsker,
10 JevQuestions,
11 JevResponse,
12 Message,
13 ResolvedCompactOptions,
14 ToolCall,
15 ToolUse,
16} from './types.js';
17
18export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
19 goal: '',
20 keepThreshold: 0.5,
21 preserveRecentMessages: 6,
22 maxStateTokens: 25_000,
23 maxRequestTokens: 30_000,
24 truncateHeadChars: 300,
25};
26
27/** Tokens the request envelope (`model`, key names) adds around state and questions. */
28const REQUEST_OVERHEAD_TOKENS = 20;
29
30function finite(value: number | undefined, fallback: number): number {
31 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
32}
33
34export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
35 return {
36 goal: options.goal ?? DEFAULT_OPTIONS.goal,
37 keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
38 preserveRecentMessages: Math.max(
39 0,
40 Math.floor(
41 finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
42 ),
43 ),
44 maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
45 maxRequestTokens: Math.max(
46 1,
47 finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
48 ),
49 truncateHeadChars: Math.max(
50 0,
51 Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
52 ),
53 };
54}
55
56/** The two `noul` questions asked about one call: keep the call, keep its result. */
57export function questionsFor(call: ToolCall): JevQuestions {
58 return {
59 [`call_${call.id}`]: {
60 type: 'noul',
61 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`,
62 },
63 [`result_${call.id}`]: {
64 type: 'noul',
65 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`,
66 },
67 };
68}
69
70/**
71 * Splits the candidate calls into batches whose questions, together with the
72 * (always complete) state, fit one request.
73 */
74export function batchCalls(
75 calls: readonly ToolCall[],
76 stateTokens: number,
77 options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
78): ToolCall[][] {
79 const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
80 const batches: ToolCall[][] = [];
81 let current: ToolCall[] = [];
82 let currentTokens = 0;
83 for (const call of calls) {
84 const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
85 if (current.length > 0 && currentTokens + tokens > budget) {
86 batches.push(current);
87 current = [];
88 currentTokens = 0;
89 }
90 if (current.length === 0 && tokens > budget) {
91 throw new Error(
92 `state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
93 );
94 }
95 current.push(call);
96 currentTokens += tokens;
97 }
98 if (current.length > 0) batches.push(current);
99 return batches;
100}
101
102export function decideCall(
103 call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
104 answer: CallAnswer,
105 options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
106): CallDecision {
107 const base = { id: call.id, tool: call.tool, ...answer };
108 if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
109 if (answer.keepResult >= options.keepThreshold) {
110 return { ...base, action: 'keep', reason: 'kept' };
111 }
112 if (answer.keepCall >= options.keepThreshold) {
113 return { ...base, action: 'drop_result', reason: 'result_dropped' };
114 }
115 return { ...base, action: 'drop_call', reason: 'call_dropped' };
116}
117
118async function askBatch(
119 asker: JevAsker,
120 state: CompactionState,
121 batch: readonly ToolCall[],
122): Promise<{ answers: Map<string, CallAnswer>; usage: JevResponse['usage'] }> {
123 const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
124 const response = await asker.ask(state, questions);
125 return {
126 answers: new Map(
127 batch.map((call) => [
128 call.id,
129 {
130 keepCall: noulAnswer(response.answers, `call_${call.id}`),
131 keepResult: noulAnswer(response.answers, `result_${call.id}`),
132 },
133 ]),
134 ),
135 usage: response.usage,
136 };
137}
138
139function aggregateUsage(
140 usages: readonly JevResponse['usage'][],
141): CompactResult['stats']['apiUsage'] {
142 const total = (key: 'input_tokens' | 'output_tokens'): number | null => {
143 const values = usages.map((usage) => usage?.[key]);
144 return values.length > 0 &&
145 values.every(
146 (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0,
147 )
148 ? values.reduce<number>((sum, value) => sum + (value ?? 0), 0)
149 : null;
150 };
151 const inputTokens = total('input_tokens');
152 const outputTokens = total('output_tokens');
153 return inputTokens === null && outputTokens === null
154 ? null
155 : { inputTokens, outputTokens };
156}
157
158function truncatedResultText(text: string, isError: boolean, headChars: number): string {
159 if (text.length <= headChars + 120) return text;
160 const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
161 return `${head}[fast-jev-plus truncated ${text.length - headChars} chars of this tool result${
162 isError ? ' (error)' : ''
163 }; re-run the tool if needed]`;
164}
165
166/**
167 * Rebuilds the conversation from the decisions. A dropped call disappears
168 * together with its result; a dropped result keeps a bounded head and note.
169 * Messages that lose all their content are removed; untouched messages are
170 * returned as the same objects they came in as.
171 */
172export function applyDecisions(
173 messages: readonly Message[],
174 decisions: readonly CallDecision[],
175 calls: readonly ToolCall[],
176 headChars: number,
177): Message[] {
178 const byId = new Map(calls.map((call) => [call.id, call]));
179 const actions = new Map<string, CallDecision['action']>();
180 for (const decision of decisions) {
181 const call = byId.get(decision.id);
182 if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
183 }
184 const kept: Message[] = [];
185 for (const message of messages) {
186 const touched =
187 message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
188 (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
189 if (!touched) {
190 kept.push(message);
191 continue;
192 }
193 const toolUses = message.toolUses
194 .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
195 .map((tool) => {
196 if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
197 const text = truncatedResultText(
198 tool.text ?? '',
199 tool.isError ?? false,
200 headChars,
201 );
202 if ((tool.text ?? '') === text) return tool;
203 const copy: ToolUse = {
204 tool_use_id: tool.tool_use_id,
205 tool: tool.tool,
206 input: tool.input,
207 text,
208 };
209 if (tool.isError) copy.isError = true;
210 return copy;
211 });
212 const toolResults = (message.toolResults ?? [])
213 .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
214 .map((result) => {
215 if (actions.get(result.tool_use_id) !== 'drop_result') return result;
216 const text = truncatedResultText(result.text, result.isError ?? false, headChars);
217 return text === result.text
218 ? result
219 : {
220 tool_use_id: result.tool_use_id,
221 text,
222 isError: result.isError,
223 };
224 });
225 if (
226 !message.toolUses.some(
227 (tool) => actions.get(tool.tool_use_id) === 'drop_call',
228 ) &&
229 !(message.toolResults ?? []).some(
230 (result) => actions.get(result.tool_use_id) === 'drop_call',
231 ) &&
232 toolUses.every((tool, index) => tool === message.toolUses[index]) &&
233 toolResults.every(
234 (result, index) => result === message.toolResults?.[index],
235 )
236 ) {
237 kept.push(message);
238 continue;
239 }
240 if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
241 continue;
242 }
243 const rebuilt: Message = { role: message.role, text: message.text, toolUses };
244 if (toolResults.length > 0) rebuilt.toolResults = toolResults;
245 kept.push(rebuilt);
246 }
247 return kept;
248}
249
250/** Characters of text, tool input and tool output a message holds. */
251export function messageChars(message: Message): number {
252 let total = message.text.length;
253 for (const tool of message.toolUses) {
254 try {
255 total += JSON.stringify(tool.input).length;
256 } catch {
257 total += 20;
258 }
259 }
260 for (const result of message.toolResults ?? []) total += result.text.length;
261 return total;
262}
263
264export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
265 const { charsBefore, charsAfter } = result.stats;
266 return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
267}
268
269function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
270 return decisions.filter((decision) => decision.reason === reason).length;
271}
272
273/**
274 * Compacts a transcript by asking Jev, for every tool call outside the pinned
275 * first and newest messages, whether the call and whether its result must
276 * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
277 * sent as state with every batch of questions. Throws when Jev fails or the
278 * history cannot be fitted; the caller decides whether to fall back.
279 */
280export async function compact(
281 messages: readonly Message[],
282 asker: JevAsker,
283 options: CompactOptions = {},
284): Promise<CompactResult> {
285 const started = Date.now();
286 const resolved = resolveOptions(options);
287 const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
288 const candidates = calls.filter((call) => !call.pinned);
289 const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
290 const estimatedTokensBefore = estimateTokens(JSON.stringify(messages));
291
292 let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
293 let batches: ToolCall[][] = [];
294 const answers = new Map<string, CallAnswer>();
295 let apiUsage: CompactResult['stats']['apiUsage'] = null;
296 if (candidates.length > 0) {
297 const state = fitState(messages, calls, resolved);
298 fitted = state;
299 batches = batchCalls(candidates, state.tokens, resolved);
300 const answered = await Promise.all(
301 batches.map((batch) => askBatch(asker, state.state, batch)),
302 );
303 apiUsage = aggregateUsage(answered.map((result) => result.usage));
304 for (const result of answered) {
305 for (const [id, answer] of result.answers) answers.set(id, answer);
306 }
307 }
308
309 const decisions = calls.map((call) =>
310 decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
311 );
312 const kept = applyDecisions(
313 messages,
314 decisions,
315 calls,
316 resolved.truncateHeadChars,
317 );
318 const estimatedTokensAfter = estimateTokens(JSON.stringify(kept));
319 return {
320 messages: kept,
321 decisions,
322 stats: {
323 messagesBefore: messages.length,
324 messagesAfter: kept.length,
325 charsBefore,
326 charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
327 calls: calls.length,
328 kept: count(decisions, 'kept'),
329 resultsDropped: count(decisions, 'result_dropped'),
330 callsDropped: count(decisions, 'call_dropped'),
331 pinned: count(decisions, 'pinned'),
332 stateTokens: fitted.tokens,
333 stateStage: fitted.stage,
334 requests: asker.isRemote === false ? 0 : batches.length,
335 ms: Date.now() - started,
336 estimatedTokensBefore,
337 estimatedTokensAfter,
338 estimatedTokensSaved: estimatedTokensBefore - estimatedTokensAfter,
339 apiUsage,
340 costUsd: null,
341 },
342 };
343}
344src/cloud.ts 219 lines1import { parseRunRecord, type RunRecord } from './run-record.js';
2
3export const CLOUD_UPLOAD_TIMEOUT_MS = 3_000;
4export const MAX_CLOUD_RUN_BYTES = 32 * 1024;
5
6export interface CloudUploadConfig {
7 url: string;
8 token: string;
9}
10
11export interface CloudRun {
12 id: string;
13 record_version: 1;
14 run_at: string;
15 source: RunRecord['source'];
16 mode: RunRecord['mode'];
17 status: RunRecord['status'];
18 fallback_reason: RunRecord['fallbackReason'];
19 estimated_tokens_before: number | null;
20 estimated_tokens_after: number | null;
21 estimated_tokens_saved: number | null;
22 estimated_reduction: number | null;
23 latency_ms: number | null;
24 request_count: number | null;
25 input_tokens: number | null;
26 output_tokens: number | null;
27 known_cost_usd: number | null;
28 critical_required: number | null;
29 critical_retained: number | null;
30 task_passed: boolean | null;
31 kept_count: number;
32 truncated_count: number;
33 removed_count: number;
34}
35
36export type CloudFetch = (
37 url: string,
38 init: { method: 'POST'; headers: Record<string, string>; body: string },
39) => Promise<{ ok: boolean }>;
40
41const FIELDS: readonly (keyof CloudRun)[] = [
42 'id', 'record_version', 'run_at', 'source', 'mode', 'status', 'fallback_reason',
43 'estimated_tokens_before', 'estimated_tokens_after', 'estimated_tokens_saved',
44 'estimated_reduction', 'latency_ms', 'request_count', 'input_tokens', 'output_tokens',
45 'known_cost_usd', 'critical_required', 'critical_retained', 'task_passed',
46 'kept_count', 'truncated_count', 'removed_count',
47];
48
49function object(value: unknown): Record<string, unknown> | null {
50 return value !== null && typeof value === 'object' && !Array.isArray(value)
51 ? value as Record<string, unknown>
52 : null;
53}
54
55function number(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null {
56 return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum
57 ? value
58 : null;
59}
60
61function integer(value: unknown): number | null {
62 const parsed = number(value);
63 return parsed !== null && Number.isInteger(parsed) ? parsed : null;
64}
65
66function nullableNumber(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null | undefined {
67 return value === null ? null : number(value, maximum) ?? undefined;
68}
69
70function nullableInteger(value: unknown): number | null | undefined {
71 return value === null ? null : integer(value) ?? undefined;
72}
73
74/** Validates the exact public-safe row accepted by the ingestion endpoint. */
75export function parseCloudRun(value: unknown): CloudRun | null {
76 const raw = object(value);
77 if (!raw || Object.keys(raw).length !== FIELDS.length ||
78 Object.keys(raw).some((key) => !FIELDS.includes(key as keyof CloudRun))) return null;
79
80 const before = nullableInteger(raw.estimated_tokens_before);
81 const after = nullableInteger(raw.estimated_tokens_after);
82 const saved = nullableInteger(raw.estimated_tokens_saved);
83 const reduction = nullableNumber(raw.estimated_reduction, 1);
84 const latency = nullableNumber(raw.latency_ms);
85 const requests = nullableInteger(raw.request_count);
86 const input = nullableInteger(raw.input_tokens);
87 const output = nullableInteger(raw.output_tokens);
88 const cost = nullableNumber(raw.known_cost_usd);
89 const required = nullableInteger(raw.critical_required);
90 const retained = nullableInteger(raw.critical_retained);
91 const kept = integer(raw.kept_count);
92 const truncated = integer(raw.truncated_count);
93 const removed = integer(raw.removed_count);
94 const source = raw.source === 'live' || raw.source === 'benchmark' ? raw.source : null;
95 const mode = raw.mode === 'jev' || raw.mode === 'local' ? raw.mode : null;
96 const status = ['applied', 'fallback', 'passed', 'failed'].includes(String(raw.status))
97 ? raw.status as CloudRun['status']
98 : null;
99 const fallback = raw.fallback_reason === null || [
100 'below_minimum_reduction', 'missing_api_key', 'request_timeout',
101 'remote_error', 'compaction_error',
102 ].includes(String(raw.fallback_reason)) ? raw.fallback_reason as CloudRun['fallback_reason'] : undefined;
103 const core = [before, after, saved, reduction, latency, requests];
104 const noMetrics = core.every((entry) => entry === null);
105 const fullMetrics = core.every((entry) => typeof entry === 'number');
106
107 if (
108 raw.record_version !== 1 ||
109 typeof raw.id !== 'string' || !/^run-[a-z0-9-]{8,80}$/.test(raw.id) ||
110 typeof raw.run_at !== 'string' || Number.isNaN(Date.parse(raw.run_at)) ||
111 !source || !mode || !status || fallback === undefined ||
112 [before, after, saved, reduction, latency, requests, input, output, cost,
113 required, retained].some((entry) => entry === undefined) ||
114 kept === null || truncated === null || removed === null ||
115 (!noMetrics && !fullMetrics) ||
116 (fullMetrics && (after! > before! || saved !== before! - after!)) ||
117 (noMetrics && (input !== null || output !== null || cost !== null)) ||
118 (source === 'live' && (!['applied', 'fallback'].includes(status) ||
119 required !== null || retained !== null || raw.task_passed !== null)) ||
120 (source === 'benchmark' && (!['passed', 'failed'].includes(status) ||
121 required === null || retained === null || (retained as number) > (required as number) ||
122 typeof raw.task_passed !== 'boolean' || ((status === 'passed') !== raw.task_passed))) ||
123 ((status === 'fallback') !== (fallback !== null)) ||
124 (status !== 'fallback' && noMetrics)
125 ) return null;
126
127 return {
128 id: raw.id,
129 record_version: 1,
130 run_at: new Date(raw.run_at).toISOString(),
131 source,
132 mode,
133 status,
134 fallback_reason: fallback,
135 estimated_tokens_before: before!,
136 estimated_tokens_after: after!,
137 estimated_tokens_saved: saved!,
138 estimated_reduction: reduction!,
139 latency_ms: latency!,
140 request_count: requests!,
141 input_tokens: input!,
142 output_tokens: output!,
143 known_cost_usd: cost!,
144 critical_required: required!,
145 critical_retained: retained!,
146 task_passed: raw.task_passed as boolean | null,
147 kept_count: kept,
148 truncated_count: truncated,
149 removed_count: removed,
150 };
151}
152
153/** Removes local-only detail before any cloud serialization occurs. */
154export function toCloudRun(value: unknown): CloudRun {
155 const record = parseRunRecord(value);
156 if (!record) throw new Error('Invalid run record');
157 const metrics = record.metrics;
158 const row = parseCloudRun({
159 id: record.id,
160 record_version: 1,
161 run_at: record.timestamp,
162 source: record.source,
163 mode: record.mode,
164 status: record.status,
165 fallback_reason: record.fallbackReason,
166 estimated_tokens_before: metrics?.estimatedTokensBefore ?? null,
167 estimated_tokens_after: metrics?.estimatedTokensAfter ?? null,
168 estimated_tokens_saved: metrics?.estimatedTokensSaved ?? null,
169 estimated_reduction: metrics?.estimatedReduction ?? null,
170 latency_ms: metrics?.latencyMs ?? null,
171 request_count: metrics?.requests ?? null,
172 input_tokens: metrics?.apiUsage?.inputTokens ?? null,
173 output_tokens: metrics?.apiUsage?.outputTokens ?? null,
174 known_cost_usd: metrics?.costUsd ?? null,
175 critical_required: metrics?.criticalRetention?.required ?? null,
176 critical_retained: metrics?.criticalRetention?.retained ?? null,
177 task_passed: metrics?.taskPassed ?? null,
178 kept_count: record.decisions.filter((decision) => decision.action === 'keep').length,
179 truncated_count: record.decisions.filter((decision) => decision.action === 'drop_result').length,
180 removed_count: record.decisions.filter((decision) => decision.action === 'drop_call').length,
181 });
182 if (!row) throw new Error('Run record cannot be uploaded');
183 return row;
184}
185
186export function cloudUploadConfig(
187 enabled: string | undefined,
188 url: string | undefined,
189 token: string | undefined,
190): CloudUploadConfig | null {
191 if (enabled !== '1') return null;
192 if (!url || !token || !/^[!-~]{32,512}$/.test(token)) {
193 throw new Error('Cloud upload configuration is incomplete');
194 }
195 let parsed: URL;
196 try { parsed = new URL(url); } catch { throw new Error('Cloud ingestion URL is invalid'); }
197 if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash || url.length > 2048) {
198 throw new Error('Cloud ingestion URL must be HTTPS');
199 }
200 return { url: parsed.href, token };
201}
202
203export async function uploadCloudRun(
204 record: RunRecord,
205 config: CloudUploadConfig,
206 fetchFn: CloudFetch,
207): Promise<void> {
208 const body = JSON.stringify(toCloudRun(record));
209 if (body.length > MAX_CLOUD_RUN_BYTES) throw new Error('Cloud run exceeds size limit');
210 const response = await fetchFn(config.url, {
211 method: 'POST',
212 headers: {
213 authorization: `Bearer ${config.token}`,
214 'content-type': 'application/json',
215 },
216 body,
217 });
218 if (!response.ok) throw new Error('Cloud upload failed');
219}src/local.ts 60 lines1import type {
2 CompactionState,
3 HistoryToolCall,
4 JevAsker,
5 JevQuestions,
6 JevResponse,
7} from './types.js';
8
9const READ_TOOLS = new Set(['read']);
10
11function structuredCalls(state: string | object): HistoryToolCall[] {
12 if (!('history' in Object(state))) return [];
13 const history = (state as CompactionState).history;
14 if (!Array.isArray(history)) return [];
15 return history.flatMap((entry) =>
16 (entry.tool_calls ?? []).filter(
17 (call): call is HistoryToolCall => typeof call === 'object',
18 ),
19 );
20}
21
22/** Conservatively removes only old, successful duplicate reads. */
23export class LocalAsker implements JevAsker {
24 readonly isRemote = false;
25
26 async ask(state: string | object, questions: JevQuestions): Promise<JevResponse> {
27 const calls = structuredCalls(state);
28 const latest = new Map<string, HistoryToolCall>();
29 for (const call of calls) {
30 if (READ_TOOLS.has(call.tool.toLowerCase())) {
31 latest.set(call.tool.toLowerCase() + '\0' + call.input, call);
32 }
33 }
34 const removable = new Set(
35 calls
36 .filter((call) => {
37 const key = call.tool.toLowerCase() + '\0' + call.input;
38 const newer = latest.get(key);
39 return (
40 READ_TOOLS.has(call.tool.toLowerCase()) &&
41 !call.input.endsWith('…') &&
42 !call.result.startsWith('error,') &&
43 newer !== undefined &&
44 newer.id !== call.id &&
45 !newer.result.startsWith('error,')
46 );
47 })
48 .map((call) => call.id),
49 );
50 return {
51 answers: Object.fromEntries(
52 Object.keys(questions).map((key) => {
53 const id = key.replace(/^(call|result)_/, '');
54 return [key, { type: 'noul', noul: removable.has(id) ? 0 : 1 }];
55 }),
56 ),
57 };
58 }
59}
60src/telemetry.ts 132 lines1import type { RunRecord } from './run-record.js';
2
3/** Must match `.claude-plugin/plugin.json`; a test keeps them in sync. */
4export const PLUGIN_VERSION = '0.6.0';
5export const DEFAULT_TELEMETRY_URL = 'https://jev-project-bay.vercel.app/api/telemetry';
6export const TELEMETRY_TIMEOUT_MS = 3_000;
7export const MAX_TELEMETRY_BYTES = 1024;
8export const MAX_TELEMETRY_TOKENS = 5_000_000;
9export const MAX_TELEMETRY_LATENCY_MS = 600_000;
10export const TELEMETRY_NOTICE =
11 'fast-jev-plus shares anonymous usage counts (mode, status, token estimates) ' +
12 'with the public community dashboard. No prompts, code, file names, or tool output ' +
13 'are sent. Nothing is sent this session. Opt out: set JEV_TELEMETRY=0 or the ' +
14 'plugin "telemetry" option to false.';
15
16/** Exactly what one live compaction contributes to the community totals. */
17export interface TelemetryEvent {
18 v: 1;
19 install_id: string;
20 plugin_version: string;
21 mode: RunRecord['mode'];
22 status: 'applied' | 'fallback';
23 tokens_before: number | null;
24 tokens_after: number | null;
25 latency_ms: number | null;
26}
27
28const FIELDS: readonly (keyof TelemetryEvent)[] = [
29 'v', 'install_id', 'plugin_version', 'mode', 'status',
30 'tokens_before', 'tokens_after', 'latency_ms',
31];
32
33const OFF_VALUES = new Set(['0', 'false', 'off', 'no']);
34
35/** Telemetry is on unless the user opts out through config or environment. */
36export function telemetryEnabled(input: {
37 option?: boolean;
38 jevTelemetry?: string;
39 doNotTrack?: string;
40}): boolean {
41 if (input.option === false) return false;
42 if (input.jevTelemetry !== undefined && OFF_VALUES.has(input.jevTelemetry.trim().toLowerCase())) {
43 return false;
44 }
45 return input.doNotTrack?.trim() !== '1';
46}
47
48export function isInstallId(value: unknown): value is string {
49 return typeof value === 'string' && /^[0-9a-f]{32}$/.test(value);
50}
51
52/** A random, anonymous install identifier; not derived from the user or machine. */
53export function newInstallId(random: () => number = Math.random): string {
54 const host = globalThis as { crypto?: { randomUUID?: () => string } };
55 const uuid = host.crypto?.randomUUID?.();
56 if (uuid) return uuid.replaceAll('-', '');
57 return Array.from({ length: 32 }, () => Math.floor(random() * 16).toString(16)).join('');
58}
59
60export function telemetryUrl(override: string | undefined): string {
61 if (!override) return DEFAULT_TELEMETRY_URL;
62 let parsed: URL;
63 try { parsed = new URL(override); } catch { throw new Error('Telemetry URL is invalid'); }
64 if (parsed.protocol !== 'https:' || parsed.username || parsed.password || override.length > 2048) {
65 throw new Error('Telemetry URL must be HTTPS');
66 }
67 return parsed.href;
68}
69
70function bounded(value: unknown, maximum: number): number | null | undefined {
71 if (value === null) return null;
72 return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= maximum
73 ? value
74 : undefined;
75}
76
77/** Validates the exact anonymous event accepted by the telemetry endpoint. */
78export function parseTelemetryEvent(value: unknown): TelemetryEvent | null {
79 if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
80 const raw = value as Record<string, unknown>;
81 const keys = Object.keys(raw);
82 if (keys.length !== FIELDS.length || keys.some((key) => !FIELDS.includes(key as keyof TelemetryEvent))) {
83 return null;
84 }
85 const before = bounded(raw.tokens_before, MAX_TELEMETRY_TOKENS);
86 const after = bounded(raw.tokens_after, MAX_TELEMETRY_TOKENS);
87 const latency = bounded(raw.latency_ms, MAX_TELEMETRY_LATENCY_MS);
88 const metrics = [before, after, latency];
89 const none = metrics.every((entry) => entry === null);
90 const all = metrics.every((entry) => typeof entry === 'number');
91 if (
92 raw.v !== 1 ||
93 !isInstallId(raw.install_id) ||
94 typeof raw.plugin_version !== 'string' ||
95 !/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(raw.plugin_version) ||
96 (raw.mode !== 'jev' && raw.mode !== 'local') ||
97 (raw.status !== 'applied' && raw.status !== 'fallback') ||
98 metrics.some((entry) => entry === undefined) ||
99 (!none && !all) ||
100 (all && after! > before!) ||
101 (raw.status === 'applied' && none)
102 ) return null;
103 return {
104 v: 1,
105 install_id: raw.install_id,
106 plugin_version: raw.plugin_version,
107 mode: raw.mode,
108 status: raw.status,
109 tokens_before: before!,
110 tokens_after: after!,
111 latency_ms: latency!,
112 };
113}
114
115/** Reduces a live run record to its anonymous event; benchmark runs are never sent. */
116export function toTelemetryEvent(record: RunRecord, installId: string): TelemetryEvent | null {
117 if (record.source !== 'live' || (record.status !== 'applied' && record.status !== 'fallback')) {
118 return null;
119 }
120 const metrics = record.metrics;
121 return parseTelemetryEvent({
122 v: 1,
123 install_id: installId,
124 plugin_version: PLUGIN_VERSION,
125 mode: record.mode,
126 status: record.status,
127 tokens_before: metrics ? Math.min(metrics.estimatedTokensBefore, MAX_TELEMETRY_TOKENS) : null,
128 tokens_after: metrics ? Math.min(metrics.estimatedTokensAfter, MAX_TELEMETRY_TOKENS) : null,
129 latency_ms: metrics ? Math.min(Math.round(metrics.latencyMs), MAX_TELEMETRY_LATENCY_MS) : null,
130 });
131}
132src/run-record.ts 308 lines1import type {
2 CallAction,
3 CallDecision,
4 CompactResult,
5 CompactionMode,
6} from './types.js';
7
8export const RUN_RECORD_VERSION = 1 as const;
9export const DEFAULT_RUN_HISTORY_PATH = '.jev/runs.jsonl';
10export const MAX_RUN_HISTORY_RECORDS = 1_000;
11
12export type RunSource = 'live' | 'benchmark';
13export type RunStatus = 'applied' | 'fallback' | 'passed' | 'failed';
14export type RunFallbackReason =
15 | 'below_minimum_reduction'
16 | 'missing_api_key'
17 | 'request_timeout'
18 | 'remote_error'
19 | 'compaction_error';
20
21export interface RunDecision {
22 id: string;
23 tool: string;
24 action: CallAction;
25 reason: CallDecision['reason'];
26 keepCall: number;
27 keepResult: number;
28}
29
30export interface RunMetrics {
31 estimatedTokensBefore: number;
32 estimatedTokensAfter: number;
33 estimatedTokensSaved: number;
34 estimatedReduction: number;
35 latencyMs: number;
36 requests: number;
37 apiUsage: CompactResult['stats']['apiUsage'];
38 costUsd: number | null;
39 criticalRetention: {
40 required: number;
41 retained: number;
42 ratio: number;
43 } | null;
44 taskPassed: boolean | null;
45}
46
47export interface RunRecord {
48 version: typeof RUN_RECORD_VERSION;
49 id: string;
50 timestamp: string;
51 source: RunSource;
52 mode: CompactionMode;
53 status: RunStatus;
54 fallbackReason: RunFallbackReason | null;
55 metrics: RunMetrics | null;
56 decisions: RunDecision[];
57 benchmark: {
58 caseId: string;
59 category: string;
60 } | null;
61}
62
63export type NewRunRecord = Omit<RunRecord, 'version' | 'id' | 'timestamp'>;
64
65const SOURCES: readonly RunSource[] = ['live', 'benchmark'];
66const MODES: readonly CompactionMode[] = ['jev', 'local'];
67const STATUSES: readonly RunStatus[] = ['applied', 'fallback', 'passed', 'failed'];
68const FALLBACKS: readonly RunFallbackReason[] = [
69 'below_minimum_reduction',
70 'missing_api_key',
71 'request_timeout',
72 'remote_error',
73 'compaction_error',
74];
75const ACTIONS: readonly CallAction[] = ['keep', 'drop_result', 'drop_call'];
76const REASONS: readonly CallDecision['reason'][] = [
77 'pinned',
78 'kept',
79 'result_dropped',
80 'call_dropped',
81];
82
83function object(value: unknown): Record<string, unknown> | null {
84 return value !== null && typeof value === 'object' && !Array.isArray(value)
85 ? (value as Record<string, unknown>)
86 : null;
87}
88
89function finite(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null {
90 return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum
91 ? value
92 : null;
93}
94
95function integer(value: unknown): number | null {
96 const parsed = finite(value);
97 return parsed !== null && Number.isInteger(parsed) ? parsed : null;
98}
99
100function probability(value: unknown): number | null {
101 return finite(value, 1);
102}
103
104function member<T extends string>(value: unknown, values: readonly T[]): T | null {
105 return typeof value === 'string' && values.includes(value as T) ? (value as T) : null;
106}
107
108function parseUsage(value: unknown): RunMetrics['apiUsage'] | undefined {
109 if (value === null) return null;
110 const raw = object(value);
111 if (!raw) return undefined;
112 const inputTokens = raw.inputTokens === null ? null : integer(raw.inputTokens);
113 const outputTokens = raw.outputTokens === null ? null : integer(raw.outputTokens);
114 if (inputTokens === null && raw.inputTokens !== null) return undefined;
115 if (outputTokens === null && raw.outputTokens !== null) return undefined;
116 return { inputTokens, outputTokens };
117}
118
119function parseMetrics(value: unknown): RunMetrics | null | undefined {
120 if (value === null) return null;
121 const raw = object(value);
122 if (!raw) return undefined;
123 const estimatedTokensBefore = integer(raw.estimatedTokensBefore);
124 const estimatedTokensAfter = integer(raw.estimatedTokensAfter);
125 const estimatedTokensSaved = integer(raw.estimatedTokensSaved);
126 const estimatedReduction = probability(raw.estimatedReduction);
127 const latencyMs = finite(raw.latencyMs);
128 const requests = integer(raw.requests);
129 const apiUsage = parseUsage(raw.apiUsage);
130 const costUsd = raw.costUsd === null ? null : finite(raw.costUsd);
131 const critical = raw.criticalRetention === null ? null : object(raw.criticalRetention);
132 const criticalRequired = critical ? integer(critical.required) : null;
133 const criticalRetained = critical ? integer(critical.retained) : null;
134 const criticalRatio = critical ? probability(critical.ratio) : null;
135 const taskPassed = raw.taskPassed;
136 if (
137 estimatedTokensBefore === null ||
138 estimatedTokensAfter === null ||
139 estimatedTokensSaved === null ||
140 estimatedReduction === null ||
141 latencyMs === null ||
142 requests === null ||
143 apiUsage === undefined ||
144 (costUsd === null && raw.costUsd !== null) ||
145 (raw.criticalRetention !== null &&
146 (criticalRequired === null ||
147 criticalRetained === null ||
148 criticalRatio === null ||
149 criticalRetained > criticalRequired)) ||
150 (taskPassed !== null && typeof taskPassed !== 'boolean')
151 ) return undefined;
152 return {
153 estimatedTokensBefore,
154 estimatedTokensAfter,
155 estimatedTokensSaved,
156 estimatedReduction,
157 latencyMs,
158 requests,
159 apiUsage,
160 costUsd,
161 criticalRetention:
162 criticalRequired === null || criticalRetained === null || criticalRatio === null
163 ? null
164 : { required: criticalRequired, retained: criticalRetained, ratio: criticalRatio },
165 taskPassed,
166 };
167}
168
169function parseDecisions(value: unknown): RunDecision[] | null {
170 if (!Array.isArray(value) || value.length > 5_000) return null;
171 const decisions: RunDecision[] = [];
172 for (const entry of value) {
173 const raw = object(entry);
174 if (!raw) return null;
175 const action = member(raw.action, ACTIONS);
176 const reason = member(raw.reason, REASONS);
177 const keepCall = probability(raw.keepCall);
178 const keepResult = probability(raw.keepResult);
179 if (
180 typeof raw.id !== 'string' || !/^t\d+$/.test(raw.id) ||
181 typeof raw.tool !== 'string' || raw.tool.length === 0 || raw.tool.length > 120 ||
182 !action || !reason || keepCall === null || keepResult === null
183 ) return null;
184 decisions.push({
185 id: raw.id,
186 tool: raw.tool,
187 action,
188 reason,
189 keepCall,
190 keepResult,
191 });
192 }
193 return decisions;
194}
195
196/** Validates and copies only the safe record fields; unknown fields are discarded. */
197export function parseRunRecord(value: unknown): RunRecord | null {
198 const raw = object(value);
199 if (!raw || raw.version !== RUN_RECORD_VERSION) return null;
200 const source = member(raw.source, SOURCES);
201 const mode = member(raw.mode, MODES);
202 const status = member(raw.status, STATUSES);
203 const fallbackReason = raw.fallbackReason === null
204 ? null
205 : member(raw.fallbackReason, FALLBACKS);
206 const metrics = parseMetrics(raw.metrics);
207 const decisions = parseDecisions(raw.decisions);
208 const benchmark = raw.benchmark === null ? null : object(raw.benchmark);
209 if (
210 typeof raw.id !== 'string' || !/^run-[a-z0-9-]{8,80}$/.test(raw.id) ||
211 typeof raw.timestamp !== 'string' || Number.isNaN(Date.parse(raw.timestamp)) ||
212 !source || !mode || !status ||
213 (fallbackReason === null && raw.fallbackReason !== null) ||
214 metrics === undefined || !decisions ||
215 (source === 'live' && !['applied', 'fallback'].includes(status)) ||
216 (source === 'benchmark' && !['passed', 'failed'].includes(status)) ||
217 (status === 'fallback' && !fallbackReason) ||
218 (status !== 'fallback' && fallbackReason !== null) ||
219 (status !== 'fallback' && metrics === null) ||
220 (source === 'live' &&
221 (benchmark !== null ||
222 (metrics !== null &&
223 (metrics.criticalRetention !== null || metrics.taskPassed !== null)))) ||
224 (source === 'benchmark' &&
225 (benchmark === null ||
226 metrics === null ||
227 metrics.criticalRetention === null ||
228 metrics.taskPassed === null)) ||
229 (status === 'passed' && metrics?.taskPassed !== true) ||
230 (status === 'failed' && metrics?.taskPassed !== false)
231 ) return null;
232 let benchmarkMetadata: RunRecord['benchmark'] = null;
233 if (benchmark) {
234 if (
235 typeof benchmark.caseId !== 'string' || !/^[a-z0-9-]{1,120}$/.test(benchmark.caseId) ||
236 typeof benchmark.category !== 'string' || !/^[a-z0-9-]{1,120}$/.test(benchmark.category)
237 ) return null;
238 benchmarkMetadata = { caseId: benchmark.caseId, category: benchmark.category };
239 }
240 return {
241 version: RUN_RECORD_VERSION,
242 id: raw.id,
243 timestamp: new Date(raw.timestamp).toISOString(),
244 source,
245 mode,
246 status,
247 fallbackReason,
248 metrics,
249 decisions,
250 benchmark: benchmarkMetadata,
251 };
252}
253
254export function createRunRecord(input: NewRunRecord): RunRecord {
255 const record = parseRunRecord({
256 ...input,
257 version: RUN_RECORD_VERSION,
258 id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
259 timestamp: new Date().toISOString(),
260 });
261 if (!record) throw new Error('Invalid run record');
262 return record;
263}
264
265export function compactRunMetrics(result: CompactResult): RunMetrics {
266 const { stats } = result;
267 return {
268 estimatedTokensBefore: stats.estimatedTokensBefore,
269 estimatedTokensAfter: stats.estimatedTokensAfter,
270 estimatedTokensSaved: stats.estimatedTokensSaved,
271 estimatedReduction:
272 stats.estimatedTokensBefore === 0
273 ? 0
274 : stats.estimatedTokensSaved / stats.estimatedTokensBefore,
275 latencyMs: stats.ms,
276 requests: stats.requests,
277 apiUsage: stats.apiUsage,
278 costUsd: stats.costUsd,
279 criticalRetention: null,
280 taskPassed: null,
281 };
282}
283
284export function serializeRunRecord(value: unknown): string {
285 const record = parseRunRecord(value);
286 if (!record) throw new Error('Invalid run record');
287 return JSON.stringify(record) + '\n';
288}
289
290/** Returns newest valid records first while ignoring blank and malformed lines. */
291export function parseRunRecords(jsonl: string, limit = 100): RunRecord[] {
292 const bounded = Math.min(MAX_RUN_HISTORY_RECORDS, Math.max(0, Math.floor(limit)));
293 if (bounded === 0) return [];
294 const records: RunRecord[] = [];
295 const lines = jsonl.split(/\r?\n/);
296 for (let index = lines.length - 1; index >= 0 && records.length < bounded; index -= 1) {
297 const line = lines[index]?.trim();
298 if (!line) continue;
299 try {
300 const record = parseRunRecord(JSON.parse(line));
301 if (record) records.push(record);
302 } catch {
303 // A broken line must not hide the remaining history.
304 }
305 }
306 return records;
307}
308src/request.ts 110 lines1import type { JevAnswer, JevQuestions, JevResponse, JevState } from './types.js';
2
3export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
4export const DEFAULT_MODEL = 'jev-latest';
5export const JEV_REQUEST_TIMEOUT_MS = 30_000;
6
7const REDACTED = '[REDACTED]';
8const PRIVATE_KEY = /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/gi;
9const BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
10const KNOWN_API_KEY = /\b(?:sk-(?:ant-)?[A-Za-z0-9_-]{8,}|sk_(?:live|test)_[A-Za-z0-9_-]{8,}|github_pat_[A-Za-z0-9_]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{8,})\b/g;
11const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
12const SECRET_ASSIGNMENT = /(\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|private[_-]?token|password|passwd|secret|token)\b\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[A-Za-z0-9_+/=-]{4,})(?![.\w(])/gi;
13const SECRET_FIELD = /^(?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|private[_-]?key|private[_-]?token|password|passwd|secret|token)$/i;
14const EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
15const PHONE = /(?<![\w])\+?\d[\d\s().-]{8,}\d(?![\w])/g;
16
17function redactSensitiveText(value: string): string {
18 return value
19 .replace(PRIVATE_KEY, '[REDACTED PRIVATE KEY]')
20 .replace(BEARER_TOKEN, 'Bearer [REDACTED]')
21 .replace(KNOWN_API_KEY, REDACTED)
22 .replace(JWT, REDACTED)
23 .replace(SECRET_ASSIGNMENT, `$1${REDACTED}`)
24 .replace(EMAIL, '[REDACTED EMAIL]')
25 .replace(PHONE, (candidate) => {
26 const digits = candidate.replace(/\D/g, '').length;
27 return digits >= 10 && digits <= 15 ? '[REDACTED PHONE]' : candidate;
28 });
29}
30
31function redactSensitiveValue(key: string, value: unknown): unknown {
32 if (typeof value !== 'string') return value;
33 return SECRET_FIELD.test(key) ? REDACTED : redactSensitiveText(value);
34}
35
36export interface JevRequest {
37 url: string;
38 method: 'POST';
39 headers: Record<string, string>;
40 body: string;
41}
42
43/** The HTTP request for one Jev call, for any fetch-like transport. */
44export function buildJevRequest(
45 params: {
46 apiKey: string;
47 model?: string;
48 baseUrl?: string;
49 },
50 state: JevState,
51 questions: JevQuestions,
52): JevRequest {
53 return {
54 url: params.baseUrl ?? SYSTEM_ONE_URL,
55 method: 'POST',
56 headers: {
57 authorization: `Bearer ${params.apiKey}`,
58 'content-type': 'application/json',
59 },
60 body: JSON.stringify(
61 { model: params.model ?? DEFAULT_MODEL, state, questions },
62 redactSensitiveValue,
63 ),
64 };
65}
66
67/** Validates a Jev response body; throws on anything but an `answers` object. */
68export function parseJevResponse(
69 status: number,
70 ok: boolean,
71 text: string,
72): JevResponse {
73 if (!ok) {
74 throw new Error(`Jev request failed (${status})`);
75 }
76 let parsed: unknown;
77 try {
78 parsed = JSON.parse(text);
79 } catch {
80 throw new Error('Jev returned malformed JSON');
81 }
82 if (
83 parsed === null ||
84 typeof parsed !== 'object' ||
85 !('answers' in parsed) ||
86 parsed.answers === null ||
87 typeof parsed.answers !== 'object'
88 ) {
89 throw new Error('Jev response is missing answers');
90 }
91 return parsed as JevResponse;
92}
93
94/** The `noul` probability of one answer; throws when it is not there. */
95export function noulAnswer(
96 answers: Record<string, JevAnswer>,
97 name: string,
98): number {
99 const answer = answers[name];
100 if (
101 !answer ||
102 !('noul' in answer) ||
103 typeof answer.noul !== 'number' ||
104 !Number.isFinite(answer.noul)
105 ) {
106 throw new Error(`Invalid Jev answer for ${name}`);
107 }
108 return answer.noul;
109}
110src/types.ts 218 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 Jev 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 /** Jev's probability that the call itself still matters. */
52 keepCall: number;
53 /** Jev'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 Jev 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}
94
95export interface CompactOptions {
96 /** Ongoing task description; defaults to the last few user prompts. */
97 goal?: string;
98 /** Minimum keep probability for a call or result to stay. Default 0.5. */
99 keepThreshold?: number;
100 /** Newest messages never touched (the first message is always kept). Default 6. */
101 preserveRecentMessages?: number;
102 /** Estimated token ceiling for the state. Default 25000. */
103 maxStateTokens?: number;
104 /** Estimated token ceiling for state plus one batch of questions. Default 30000. */
105 maxRequestTokens?: number;
106 /** Characters of a dropped tool result to retain. Default 300. */
107 truncateHeadChars?: number;
108}
109
110export type CompactionMode = 'jev' | 'local';
111
112export interface ResolvedCompactOptions {
113 goal: string;
114 keepThreshold: number;
115 preserveRecentMessages: number;
116 maxStateTokens: number;
117 maxRequestTokens: number;
118 truncateHeadChars: number;
119}
120
121export interface CompactResult {
122 /** The compacted transcript; untouched messages are the input objects. */
123 messages: Message[];
124 decisions: CallDecision[];
125 stats: {
126 messagesBefore: number;
127 messagesAfter: number;
128 charsBefore: number;
129 charsAfter: number;
130 calls: number;
131 kept: number;
132 resultsDropped: number;
133 callsDropped: number;
134 pinned: number;
135 stateTokens: number;
136 /** Which fitting stage the state needed, '' when no request was made. */
137 stateStage: string;
138 requests: number;
139 ms: number;
140 /** Estimates over the serialized transcript, not provider token counts. */
141 estimatedTokensBefore: number;
142 estimatedTokensAfter: number;
143 estimatedTokensSaved: number;
144 /** Complete provider-reported totals; an unreported field remains null. */
145 apiUsage: {
146 inputTokens: number | null;
147 outputTokens: number | null;
148 } | null;
149 /** Unknown until pricing for the returned model is verified. */
150 costUsd: number | null;
151 };
152}
153
154/** The `state` of a Jev request: a string or any JSON-serialisable object. */
155export type JevState = string | object;
156
157export interface NoulQuestion {
158 type: 'noul';
159 instructions: string;
160 criteria?: {
161 true?: string;
162 false?: string;
163 };
164}
165
166export interface ChoiceQuestion {
167 type: 'choice';
168 instructions: string;
169 criteria: Record<string, string | null>;
170}
171
172export interface ScoreQuestion {
173 type: 'score';
174 instructions: string;
175 criteria: string[];
176}
177
178export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
179export type JevQuestions = Record<string, JevQuestion>;
180
181export interface NoulAnswer {
182 type?: 'noul';
183 noul: number;
184}
185
186export interface ChoiceAnswer {
187 type?: 'choice';
188 choice: string;
189 confidence: number;
190 probabilities: Record<string, number>;
191}
192
193export interface ScoreAnswer {
194 type?: 'score';
195 score: number;
196 confidence: number;
197 probabilities: Record<string, number>;
198}
199
200export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
201
202export interface JevResponse {
203 model?: string;
204 answers: Record<string, JevAnswer>;
205 usage?: {
206 input_tokens?: number;
207 output_tokens?: number;
208 };
209 [key: string]: unknown;
210}
211
212/** Anything that can answer Jev questions: `JevClient`, or a host-provided adapter. */
213export interface JevAsker {
214 /** False for deterministic local scorers, so network request metrics stay accurate. */
215 readonly isRemote?: boolean;
216 ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
217}
218src/state.ts 305 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 the usage Jev reports for real transcripts, where it lands 2–18%
25 * above the true count; a plain characters-per-token ratio undercounts the
26 * JSON-heavy states by up to 40%.
27 */
28export function estimateTokens(text: string): number {
29 let tokens = 0;
30 for (const [piece] of text.matchAll(TOKEN_PIECES)) {
31 const first = piece.charCodeAt(0);
32 if (first >= 48 && first <= 57) tokens += piece.length / 2;
33 else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
34 tokens += 1 + Math.floor((piece.length - 1) / 6);
35 } else tokens += 0.9;
36 }
37 return Math.ceil(tokens);
38}
39
40export function truncate(text: string, limit: number): string {
41 return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
42}
43
44function abridge(text: string, head: number, tail: number): string {
45 if (text.length <= head + tail + 40) return text;
46 const omitted = text.length - head - tail;
47 return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
48}
49
50export function isPinned(
51 index: number,
52 total: number,
53 preserveRecentMessages: number,
54): boolean {
55 return index === 0 || index >= total - preserveRecentMessages;
56}
57
58/**
59 * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
60 * result are not candidates (there is nothing to drop yet).
61 */
62export function collectToolCalls(
63 messages: readonly Message[],
64 preserveRecentMessages: number,
65): ToolCall[] {
66 const results = new Map<string, { index: number; result: ToolResult }>();
67 messages.forEach((message, index) => {
68 for (const result of message.toolResults ?? []) {
69 results.set(result.tool_use_id, { index, result });
70 }
71 });
72 const calls: ToolCall[] = [];
73 messages.forEach((message, callIndex) => {
74 for (const tool of message.toolUses) {
75 const found = results.get(tool.tool_use_id);
76 if (!found) continue;
77 calls.push({
78 id: `t${calls.length + 1}`,
79 tool_use_id: tool.tool_use_id,
80 tool: tool.tool,
81 input: tool.input,
82 callIndex,
83 resultIndex: found.index,
84 resultChars: found.result.text.length,
85 isError: found.result.isError ?? false,
86 pinned:
87 isPinned(callIndex, messages.length, preserveRecentMessages) ||
88 isPinned(found.index, messages.length, preserveRecentMessages),
89 });
90 }
91 });
92 return calls;
93}
94
95function inputText(input: Record<string, unknown>, limit: number): string {
96 let json = '';
97 try {
98 json = JSON.stringify(input);
99 } catch {
100 json = '[unserializable input]';
101 }
102 return truncate(json, limit);
103}
104
105function resultNote(call: ToolCall): string {
106 return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
107}
108
109/** One call as a single line, for when the structured form is too costly. */
110function compactCall(call: ToolCall): string {
111 const input = Object.entries(call.input)
112 .map(([key, value]) => {
113 const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
114 return `${key}=${text.replace(/\s+/g, ' ')}`;
115 })
116 .join(' ');
117 return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
118 call.isError ? 'error' : 'ok'
119 } ${call.resultChars}ch`;
120}
121
122/**
123 * Folds runs of adjacent call-only entries into one entry each, so the
124 * per-entry envelope is paid once per run; the call lines keep their ids.
125 */
126function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
127 const merged: HistoryEntry[] = [];
128 for (const entry of history) {
129 const previous = merged[merged.length - 1];
130 const foldable = (e: HistoryEntry): boolean =>
131 !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
132 if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
133 previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
134 continue;
135 }
136 merged.push({ ...entry });
137 }
138 return merged;
139}
140
141function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
142 const byMessage = new Map<number, ToolCall[]>();
143 for (const call of calls) {
144 const list = byMessage.get(call.callIndex) ?? [];
145 list.push(call);
146 byMessage.set(call.callIndex, list);
147 }
148 return byMessage;
149}
150
151function historyEntries(
152 messages: readonly Message[],
153 calls: readonly ToolCall[],
154 inputChars: number,
155): HistoryEntry[] {
156 const byMessage = callsByMessage(calls);
157 const entries: HistoryEntry[] = [];
158 messages.forEach((message, i) => {
159 const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
160 id: call.id,
161 tool: call.tool,
162 input: inputText(call.input, inputChars),
163 result: resultNote(call),
164 }));
165 if (message.text.trim().length === 0 && toolCalls.length === 0) return;
166 const entry: HistoryEntry = { i, role: message.role, text: message.text };
167 if (toolCalls.length > 0) entry.tool_calls = toolCalls;
168 entries.push(entry);
169 });
170 return entries;
171}
172
173/** The last three user prompts, as the default `goal`. */
174export function goalFromMessages(messages: readonly Message[]): string {
175 return messages
176 .filter(
177 (message) =>
178 message.role === 'user' &&
179 message.text.trim().length > 0 &&
180 (message.toolResults ?? []).length === 0,
181 )
182 .slice(-3)
183 .map((message) => truncate(message.text, 500))
184 .join('\n');
185}
186
187/**
188 * Builds the Jev state from the whole conversation and shrinks it in stages
189 * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
190 * are abridged oldest-first (pinned messages last), then old messages collapse
191 * to a one-line note, then old tool calls shrink to one line each, then old
192 * messages that carry no call are left out, then runs of old call-only
193 * messages are folded into one entry. Throws when even that is too big.
194 */
195export function fitState(
196 messages: readonly Message[],
197 calls: readonly ToolCall[],
198 options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
199): FittedState {
200 const goal = options.goal || goalFromMessages(messages);
201 const stateOf = (history: HistoryEntry[]): CompactionState => ({
202 context: STATE_CONTEXT,
203 goal,
204 history,
205 });
206 const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
207 const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
208 const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
209 state: stateOf(history),
210 tokens,
211 stage,
212 });
213
214 let history: HistoryEntry[] = [];
215 let perEntry: number[] = [];
216 let tokens = 0;
217 const rebuild = (inputChars: number): void => {
218 history = historyEntries(messages, calls, inputChars);
219 perEntry = history.map(entryTokens);
220 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
221 };
222 const fits = (): boolean => tokens <= options.maxStateTokens;
223 const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
224 const entry = history[index];
225 if (!entry) return;
226 change(entry);
227 const now = entryTokens(entry);
228 tokens += now - (perEntry[index] ?? 0);
229 perEntry[index] = now;
230 };
231
232 rebuild(INPUT_CHARS[0]);
233 if (fits()) return fitted(history, tokens, 'full');
234
235 for (const limit of INPUT_CHARS.slice(1)) {
236 rebuild(limit);
237 if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
238 }
239
240 const pinned = (entry: HistoryEntry): boolean =>
241 isPinned(entry.i, messages.length, options.preserveRecentMessages);
242 const indices = history.map((_, index) => index);
243 const order = [
244 ...indices.filter((index) => !pinned(history[index]!)),
245 ...indices.filter((index) => pinned(history[index]!)),
246 ];
247
248 for (const index of order) {
249 const entry = history[index]!;
250 if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
251 shrink(index, (e) => {
252 e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
253 });
254 if (fits()) return fitted(history, tokens, 'texts abridged');
255 }
256
257 for (const index of order) {
258 const entry = history[index]!;
259 if (pinned(entry) || entry.text.length === 0) continue;
260 const original = messages[entry.i]?.text.length ?? entry.text.length;
261 shrink(index, (e) => {
262 e.text = `[… ${original} chars omitted …]`;
263 });
264 if (fits()) return fitted(history, tokens, 'old messages collapsed');
265 }
266
267 const byMessage = callsByMessage(calls);
268 for (const index of order) {
269 const entry = history[index]!;
270 const own = byMessage.get(entry.i);
271 if (pinned(entry) || !own) continue;
272 shrink(index, (e) => {
273 e.tool_calls = own.map(compactCall);
274 });
275 if (fits()) return fitted(history, tokens, 'old calls compacted');
276 }
277
278 const left = new Set<number>();
279 for (const index of order) {
280 const entry = history[index]!;
281 if (pinned(entry) || entry.tool_calls) continue;
282 left.add(index);
283 tokens -= perEntry[index] ?? 0;
284 if (fits()) {
285 return fitted(
286 history.filter((_, i) => !left.has(i)),
287 tokens,
288 'old messages left out',
289 );
290 }
291 }
292
293 history = mergeCallRuns(
294 history.filter((_, i) => !left.has(i)),
295 pinned,
296 );
297 perEntry = history.map(entryTokens);
298 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
299 if (fits()) return fitted(history, tokens, 'old calls merged');
300
301 throw new Error(
302 `history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
303 );
304}
305