Jev decides when the session compacts, inside a 280k–400k token band; Claude's own summary does the compacting.

A Claude Code plugin that picks when a session compacts. Inside a 280k–400k token band, TypeSafe's Jev judges each finished turn: has a unit of work just closed, and does the next step still need exact recent tool output? It compacts at a seam, holds while detail matters, and never lets the context pass 400k. Claude's own /compact summary does the compacting. Jev never rewrites the transcript.
Last updated: 2026-09-27
Native auto-compaction fires at a fixed fill percentage, blind to where the work stands, so it compacts mid-debug as readily as after a clean commit. Earlier Jev integrations such as fast-jev-compaction point Jev at how to compact: they prune tool calls line by line. That breaks the prompt cache, strips reasoning payloads, and lets the agent repeat failures it no longer remembers. This plugin points Jev at when instead. It asks one cheap question per turn and leaves the summary to Claude.
Jev is not an LLM. It is a System One decision model: it takes typed questions over a state object and returns calibrated probabilities, with no text. On OpenRouter it costs $0.042 per million input tokens, with output free. A gate call here sends about 700–900 input tokens, roughly $0.00003, and returns in 0.3–0.8s once the connection is warm.
turn.complete ─┬─ subagent / aborted / errored / in flight / cooldown → pass
├─ context window < ceiling (a 200k model) → pass; native compaction governs
├─ tokens < 280k → pass
├─ tokens ≥ 400k → compact, no Jev call
└─ 280k ≤ tokens < 400k → ask Jev → veto? threshold? → compact | hold
One request to POST https://openrouter.ai/api/v1/systemone carries three questions over a bounded, redacted state. The state holds the last three typed prompts, the final answer, and the last 15 tool calls as one-liners. Token counts stay in code, where the band is decided, because they tell Jev nothing about whether work just closed.
| Question | Type | Judges |
|---|---|---|
at_boundary | Noul | A deliverable just closed (commit, green tests, finished answer, approved plan) and the next request is likely new work |
needs_verbatim_recent | Noul | The next step depends on exact recent error text, file contents, diffs, or command output |
phase | Choice | exploring · implementing · debugging · verifying · wrapping_up · conversing |
The policy lives in code, per TypeSafe's guidance: separate conditions for hard violations, and weighted thresholds for preferences.
P(needs_verbatim_recent) ≥ 0.5 holds, whatever the boundary says.P(at_boundary) ≥ T. T falls linearly from 0.70 at 280k to 0.30 at 400k, and rises by 0.15 × P(debugging), using Jev's own phase distribution rather than its top pick.Compaction runs as $.session.compact({ instructions }), which is the same path as /compact <instructions>. The instructions name the latest prompt and the phase, because Jev writes no text.
Requires Claude Code 2.1.274+ (function hooks are early access) and an OpenRouter key.
What leaves the machine. Each gate call sends OpenRouter (and TypeSafe behind it) your last three prompts, up to 600 characters each, the last answer, up to 1,500 characters, and the key argument of the last 15 tool calls: a bash command, file path, URL, or search query, up to 120 characters each. Tool output is never sent. Before sending, redact() in src/decide.ts masks credential-shaped text:
*_KEY=, *TOKEN=, --password=, "api_key": "…")Bearer headerssk-…, ghp_… and AKIA… key shapesRedaction is pattern-based and biased toward over-redacting. It is not a guarantee, so don't install this where prompts must not reach a third party. Shadow mode sends the same state as live mode.
# 1. Enable function hooks wherever Claude Code runs (~/.claude/settings.json)
# { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
# 2. Key in the shell environment, never in settings or the repo
export OPENROUTER_API_KEY=sk-or-...
# 3. Install from this folder as a local marketplace
claude plugin marketplace add ./plugins/jev-compact-gate
claude plugin install jev-compact-gate@jev-compact-gate
# Or load it for one session without installing
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./plugins/jev-compact-gate
An installed plugin is a snapshot in ~/.claude/plugins/cache/. After editing the source, bump version in .claude-plugin/plugin.json and marketplace.json, then run claude plugin marketplace update jev-compact-gate && claude plugin update jev-compact-gate@jev-compact-gate. An unchanged version skips the update.
Set values with /plugin configure jev-compact-gate@jev-compact-gate, or under pluginConfigs["jev-compact-gate@jev-compact-gate"].options in settings (jev-compact-gate@inline when loaded with --plugin-dir). Unset options take these defaults:
| Option | Default | Meaning |
|---|---|---|
mode | shadow | shadow logs every verdict and never compacts; live acts |
floorTokens | 280000 | Below this, the gate never asks |
ceilingTokens | 400000 | At or above this, it compacts without asking |
boundaryMax | 0.7 | P(at_boundary) required at the floor |
boundaryMin | 0.3 | P(at_boundary) required just below the ceiling |
verbatimVeto | 0.5 | P(needs_verbatim_recent) at which the gate holds |
debugPenalty | 0.15 | Threshold raise, weighted by P(debugging) |
cooldownTurns | 3 | Turns after a compaction before the gate may act again |
timeoutMs | 5000 | A slower Jev call holds |
model | typesafe/jev-1.13 | Pinned release; ~typesafe/jev-latest tracks the newest |
The band only means something on a model with a window above ceilingTokens (a 1M-context model). On a 200k window the gate logs once and stands aside.
Every in-band turn writes one row to ~/.claude/jev-compact-gate/<session-id>.jsonl, rotating to <session-id>.1.jsonl, .2, … every 200 rows. $.fs.write rewrites whole files, so rotation bounds each write:
{"tokens":312000,"urgency":0.27,"verdict":"compact","reason":"seam","pBoundary":0.96,"pVerbatim":0.13,
"phase":"wrapping_up","pDebugging":0.01,"threshold":0.59,"jevMs":736,"inputTokens":852,"cost":0.000036,"acted":false}
The rows keep Jev's raw judgments, so a threshold change is a re-read of the log, not another Jev call. Run in shadow for a week of normal sessions, then read the rows and ask, at each compact: would you have compacted there? Tune boundaryMax, boundaryMin, verbatimVeto and debugPenalty from those answers, then switch mode to live. Jev's probabilities are calibrated in aggregate, not certified per call, so fit the thresholds to your own sessions rather than taking round numbers on faith.
npm test # 67 node:test cases: band edges, veto, thresholds, malformed answers,
# redaction, cooldown, vetoed and rejected compactions, transport,
# timeout, settings parsing, log rotation
npm run typecheck # against the engine's declarations (run /plugin-types first)
uv run scripts/smoke.py # live Jev probe: "just committed" vs "mid-debug"
claude plugin validate .claude-plugin/plugin.json --strict
The live probe separates the two canned states cleanly:
| State | at_boundary | needs_verbatim_recent | phase |
|---|---|---|---|
| Just committed | 0.96 | 0.13 | wrapping_up (0.94) |
| Mid-debug | 0.05 | 0.67 | debugging (0.99) |
For a hermetic end-to-end run that keeps your user hooks and installed plugins out and writes no transcript, use this. Pass a --settings file that sets pluginConfigs["jev-compact-gate@inline"].options with a low floorTokens:
env -u ANTHROPIC_API_KEY CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Reply: ready" \
--setting-sources project,local --no-session-persistence --debug \
--plugin-dir ./plugins/jev-compact-gate --settings ./gate-smoke-settings.json
These were learned building against 2.1.283. Early access means they may move.
hooks/hooks.json → { "modules": ["./<file>.ts"] }, with one module per plugin. Without it the plugin loads and its hooks silently never do.setTimeout. Waits go through $.clock.sleep(ms, { signal }). AbortController and AbortSignal.any exist.$ (fetch, compact) do not count, but $.clock waits do. Honor next.signal, which aborts when the dispatch is abandoned.turn.complete. $.session.compact() works in interactive sessions. It was verified: the summary ran inside the dispatch in about 12s, and the transcript showed "Conversation compacted". Headless sessions (-p, SDK) refuse it with "not available in a headless session yet"; the gate catches that, logs it, and holds.--safe-mode disables every non-builtin hooks module, so use --setting-sources to isolate a test run instead.$.env.get takes string literals only. claude plugin validate lists every variable the module reads..ts extension. The only bare import allowed is 'claude-code'./plugin-types writes the engine's declarations to .claude/types/. Regenerate them after every Claude Code upgrade and re-check the session.usage, session.compact and turn.complete shapes.jev-compact-gate/
.claude-plugin/
plugin.json # Manifest and userConfig
marketplace.json # Local marketplace (source "./")
hooks/
hooks.json # Names the hooks module
jev-compact-gate.ts # Adapter: the engine's $ → src/run.ts Io
src/
decide.ts # Band gate, thresholds, verdict, Jev state + questions (pure)
run.ts # Turn loop, System One request/response, timeout
tests/
decide.test.ts # Policy and state construction
run.test.ts # Turn loop over a fake Io, transport, timeout
scripts/
smoke.py # Live Jev probe
INDEX.md # Folder holdings
README.md # This file
/llms.txt)hooks/jev-compact-gate.ts 86 lines1/**
2 * Claude Code function hook: Jev decides when the session compacts.
3 *
4 * A thin adapter from the engine's `$` onto `src/run.ts`'s `Io`. Verdicts
5 * log to ~/.claude/jev-compact-gate/<session>[.N].jsonl in every mode.
6 */
7
8import type { Register } from 'claude-code';
9
10import type { Message } from '../src/decide.ts';
11import {
12 appendRow,
13 buildJevRequest,
14 newMemo,
15 onTurn,
16 parseJevResponse,
17 resolveSettings,
18 withTimeout,
19 type LogRow,
20} from '../src/run.ts';
21
22export const register: Register = (on, options) => {
23 const cfg = resolveSettings(options);
24 const memo = newMemo();
25 const logCursor = { chunk: 0 };
26
27 on('turn.complete', async ($, event, next) => {
28 await onTurn(
29 event,
30 {
31 usage: async () => {
32 const { context } = await $.session.usage();
33 return { tokens: context.tokens, window: context.window, percent: context.percent };
34 },
35 messages: async () => (await $.session.messages()) as readonly Message[],
36 ask: async (state, questions) => {
37 const apiKey = await $.env.get('OPENROUTER_API_KEY');
38 if (!apiKey) throw new Error('OPENROUTER_API_KEY is unset');
39 const { url, init } = buildJevRequest(apiKey, cfg.model, state, questions);
40 const response = await withTimeout($.http.fetch(url, init), cfg.timeoutMs, (ms, signal) =>
41 $.clock.sleep(ms, { signal: AbortSignal.any([signal, next.signal]) }),
42 );
43 return parseJevResponse(response.status, response.text);
44 },
45 compact: async (instructions) => {
46 if (next.signal.aborted) return { skip: 'turn.complete dispatch abandoned' };
47 const result = await $.session.compact(instructions ? { instructions } : undefined);
48 return { skip: result.skip };
49 },
50 log: async (row: LogRow) => {
51 const home = await $.env.get('HOME');
52 if (!home) return;
53 await appendRow(
54 { exists: (p) => $.fs.exists(p), read: (p) => $.fs.read(p), write: (p, text) => $.fs.write(p, text) },
55 `${home}/.claude/jev-compact-gate`,
56 await $.session.id(),
57 row,
58 logCursor,
59 );
60 if (row.acted) {
61 $.ui.toast(
62 `jev-compact-gate: compacting at ${Math.round((row.tokens ?? 0) / 1000)}k (${row.gate === 'ceiling' ? 'ceiling' : `seam ${row.pBoundary?.toFixed(2)} ≥ ${row.threshold?.toFixed(2)}`})`,
63 );
64 }
65 if (row.error) $.ui.log(`jev-compact-gate: ${row.error}`);
66 },
67 now: () => Date.now(),
68 },
69 cfg,
70 memo,
71 ).catch((error: unknown) => {
72 $.ui.log(`jev-compact-gate: skipped (${error instanceof Error ? error.message : String(error)})`);
73 });
74 return next(event);
75 });
76
77 // Compactions the gate did not start (native threshold, /compact) restart its turn count too.
78 on('session.compact', async ($, event, next) => {
79 const result = await next(event);
80 if (event.agentId === undefined && event.trigger !== 'precompute' && !result.skip) {
81 memo.lastCompactTurn = memo.turn;
82 }
83 return result;
84 });
85};
86src/decide.ts 256 lines1/**
2 * Pure decision core for the Jev compaction gate: no I/O, no engine types.
3 *
4 * The hook asks `gate()` where the session sits in the token band, sends
5 * `buildState()` + `QUESTIONS` to Jev inside it, and acts on `decide()`.
6 */
7
8export type ToolUse = {
9 tool_use_id: string;
10 tool: string;
11 input: Record<string, unknown>;
12 text?: string;
13 isError?: true;
14};
15
16/** The subset of Claude Code's `SessionMessage` the gate reads. */
17export type Message = {
18 role: 'user' | 'assistant';
19 text: string;
20 toolUses: ToolUse[];
21 toolResults?: { tool_use_id: string; text: string; isError: boolean }[];
22};
23
24export type Config = {
25 floorTokens: number;
26 ceilingTokens: number;
27 /** P(at_boundary) required to compact at the floor. */
28 boundaryMax: number;
29 /** P(at_boundary) required to compact just below the ceiling. */
30 boundaryMin: number;
31 /** P(needs_verbatim_recent) at or above which the gate holds, whatever the boundary. */
32 verbatimVeto: number;
33 /** Added to the boundary threshold, weighted by P(phase = debugging). */
34 debugPenalty: number;
35};
36
37export const DEFAULTS: Config = {
38 floorTokens: 280_000,
39 ceilingTokens: 400_000,
40 boundaryMax: 0.7,
41 boundaryMin: 0.3,
42 verbatimVeto: 0.5,
43 debugPenalty: 0.15,
44};
45
46export type Phase =
47 | 'exploring'
48 | 'implementing'
49 | 'debugging'
50 | 'verifying'
51 | 'wrapping_up'
52 | 'conversing';
53
54export const QUESTIONS = {
55 at_boundary: {
56 type: 'noul',
57 instructions: 'Has a unit of work just closed in this coding-agent session?',
58 criteria: {
59 true: 'The last turn completed a deliverable—a commit, passing tests, a finished answer or report, or an approved plan—and the next request is likely to start new work.',
60 false:
61 'Work is mid-flight: a bug is still being chased, an edit series is incomplete, tests are failing, or the assistant just asked a question whose answer depends on recent detail.',
62 },
63 },
64 needs_verbatim_recent: {
65 type: 'noul',
66 instructions:
67 "Would the agent's next step need exact text from recent tool output that a summary would likely lose?",
68 criteria: {
69 true: 'The next step depends on exact error messages, file contents, diffs, stack traces, or command output from the last few tool calls.',
70 false:
71 'The next step can proceed from a summary of what was done; exact recent tool output is no longer needed.',
72 },
73 },
74 phase: {
75 type: 'choice',
76 instructions: 'Which phase is the session in right now?',
77 criteria: {
78 exploring: 'Reading code or docs to understand a problem.',
79 implementing: 'Writing or editing code toward a known design.',
80 debugging: 'Chasing a failure whose cause is not yet known.',
81 verifying: 'Running tests or checks on finished work.',
82 wrapping_up: 'Committing, documenting, or summarizing finished work.',
83 conversing: 'Discussing ideas with the user; no active code work.',
84 } satisfies Record<Phase, string>,
85 },
86} as const;
87
88export type Gate =
89 | { action: 'skip'; reason: 'no-usage' | 'small-window' | 'below-floor' }
90 | { action: 'ceiling' }
91 | { action: 'ask'; urgency: number };
92
93/** Where the session sits relative to the band; `urgency` runs 0 at the floor to 1 at the ceiling. */
94export function gate(tokens: number | undefined, window: number, cfg: Config): Gate {
95 if (tokens === undefined) return { action: 'skip', reason: 'no-usage' };
96 if (window < cfg.ceilingTokens) return { action: 'skip', reason: 'small-window' };
97 if (tokens < cfg.floorTokens) return { action: 'skip', reason: 'below-floor' };
98 if (tokens >= cfg.ceilingTokens) return { action: 'ceiling' };
99 return { action: 'ask', urgency: (tokens - cfg.floorTokens) / (cfg.ceilingTokens - cfg.floorTokens) };
100}
101
102/**
103 * The P(at_boundary) a compaction must reach: strict near the floor, lenient
104 * near the ceiling, raised in proportion to how likely the session is debugging.
105 */
106export function threshold(urgency: number, pDebugging: number, cfg: Config): number {
107 const base = cfg.boundaryMax - (cfg.boundaryMax - cfg.boundaryMin) * urgency;
108 return Math.min(1, base + cfg.debugPenalty * pDebugging);
109}
110
111export type Verdict = {
112 action: 'compact' | 'hold';
113 reason: 'seam' | 'needs-verbatim' | 'mid-work' | 'malformed';
114 pBoundary?: number;
115 pVerbatim?: number;
116 phase?: string;
117 pDebugging?: number;
118 threshold?: number;
119};
120
121const probability = (value: unknown): number | undefined =>
122 typeof value === 'number' && value >= 0 && value <= 1 ? value : undefined;
123
124function field(answers: unknown, key: string, prop: string): unknown {
125 if (!answers || typeof answers !== 'object') return undefined;
126 const answer = (answers as Record<string, unknown>)[key];
127 return answer && typeof answer === 'object' ? (answer as Record<string, unknown>)[prop] : undefined;
128}
129
130/**
131 * P(phase = debugging) from the Choice's distribution; when the distribution
132 * is absent, 1 or 0 by the chosen option. `null` when present but unreadable.
133 */
134function debuggingProbability(answers: unknown, phase: string): number | null {
135 const probabilities = field(answers, 'phase', 'probabilities');
136 if (!probabilities || typeof probabilities !== 'object') return phase === 'debugging' ? 1 : 0;
137 const value = (probabilities as Record<string, unknown>).debugging;
138 if (value === undefined) return phase === 'debugging' ? 1 : 0;
139 return probability(value) ?? null;
140}
141
142/**
143 * Turns Jev's `answers` into a verdict; anything unreadable holds.
144 *
145 * Needing exact recent output is a hard condition (a veto); being at a seam
146 * is the preference urgency relaxes. Each Noul keeps its own threshold.
147 */
148export function decide(answers: unknown, urgency: number, cfg: Config): Verdict {
149 const pBoundary = probability(field(answers, 'at_boundary', 'noul'));
150 const pVerbatim = probability(field(answers, 'needs_verbatim_recent', 'noul'));
151 const phase = field(answers, 'phase', 'choice');
152 if (pBoundary === undefined || pVerbatim === undefined || typeof phase !== 'string') {
153 return { action: 'hold', reason: 'malformed' };
154 }
155 const pDebugging = debuggingProbability(answers, phase);
156 if (pDebugging === null) return { action: 'hold', reason: 'malformed' };
157 const t = threshold(urgency, pDebugging, cfg);
158 const judged = { pBoundary, pVerbatim, phase, pDebugging, threshold: t };
159 if (pVerbatim >= cfg.verbatimVeto) return { action: 'hold', reason: 'needs-verbatim', ...judged };
160 if (pBoundary < t) return { action: 'hold', reason: 'mid-work', ...judged };
161 return { action: 'compact', reason: 'seam', ...judged };
162}
163
164const PROMPTS = 3;
165const PROMPT_CHARS = 600;
166const ANSWER_CHARS = 1500;
167const TOOL_CALLS = 15;
168const ARG_CHARS = 120;
169const KEY_ARGS = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt'];
170
171const clip = (text: string, max: number): string =>
172 text.length > max ? `${text.slice(0, max)}…` : text;
173
174const stripReminders = (text: string): string =>
175 text.replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/g, '').trim();
176
177const REDACTED = '[redacted]';
178const SECRET_NAME = String.raw`[A-Za-z0-9_]*(?:key|token|secret|passw(?:or)?d|pwd)[A-Za-z0-9_]*`;
179const SECRET_PATTERNS: [RegExp, string][] = [
180 [new RegExp(String.raw`("${SECRET_NAME}"\s*:\s*)"[^"]*"`, 'gi'), `$1${REDACTED}`],
181 [new RegExp(String.raw`\b(${SECRET_NAME})=("[^"]*"|'[^']*'|\S+)`, 'gi'), `$1=${REDACTED}`],
182 [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${REDACTED}`],
183 [/\bsk-[A-Za-z0-9_-]{16,}/g, REDACTED],
184 [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED],
185 [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
186];
187
188/**
189 * Masks credential-shaped text before the state leaves the machine: named
190 * secret assignments (`*_KEY=`, `--password=`, `"token": "…"`), bearer
191 * headers, and OpenAI/OpenRouter, GitHub and AWS key shapes. Best effort,
192 * biased to over-redact; Jev judges work boundaries, not values.
193 */
194export function redact(text: string): string {
195 return SECRET_PATTERNS.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
196}
197
198/** One line per tool call: its key argument and outcome, never its output. */
199export function toolLine(use: ToolUse): string {
200 const key = KEY_ARGS.find((k) => typeof use.input[k] === 'string');
201 const value = key ? clip(redact(String(use.input[key]).replace(/\s+/g, ' ')), ARG_CHARS) : '';
202 const arg = key ? ` ${key}=${value.includes(' ') ? JSON.stringify(value) : value}` : '';
203 const outcome =
204 use.text === undefined
205 ? 'pending'
206 : use.isError
207 ? `error: ${clip(redact(use.text.split('\n', 1)[0] ?? ''), ARG_CHARS)}`
208 : `ok ${use.text.length}ch`;
209 return `${use.tool}${arg} → ${outcome}`;
210}
211
212export type JevState = {
213 recent_prompts: string[];
214 last_answer: string;
215 recent_activity: string[];
216};
217
218/**
219 * The bounded, redacted view of the session Jev judges: recent prompts, the
220 * last answer, recent tool calls. Token counts stay in code, where the band is
221 * decided; they tell Jev nothing about whether work just closed.
222 */
223export function buildState(input: { messages: readonly Message[]; answer: string }): JevState {
224 const prompts: string[] = [];
225 const calls: ToolUse[] = [];
226 for (const message of input.messages.toReversed()) {
227 if (prompts.length === PROMPTS && calls.length === TOOL_CALLS) break;
228 if (message.role === 'user' && prompts.length < PROMPTS) {
229 const text = stripReminders(message.text);
230 if (text) prompts.unshift(clip(redact(text), PROMPT_CHARS));
231 }
232 const room = TOOL_CALLS - calls.length;
233 if (room > 0) calls.unshift(...(message.toolUses ?? []).slice(-room));
234 }
235 return {
236 recent_prompts: prompts,
237 last_answer: clip(redact(input.answer), ANSWER_CHARS),
238 recent_activity: calls.map(toolLine),
239 };
240}
241
242/** Rough token count, the chars/3.2 heuristic `hooks/precompact-handoff.py` uses. */
243export function estimateTokens(value: unknown): number {
244 return Math.ceil(JSON.stringify(value).length / 3.2);
245}
246
247const GOAL_CHARS = 300;
248
249/** `/compact` instructions from what the gate knows; Jev itself writes no text. */
250export function buildInstructions(recentPrompts: readonly string[], phase: string | undefined): string {
251 const last = recentPrompts.at(-1);
252 if (!last) return '';
253 const phaseNote = phase ? ` Current phase: ${phase}.` : '';
254 return `Preserve the active goal: ${clip(last, GOAL_CHARS)}.${phaseNote}`;
255}
256src/run.ts 280 lines1/**
2 * The gate's turn loop, over an injected `Io` so it runs without the engine.
3 * `hooks/jev-compact-gate.ts` maps Claude Code's `$` onto `Io`.
4 */
5
6import {
7 buildInstructions,
8 buildState,
9 decide,
10 DEFAULTS,
11 gate,
12 QUESTIONS,
13 type Config,
14 type Message,
15 type Verdict,
16} from './decide.ts';
17
18/** Jev's typed answers, with the usage OpenRouter reports beside them. */
19export type JevReply = { answers: unknown; inputTokens?: number; cost?: number };
20
21export type Settings = Config & {
22 /** `shadow` logs every verdict and never compacts; `live` acts on them. */
23 mode: 'shadow' | 'live';
24 /** Main-loop turns after the gate's own compaction before it may act again. */
25 cooldownTurns: number;
26 timeoutMs: number;
27 model: string;
28};
29
30export const SETTINGS: Settings = {
31 ...DEFAULTS,
32 mode: 'shadow',
33 cooldownTurns: 3,
34 timeoutMs: 5000,
35 model: 'typesafe/jev-1.13',
36};
37
38const NUMERIC_SETTINGS = [
39 'floorTokens',
40 'ceilingTokens',
41 'boundaryMax',
42 'boundaryMin',
43 'verbatimVeto',
44 'debugPenalty',
45 'cooldownTurns',
46 'timeoutMs',
47] as const satisfies readonly (keyof Settings)[];
48
49function finite(value: unknown): number | undefined {
50 if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
51 if (typeof value === 'string' && value.trim() !== '') {
52 const parsed = Number(value);
53 return Number.isFinite(parsed) ? parsed : undefined;
54 }
55 return undefined;
56}
57
58/**
59 * The plugin's `userConfig` values over the defaults. Numbers typed into
60 * settings.json as strings still count; anything unreadable takes the default.
61 */
62export function resolveSettings(options: Readonly<Record<string, unknown>>): Settings {
63 const settings: Settings = { ...SETTINGS };
64 for (const key of NUMERIC_SETTINGS) settings[key] = finite(options[key]) ?? SETTINGS[key];
65 if (options.mode === 'live') settings.mode = 'live';
66 if (typeof options.model === 'string' && options.model) settings.model = options.model;
67 return settings;
68}
69
70export type TurnEvent = {
71 turnId: string;
72 answer: string;
73 reason: string;
74 isAborted: boolean;
75 agentId?: string;
76};
77
78export type Io = {
79 usage: () => Promise<{ tokens?: number; window: number; percent?: number }>;
80 messages: () => Promise<readonly Message[]>;
81 /** Resolves with Jev's `answers`; rejects on any transport or protocol fault. */
82 ask: (state: unknown, questions: unknown) => Promise<JevReply>;
83 compact: (instructions: string) => Promise<{ skip?: string }>;
84 log: (row: LogRow) => Promise<void>;
85 now: () => number;
86};
87
88/** What the gate remembers across one session's turns. */
89export type Memo = {
90 busy: boolean;
91 turn: number;
92 lastCompactTurn?: number;
93 smallWindowLogged: boolean;
94};
95
96export const newMemo = (): Memo => ({ busy: false, turn: 0, smallWindowLogged: false });
97
98export type LogRow = {
99 ts: number;
100 turnId: string;
101 mode: Settings['mode'];
102 tokens?: number;
103 window: number;
104 gate: 'small-window' | 'cooldown' | 'ceiling' | 'band';
105 urgency?: number;
106 verdict?: 'compact' | 'hold';
107 pBoundary?: number;
108 pVerbatim?: number;
109 phase?: string;
110 reason?: Verdict['reason'];
111 pDebugging?: number;
112 inputTokens?: number;
113 cost?: number;
114 threshold?: number;
115 jevMs?: number;
116 acted: boolean;
117 skipped?: string;
118 error?: string;
119};
120
121const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
122
123/** One main-loop turn: place it in the band, ask Jev inside it, compact when the verdict and mode say so. */
124export async function onTurn(event: TurnEvent, io: Io, cfg: Settings, memo: Memo): Promise<void> {
125 if (event.agentId !== undefined || event.isAborted || event.reason !== 'answer' || memo.busy) return;
126 memo.turn++;
127 memo.busy = true;
128 try {
129 const usage = await io.usage();
130 const g = gate(usage.tokens, usage.window, cfg);
131 const base = { ts: io.now(), turnId: event.turnId, mode: cfg.mode, tokens: usage.tokens, window: usage.window };
132
133 if (g.action === 'skip') {
134 if (g.reason === 'small-window' && !memo.smallWindowLogged) {
135 memo.smallWindowLogged = true;
136 await io.log({ ...base, gate: 'small-window', acted: false });
137 }
138 return;
139 }
140 if (memo.lastCompactTurn !== undefined && memo.turn - memo.lastCompactTurn < cfg.cooldownTurns) {
141 await io.log({ ...base, gate: 'cooldown', acted: false });
142 return;
143 }
144
145 const state = buildState({ messages: await io.messages(), answer: event.answer });
146
147 let row: LogRow;
148 if (g.action === 'ceiling') {
149 row = { ...base, gate: 'ceiling', verdict: 'compact', acted: false };
150 } else {
151 const started = io.now();
152 try {
153 const reply = await io.ask(state, QUESTIONS);
154 const verdict = decide(reply.answers, g.urgency, cfg);
155 row = {
156 ...base,
157 gate: 'band',
158 urgency: g.urgency,
159 verdict: verdict.action,
160 reason: verdict.reason,
161 pBoundary: verdict.pBoundary,
162 pVerbatim: verdict.pVerbatim,
163 phase: verdict.phase,
164 pDebugging: verdict.pDebugging,
165 threshold: verdict.threshold,
166 jevMs: io.now() - started,
167 inputTokens: reply.inputTokens,
168 cost: reply.cost,
169 acted: false,
170 error: verdict.reason === 'malformed' ? 'malformed answers' : undefined,
171 };
172 } catch (error) {
173 row = { ...base, gate: 'band', urgency: g.urgency, verdict: 'hold', jevMs: io.now() - started, acted: false, error: message(error) };
174 }
175 }
176
177 if (row.verdict === 'compact' && cfg.mode === 'live') {
178 try {
179 const result = await io.compact(buildInstructions(state.recent_prompts, row.phase));
180 if (result.skip) row.skipped = result.skip;
181 else {
182 row.acted = true;
183 memo.lastCompactTurn = memo.turn;
184 }
185 } catch (error) {
186 row.error = message(error);
187 }
188 }
189 await io.log(row);
190 } finally {
191 memo.busy = false;
192 }
193}
194
195/** OpenRouter's System One API: TypeSafe's own request and response contract, billed to the OpenRouter key. */
196const SYSTEM_ONE_URL = 'https://openrouter.ai/api/v1/systemone';
197
198export function buildJevRequest(apiKey: string, model: string, state: unknown, questions: unknown) {
199 return {
200 url: SYSTEM_ONE_URL,
201 init: {
202 method: 'POST',
203 headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
204 body: JSON.stringify({ model, state, questions }),
205 },
206 };
207}
208
209export function parseJevResponse(status: number, text: string): JevReply {
210 if (status < 200 || status >= 300) throw new Error(`Jev HTTP ${status}: ${text.slice(0, 200)}`);
211 let body: { answers?: unknown; usage?: { input_tokens?: unknown; cost?: unknown } };
212 try {
213 body = JSON.parse(text);
214 } catch {
215 throw new Error(`Jev returned invalid JSON: ${text.slice(0, 200)}`);
216 }
217 if (!body || typeof body !== 'object' || !body.answers || typeof body.answers !== 'object') {
218 throw new Error('Jev response has no answers');
219 }
220 const reply: JevReply = { answers: body.answers };
221 if (typeof body.usage?.input_tokens === 'number') reply.inputTokens = body.usage.input_tokens;
222 if (typeof body.usage?.cost === 'number') reply.cost = body.usage.cost;
223 return reply;
224}
225
226export type Sleep = (ms: number, signal: AbortSignal) => Promise<void>;
227
228/**
229 * Rejects when `promise` has not settled within `ms`. The hook sandbox has no
230 * `setTimeout`, so the wait is injected: `$.clock.sleep` there, a timer in tests.
231 */
232export async function withTimeout<T>(promise: Promise<T>, ms: number, sleep: Sleep): Promise<T> {
233 const controller = new AbortController();
234 const timeout = sleep(ms, controller.signal).then((): never => {
235 throw new Error(`Jev timeout after ${ms}ms`);
236 });
237 timeout.catch(() => {});
238 try {
239 return await Promise.race([promise, timeout]);
240 } finally {
241 controller.abort();
242 }
243}
244
245/** The whole-file `$.fs` calls the log needs; the engine offers no append. */
246export type LogFs = {
247 exists: (path: string) => Promise<boolean>;
248 read: (path: string) => Promise<string>;
249 write: (path: string, text: string) => Promise<void>;
250};
251
252/** Rows per log file: bounds each read-modify-write, since `$.fs.write` rewrites whole files. */
253export const LOG_ROWS_PER_FILE = 200;
254
255const rowCount = (text: string): number => text.split('\n').length - 1;
256
257/**
258 * Appends one JSONL row to `<dir>/<session>.jsonl`, rotating to
259 * `<session>.1.jsonl`, `.2`, … as each fills. `cursor` remembers the open
260 * file; a fresh one (after a reload) walks past full files instead of growing them.
261 */
262export async function appendRow(
263 fs: LogFs,
264 dir: string,
265 sessionId: string,
266 row: unknown,
267 cursor: { chunk: number },
268): Promise<string> {
269 for (;;) {
270 const path = `${dir}/${sessionId}${cursor.chunk ? `.${cursor.chunk}` : ''}.jsonl`;
271 const prior = (await fs.exists(path)) ? await fs.read(path) : '';
272 if (rowCount(prior) >= LOG_ROWS_PER_FILE) {
273 cursor.chunk++;
274 continue;
275 }
276 await fs.write(path, `${prior}${JSON.stringify(row)}\n`);
277 return path;
278 }
279}
280