SLOPSHOPPER

jev-compact-gate

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

newtoastnetwork
A shopper browsing a rack in a slop shop
README

jev-compact-gate

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


Why This Plugin Exists

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.


How It Works

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.

QuestionTypeJudges
at_boundaryNoulA deliverable just closed (commit, green tests, finished answer, approved plan) and the next request is likely new work
needs_verbatim_recentNoulThe next step depends on exact recent error text, file contents, diffs, or command output
phaseChoiceexploring · 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.

  • Veto. P(needs_verbatim_recent) ≥ 0.5 holds, whatever the boundary says.
  • Threshold. Compact when 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.
  • Faults hold. A timeout, HTTP error, missing key, or malformed answer holds, and the reason is logged. The 400k ceiling never depends on Jev, so a dead Jev degrades to "compact at 400k."
  • Cooldown. After a compaction, the gate waits three main-loop turns before it can act again. Native and manual compactions reset its count too.

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.


Install

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:

  • named secret assignments (*_KEY=, *TOKEN=, --password=, "api_key": "…")
  • Bearer headers
  • sk-…, ghp_… and AKIA… key shapes

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


Configuration

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:

OptionDefaultMeaning
modeshadowshadow logs every verdict and never compacts; live acts
floorTokens280000Below this, the gate never asks
ceilingTokens400000At or above this, it compacts without asking
boundaryMax0.7P(at_boundary) required at the floor
boundaryMin0.3P(at_boundary) required just below the ceiling
verbatimVeto0.5P(needs_verbatim_recent) at which the gate holds
debugPenalty0.15Threshold raise, weighted by P(debugging)
cooldownTurns3Turns after a compaction before the gate may act again
timeoutMs5000A slower Jev call holds
modeltypesafe/jev-1.13Pinned 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.


Shadow Mode and Tuning

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.


Verification

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:

Stateat_boundaryneeds_verbatim_recentphase
Just committed0.960.13wrapping_up (0.94)
Mid-debug0.050.67debugging (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

Function-Hook Notes

These were learned building against 2.1.283. Early access means they may move.

  • Registration. A plugin's hooks module is found only through hooks/hooks.json → { "modules": ["./<file>.ts"] }, with one module per plugin. Without it the plugin loads and its hooks silently never do.
  • Sandbox. The module runs with no Node and no DOM, and there is no setTimeout. Waits go through $.clock.sleep(ms, { signal }). AbortController and AbortSignal.any exist.
  • Budget. A hook gets 10s of its own time. Calls in flight on $ (fetch, compact) do not count, but $.clock waits do. Honor next.signal, which aborts when the dispatch is abandoned.
  • Compaction from 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. --safe-mode disables every non-builtin hooks module, so use --setting-sources to isolate a test run instead.
  • Environment variables. $.env.get takes string literals only. claude plugin validate lists every variable the module reads.
  • Imports. Relative imports resolve with or without the .ts extension. The only bare import allowed is 'claude-code'.
  • Types. /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.

Structure

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

References

Source 3 files
hooks/jev-compact-gate.ts 86 lines
1/**
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};
86
src/decide.ts 256 lines
1/**
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}
256
src/run.ts 280 lines
1/**
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