SLOPSHOPPER

fast-jev-plus

Verbatim Jev-guided compaction for Claude Code sessions.

toastnetworktimer
v0.6.0MITupdated 2026-09-29gaaurav03/Jev-Project
A shopper browsing a rack in a slop shop
README

fast-jev-plus

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

fast-jev-plus website with live compaction metrics

Why

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; …)

Features

Verbatim pruningRemoves whole tool calls or truncates their results. Kept messages are the original objects, unchanged.
Two modesjev scores every call with the Jev model in one fast request. local uses offline, deterministic rules with no API key and no network.
Outbound redactionBearer tokens, API keys, passwords, private keys, emails, and phone numbers are stripped from anything sent to Jev. Your transcript is untouched.
Safe fallbackErrors, timeouts, a missing key, or savings below the minimum (15% by default) hand off to Claude Code's built-in summary.
MetricsTokens before and after, reduction, latency, request count, and provider usage when reported. Nothing is invented.
Retention benchmarknpm run bench checks that labelled critical calls survive and that tasks still pass, in both modes.
Run historyEvery run is appended to .jev/runs.jsonl with decisions, never prompts or tool output.
DashboardsA private dashboard on 127.0.0.1, and a public one with anonymous community totals.

Quick start

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. Without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS, Claude Code does not load the hook and /compact silently 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

Configuration

Plugin options are set with /config (stored in Claude Code's settings).

OptionDefaultWhat it does
modejevjev for model scoring, local for offline deterministic rules
apiKey–TypeSafe key; leave unset to use TYPESAFE_API_KEY
telemetrytrueShare anonymous usage counts (details)
compactAtPercent60Context percentage at which compaction is requested automatically
minReductionRatio0.15Minimum estimated reduction required to replace the history
keepThreshold0.5Minimum Jev probability for a call or result to stay
preserveRecentMessages6Newest messages that are never touched (the first message is always kept)
truncateHeadChars300Characters kept from a truncated tool result, followed by a note
maxStateTokens25000Estimated token budget for the conversation state sent to Jev
maxRequestTokens30000Estimated budget for state plus questions in one Jev request
modeljev-latestJev model name

Environment variables:

VariablePurpose
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1Required for the plugin to load
TYPESAFE_API_KEYJev mode API key
JEV_TELEMETRY=0 / DO_NOT_TRACK=1Disable anonymous telemetry
JEV_CLOUD_UPLOAD, JEV_CLOUD_INGEST_URL, JEV_CLOUD_INGEST_TOKENOptional authenticated upload to a dashboard you host (self-hosting)

How it works

  1. Trigger. /compact, or the turn.complete hook when context reaches compactAtPercent.
  2. Pair and pin. Every tool_use is paired with its tool_result. Calls in the first message and the newest preserveRecentMessages messages are pinned.
  3. Redact. Secrets and PII are removed from the outbound copy only.
  4. Score.
  5. 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.
  6. local: old, successful duplicate Read calls are removed; recent calls, errors, edits, and anything uncertain are kept.
  7. Decide per call, against 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.
  8. Apply or fall back. If the estimated reduction reaches minReductionRatio, the rebuilt message list replaces the history. Untouched messages are the engine's own objects. Otherwise Claude Code's built-in compaction runs.
  9. Record. Metrics and decision metadata are appended to .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.

Benchmarks

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:

CaseJev reductionJev critical keptLocal reductionLocal critical keptTask (jev · local)
obsolete-read40.3%100%40.3%100%pass · pass
required-error69.6%0%0.0%100%pass · pass
repeated-test42.4%100%0.0%100%pass · pass
protected-constraint64.7%0%0.0%100%pass · pass
Total52.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.

Dashboards

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.

Privacy and telemetry

Telemetry is on by default and powers the public community totals.

  • First compaction: a random install ID is created in the plugin's own store and a notice is shown. Nothing is sent.
  • Every later live compaction sends exactly this:
{ "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.

Troubleshooting

SymptomFix
/compact shows no fast-jev-plus toastFunction 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 upRun 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.

Use as a library

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.

Self-host the public dashboard

  1. Supabase. In the SQL Editor, run both migrations in supabase/migrations/ in order. They create constrained tables with RLS enabled and no browser-role access. See supabase/README.md.
  2. Vercel. Import the repository. 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
  1. Your machine. To upload your own runs and benchmarks, add to the 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.

Development

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 .

PathContents
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/JevDemoScripted SwiftUI animation of the flow for screen recording (macOS)

Limitations

  • Only tool calls and results are candidates; text messages are never removed or shortened.
  • Token counts are estimates from characters, not a tokenizer.
  • A keep probability is not a proof that a result is safe to delete; the benchmark shows Jev can remove critical information.
  • The full state is resent with every Jev request, so very long histories cost more requests.
  • Function hooks are early access and may change between Claude Code releases.

License

MIT. See LICENSE.

Source 9 files
hooks/fast-jev.ts 504 lines
1import type {
2  On,
3  PluginOptions,
4  Register,
5  SessionMessage,
6  ToolResultSummary,
7  ToolUseSummary,
8  TurnCompleteInput,
9} from 'claude-code';
10
11import { compact, reductionRatio, resolveOptions } from '../src/compact.js';
12import {
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 };
504
src/compact.ts 344 lines
1import { noulAnswer } from './request.js';
2import { collectToolCalls, estimateTokens, fitState } from './state.js';
3import type {
4  CallAnswer,
5  CallDecision,
6  CompactOptions,
7  CompactResult,
8  CompactionState,
9  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}
344
src/cloud.ts 219 lines
1import { 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 lines
1import 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}
60
src/telemetry.ts 132 lines
1import 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}
132
src/run-record.ts 308 lines
1import 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}
308
src/request.ts 110 lines
1import 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}
110
src/types.ts 218 lines
1export type Role = 'user' | 'assistant';
2
3/**
4 * A tool_use block of an assistant message. `text` and `isError` mirror the
5 * outcome once the transcript holds it (Claude Code attaches them).
6 */
7export interface ToolUse {
8  tool_use_id: string;
9  tool: string;
10  input: Record<string, unknown>;
11  text?: string;
12  isError?: boolean;
13}
14
15/** A tool_result block of a user message. */
16export interface ToolResult {
17  tool_use_id: string;
18  text: string;
19  isError?: boolean;
20}
21
22/**
23 * One transcript message. The shape is a subset of Claude Code's
24 * `SessionMessage`, so a session transcript can be passed in as is.
25 */
26export interface Message {
27  role: Role;
28  text: string;
29  toolUses: ToolUse[];
30  toolResults?: ToolResult[];
31}
32
33/** A tool call paired with its result by `tool_use_id`. */
34export interface ToolCall {
35  /** Short id used in the 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}
218
src/state.ts 305 lines
1import type {
2  CompactionState,
3  FittedState,
4  HistoryEntry,
5  Message,
6  ResolvedCompactOptions,
7  ToolCall,
8  ToolResult,
9} from './types.js';
10
11export const STATE_CONTEXT =
12  'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
13
14/** Successive caps on the serialised tool input included per call. */
15const INPUT_CHARS = [1000, 200, 60] as const;
16const TEXT_HEAD = 400;
17const TEXT_TAIL = 150;
18
19const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
20
21/**
22 * Estimates tokens without a tokenizer: a word costs one token per six
23 * letters, a digit half a token, any other symbol nine tenths. Calibrated
24 * against 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