Solo PM conveyor for Claude Code: orchestrator agents, lane playbooks, onboard and resume commands.

Claude-facing half of Claude Lane Stack: PM agents, slash commands, and playbook skills.
Install from the repo marketplace (not by copying into ~/.claude/agents / ~/.claude/skills):
claude plugin marketplace add VKirill/claude-lane-stack
claude plugin install lane-stack@claude-lane-stack -y
./install.sh registers that GitHub marketplace with autoUpdate: true and still rsyncs the host runtime (bin/, board, writer profiles) to ~/.agents. Host files do not auto-update — rerun ./install.sh. Live checkout: LANE_INSTALL_LOCAL_MARKETPLACE=1 ./install.sh.
Skills are namespaced as /lane-stack:<skill>. Work cheat sheet: /lane-stack:info or /info. New app/service talk: /lane-stack:app-architect. Living docs: /lane-stack:docs-maintain. Fact corpus: /lane-stack:lane-memory. PM playbooks (orchestrator-lanes, orchestrator-workflow, info, app-architect) live only in this plugin + ~/.agents/pm-skills — they are not copied to the shared writer catalog.
hooks/lane-mods.ts 13 lines1import type { On, PluginOptions, Register } from 'claude-code'
2
3import { register as registerCompact } from './fast-jev.js'
4import { register as registerRouter } from './jev-router.js'
5import { register as registerWinnow } from './winnow.js'
6
7/** One function-hook module per plugin. Compact, winnow and jev-router register here. */
8export const register: Register = (on: On, options: PluginOptions) => {
9 registerCompact(on, options)
10 registerWinnow(on, options)
11 registerRouter(on, options)
12}
13hooks/fast-jev.ts 312 lines1// Vendored from tamaratran/fast-jev-compaction (MIT). session.compact replaces /compact.
2import type {
3 On,
4 PluginOptions,
5 Register,
6 SessionMessage,
7 ToolResultSummary,
8 ToolUseSummary,
9 TurnCompleteInput,
10} from 'claude-code';
11
12import { compact, reductionRatio, resolveOptions } from '../fast-jev/src/compact.js';
13import { buildJevRequest, DEFAULT_MODEL, parseJevResponse } from '../fast-jev/src/request.js';
14import type {
15 CompactOptions,
16 CompactResult,
17 JevAsker,
18 Message,
19 ToolResult,
20 ToolUse,
21} from '../fast-jev/src/types.js';
22
23const HOOK_DEFAULTS = {
24 compactAtPercent: 60,
25 minReductionRatio: 0.25,
26 model: DEFAULT_MODEL,
27};
28
29export type HookFetchInit = {
30 method?: string;
31 headers?: Record<string, string>;
32 body?: string;
33};
34
35export type HookFetchResponse = {
36 status: number;
37 ok: boolean;
38 text: string;
39};
40
41/** The shape of `$.http.fetch`, so the hook can be driven without an engine. */
42export type HookFetch = (url: string, init?: HookFetchInit) => Promise<HookFetchResponse>;
43
44export type HookConfig = CompactOptions & {
45 apiKey?: string;
46 compactAtPercent: number;
47 minReductionRatio: number;
48 model: string;
49};
50
51function optionNumber(options: PluginOptions, key: string, fallback: number): number {
52 const value = options[key];
53 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
54}
55
56function optionString(options: PluginOptions, key: string): string | undefined {
57 const value = options[key];
58 return typeof value === 'string' && value.length > 0 ? value : undefined;
59}
60
61/** Reads the plugin's `userConfig` values; anything missing takes the defaults. */
62export function resolveHookConfig(options: PluginOptions): HookConfig {
63 const numbers: Partial<Omit<CompactOptions, 'goal'>> = {};
64 for (const key of [
65 'keepThreshold',
66 'preserveRecentMessages',
67 'maxStateTokens',
68 'maxRequestTokens',
69 'truncateHeadChars',
70 ] as const) {
71 const value = options[key];
72 if (typeof value === 'number' && Number.isFinite(value)) numbers[key] = value;
73 }
74 const config: HookConfig = {
75 ...numbers,
76 compactAtPercent: optionNumber(options, 'compactAtPercent', HOOK_DEFAULTS.compactAtPercent),
77 minReductionRatio: optionNumber(
78 options,
79 'minReductionRatio',
80 HOOK_DEFAULTS.minReductionRatio,
81 ),
82 model: optionString(options, 'model') ?? HOOK_DEFAULTS.model,
83 };
84 const apiKey = optionString(options, 'apiKey');
85 if (apiKey) config.apiKey = apiKey;
86 const goal = optionString(options, 'goal');
87 if (goal) config.goal = goal;
88 return config;
89}
90
91/** A `JevAsker` over the engine's `$.http.fetch`. */
92export function jevAsker(fetchFn: HookFetch, apiKey: string, model: string): JevAsker {
93 return {
94 async ask(state, questions) {
95 const request = buildJevRequest({ apiKey, model }, state, questions);
96 const response = await fetchFn(request.url, {
97 method: request.method,
98 headers: request.headers,
99 body: request.body,
100 });
101 return parseJevResponse(response.status, response.ok, response.text);
102 },
103 };
104}
105
106function toolUseSummary(tool: ToolUse): ToolUseSummary {
107 const summary: ToolUseSummary = {
108 tool_use_id: tool.tool_use_id,
109 tool: tool.tool,
110 input: tool.input,
111 };
112 if (tool.text !== undefined) summary.text = tool.text;
113 if (tool.isError) summary.isError = true;
114 return summary;
115}
116
117function toolResultSummary(result: ToolResult): ToolResultSummary {
118 return {
119 tool_use_id: result.tool_use_id,
120 text: result.text,
121 isError: result.isError ?? false,
122 };
123}
124
125/**
126 * Maps the library's output back onto session messages. Whatever came back
127 * unchanged (a message, a tool use, a tool result) is the engine's own object,
128 * handle included; anything rebuilt is a fresh message without a handle, so the
129 * engine takes the edited content instead of its original.
130 */
131export function toSessionMessages(
132 input: readonly SessionMessage[],
133 output: readonly Message[],
134): SessionMessage[] {
135 const messages = new Map<Message, SessionMessage>();
136 const uses = new Map<ToolUse, ToolUseSummary>();
137 const results = new Map<ToolResult, ToolResultSummary>();
138 for (const message of input) {
139 messages.set(message, message);
140 for (const tool of message.toolUses) uses.set(tool, tool);
141 for (const result of message.toolResults ?? []) results.set(result, result);
142 }
143 return output.map((message) => {
144 const own = messages.get(message);
145 if (own) return own;
146 const rebuilt: SessionMessage = {
147 role: message.role,
148 text: message.text,
149 toolUses: message.toolUses.map((tool) => uses.get(tool) ?? toolUseSummary(tool)),
150 };
151 if (message.toolResults && message.toolResults.length > 0) {
152 rebuilt.toolResults = message.toolResults.map(
153 (result) => results.get(result) ?? toolResultSummary(result),
154 );
155 }
156 return rebuilt;
157 });
158}
159
160export type SessionCompaction = {
161 result: CompactResult;
162 messages: SessionMessage[];
163};
164
165/** Runs the library over a session transcript; throws when the key is missing or Jev fails. */
166export async function compactSession(
167 messages: readonly SessionMessage[],
168 config: HookConfig,
169 fetchFn: HookFetch,
170): Promise<SessionCompaction> {
171 if (!config.apiKey) throw new Error('TYPESAFE_API_KEY is not configured');
172 const result = await compact(messages, jevAsker(fetchFn, config.apiKey, config.model), config);
173 return { result, messages: toSessionMessages(messages, result.messages) };
174}
175
176function percent(ratio: number): string {
177 return `${Math.round(ratio * 100)}%`;
178}
179
180export function summarize(result: CompactResult): string {
181 const { stats } = result;
182 const parts = [
183 stats.kept > 0 ? `${stats.kept} kept` : '',
184 stats.resultsDropped > 0 ? `${stats.resultsDropped} results truncated` : '',
185 stats.callsDropped > 0 ? `${stats.callsDropped} call_dropped` : '',
186 stats.pinned > 0 ? `${stats.pinned} pinned` : '',
187 ].filter(Boolean);
188 return `${percent(reductionRatio(result))} reduction; ${
189 parts.join(', ') || 'no tool calls'
190 }; state ~${stats.stateTokens} tokens (${stats.stateStage}) in ${stats.requests} request(s)`;
191}
192
193const UI_LOG_MAX_CHARS = 4096;
194
195export function decisionLog(result: CompactResult): string {
196 return result.decisions
197 .filter((d) => d.reason !== 'pinned')
198 .map(
199 (d) =>
200 `${d.id}:${d.tool}:${d.action}/call=${d.keepCall.toFixed(2)}/result=${d.keepResult.toFixed(2)}`,
201 )
202 .join(' ');
203}
204
205export function decisionLogLines(
206 result: CompactResult,
207 maxChars: number = UI_LOG_MAX_CHARS,
208): string[] {
209 const entries = decisionLog(result).split(' ').filter(Boolean);
210 if (entries.length === 0) return ['decisions: (none)'];
211 const chunks: string[] = [];
212 let current = '';
213 for (const entry of entries) {
214 const next = current ? `${current} ${entry}` : entry;
215 if (current && next.length > maxChars - 24) {
216 chunks.push(current);
217 current = entry;
218 } else current = next;
219 }
220 chunks.push(current);
221 return chunks.map((chunk, index) =>
222 chunks.length === 1
223 ? `decisions: ${chunk}`
224 : `decisions (${index + 1}/${chunks.length}): ${chunk}`,
225 );
226}
227
228async function getApiKey(
229 $: {
230 env: { get: (name: string) => Promise<string | undefined> };
231 settings: { read: () => Promise<Readonly<Record<string, unknown>>> };
232 },
233 config: HookConfig,
234): Promise<string | undefined> {
235 if (config.apiKey) return config.apiKey;
236 const fromEnv = await $.env.get('TYPESAFE_API_KEY');
237 if (fromEnv) return fromEnv;
238 const settings = await $.settings.read();
239 const env = settings['env'];
240 if (env && typeof env === 'object') {
241 const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY'];
242 if (typeof value === 'string' && value) return value;
243 }
244 return undefined;
245}
246
247function notify(
248 $: {
249 ui: {
250 log: (text: string) => void;
251 toast: (text: string, options?: { timeoutMs?: number }) => void;
252 };
253 },
254 text: string,
255): void {
256 $.ui.log(text);
257 $.ui.toast(text, { timeoutMs: 15_000 });
258}
259
260export const register: Register = (on: On, options: PluginOptions) => {
261 const configured = resolveHookConfig(options);
262 let compacting = false;
263
264 on('session.compact', async ($, event, next) => {
265 try {
266 const config = { ...configured, apiKey: await getApiKey($, configured) };
267 const { result, messages } = await compactSession(event.messages, config, async (url, init) => {
268 const response = await $.http.fetch(url, init);
269 return { status: response.status, ok: response.ok, text: response.text };
270 });
271 for (const line of decisionLogLines(result)) $.ui.log(line);
272 if (reductionRatio(result) < config.minReductionRatio) {
273 notify(
274 $,
275 `fallback to built-in summary (below ${percent(config.minReductionRatio)} minimum: ${summarize(result)})`,
276 );
277 return next(event);
278 }
279 notify(
280 $,
281 `kept ${messages.length}/${event.messages.length} messages, no summary (${summarize(result)})`,
282 );
283 return { messages };
284 } catch (error) {
285 notify(
286 $,
287 `fallback to built-in summary (${error instanceof Error ? error.message : String(error)})`,
288 );
289 return next(event);
290 }
291 });
292
293 on('turn.complete', async ($, event: TurnCompleteInput, next) => {
294 if (compacting) return next(event);
295 try {
296 const { context } = await $.session.usage();
297 if ((context.percent ?? 0) < configured.compactAtPercent) return next(event);
298 compacting = true;
299 await $.session.compact();
300 } catch (error) {
301 $.ui.log(
302 `auto-compact skipped (${error instanceof Error ? error.message : String(error)})`,
303 );
304 } finally {
305 compacting = false;
306 }
307 return next(event);
308 });
309};
310
311export { resolveOptions };
312hooks/jev-router.ts 129 lines1import type { On, PluginOptions, Register, SessionMessage } from 'claude-code'
2
3import { buildJevRequest, parseJevResponse } from '../fast-jev/src/request.js'
4import {
5 ROUTE_QUESTIONS,
6 ROUTE_TIMEOUT_MS,
7 asSessionEffort,
8 claudeSessionEffort,
9 defaultRoute,
10 parseRoute,
11 routeEnabled,
12 type Route,
13 type SessionEffort,
14} from './jev-route-core.js'
15
16const RAW_TASK_MARKER = /^--- RAW TASK YAML \(verbatim\) ---\r?$/m
17
18function canonicalTask(text: string): string {
19 const marker = RAW_TASK_MARKER.exec(text)
20 return marker ? text.slice(marker.index + marker[0].length).trim() : text
21}
22
23function lastUser(messages: readonly SessionMessage[]): string {
24 for (let i = messages.length - 1; i >= 0; i--) {
25 const m = messages[i]
26 if (m.role !== 'user') continue
27 if (m.toolResults && m.toolResults.length > 0) continue
28 const text = m.text.trim()
29 if (text) return canonicalTask(text)
30 }
31 return ''
32}
33
34async function getApiKey($: {
35 env: { get: (name: string) => Promise<string | undefined> }
36 settings: { read: () => Promise<Readonly<Record<string, unknown>>> }
37}): Promise<string | undefined> {
38 const fromEnv = await $.env.get('TYPESAFE_API_KEY')
39 if (fromEnv) return fromEnv
40 const settings = await $.settings.read()
41 const env = settings['env']
42 if (env && typeof env === 'object') {
43 const value = (env as Record<string, unknown>)['TYPESAFE_API_KEY']
44 if (typeof value === 'string' && value) return value
45 }
46 return undefined
47}
48
49async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
50 let timer: ReturnType<typeof setTimeout> | undefined
51 try {
52 return await Promise.race([
53 promise,
54 new Promise<T>((_, reject) => {
55 timer = setTimeout(() => reject(new Error('timeout')), ms)
56 }),
57 ])
58 } finally {
59 if (timer !== undefined) clearTimeout(timer)
60 }
61}
62
63async function classify(
64 $: {
65 http: { fetch: (url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<{ status: number; ok: boolean; text: string }> }
66 env: { get: (name: string) => Promise<string | undefined> }
67 settings: { read: () => Promise<Readonly<Record<string, unknown>>> }
68 },
69 prompt: string,
70 current: SessionEffort,
71): Promise<Route> {
72 if (!prompt) return defaultRoute(current)
73 const apiKey = await getApiKey($)
74 if (!apiKey) return defaultRoute(current)
75 try {
76 const request = buildJevRequest({ apiKey }, { task: prompt }, ROUTE_QUESTIONS)
77 const response = await withTimeout(
78 $.http.fetch(request.url, { method: request.method, headers: request.headers, body: request.body }),
79 ROUTE_TIMEOUT_MS,
80 )
81 const parsed = parseJevResponse(response.status, response.ok, response.text)
82 return parseRoute(parsed.answers, current)
83 } catch {
84 return defaultRoute(current)
85 }
86}
87
88export const register: Register = (on: On, _options: PluginOptions) => {
89 const byTurn = new Map<string, Route>()
90 const byAgent = new Map<string, Route>()
91
92 on('agent.spawn', async ($, e, next) => {
93 if (e.fork) return next(e)
94 const off = await $.env.get('LANE_JEV_EFFORT')
95 if (!routeEnabled(off)) return next(e)
96 const route = await classify($, canonicalTask(e.prompt.trim()), 'medium')
97 if (route.reason === 'fail-open') return next(e)
98 const result = await next({ ...e, model: route.subagent })
99 if (result.agentId) byAgent.set(result.agentId, route)
100 return result
101 })
102
103 on('turn.step', async function* ($, e, next) {
104 const off = await $.env.get('LANE_JEV_EFFORT')
105 if (!routeEnabled(off)) return yield* next(e)
106 try {
107 if (e.agentId) {
108 const cached = byAgent.get(e.agentId)
109 if (!cached) return yield* next(e)
110 const nextEffort = claudeSessionEffort(cached.effort)
111 if (nextEffort === e.effort) return yield* next(e)
112 return yield* next({ ...e, effort: nextEffort })
113 }
114 let route = byTurn.get(e.turnId)
115 if (!route) {
116 if (e.index !== 0) return yield* next(e)
117 const messages = await $.session.messages()
118 route = await classify($, lastUser(messages), asSessionEffort(e.effort))
119 byTurn.set(e.turnId, route)
120 }
121 const nextEffort = claudeSessionEffort(route.effort)
122 if (nextEffort === e.effort) return yield* next(e)
123 return yield* next({ ...e, effort: nextEffort })
124 } catch {
125 return yield* next(e)
126 }
127 })
128}
129hooks/winnow.ts 152 lines1// Vendored from GhalebDweikat/winnow (MIT). Filters large Read/Bash/Grep results.
2/**
3 * winnow's hooks: a Claude Code function-hook module ("Claude Mods", early access).
4 *
5 * Claude Code loads this file when it runs with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
6 * (2.1.260 or newer); without the flag winnow does nothing, which `winnow doctor`
7 * reports. The module wraps every Read, Bash and Grep call in-process: the result
8 * goes to the resident sidecar (`winnow serve`) together with the task read from
9 * the live session, and the sidecar's rewrite comes back as the tool's result.
10 * At prompt time it asks the sidecar which context files the prompt needs and
11 * appends them to the prompt's context.
12 */
13import type { EngineInterface, Register, SessionMessage } from 'claude-code'
14
15/** The sidecar's port. Keep it equal to WINNOW_PORT. */
16export const PORT = 47311
17export const TOOLS = ['Read', 'Bash', 'Grep'] as const
18/** Results shorter than this are never rewritten (the sidecar's WINNOW_MIN_CHARS default); skip the round trip. */
19const MIN_CHARS = 1500
20
21export type Task = { user_request: string; assistant_intent: string }
22
23/** What the sidecar puts in its X-Winnow response header when it rewrote a result. */
24export type Meta = { hidden?: number; blocks?: number; before?: number; after?: number; key?: string }
25
26type HookOutput = {
27 hookSpecificOutput?: { updatedToolOutput?: unknown; additionalContext?: string }
28 systemMessage?: string // the sidecar's once-per-session notice when its judge cannot start
29}
30
31type ToolCallEvent = { tool: string; tool_use_id?: string; agentId?: string } & Record<string, unknown>
32
33/**
34 * The task a tool call serves: the last thing the human asked for and the last
35 * thing the assistant said since. Mirrors the sidecar's `read_task`, which reads
36 * the same two things from the transcript file; here they come from the live
37 * session, so no transcript path is needed and a subagent sees its own task.
38 */
39export function taskFrom(messages: readonly SessionMessage[]): Task {
40 let user = ''
41 let assistant = ''
42 for (const m of messages) {
43 const text = m.text.trim()
44 if (m.role === 'user') {
45 if (m.toolResults !== undefined && m.toolResults.length > 0) continue // a tool-result turn, not the human
46 if (text !== '') {
47 user = text
48 assistant = ''
49 }
50 } else if (text !== '') {
51 assistant = text
52 }
53 }
54 return { user_request: user, assistant_intent: assistant }
55}
56
57export function describeMeta(tool: string, meta: Meta): string {
58 const k = (n: number | undefined) =>
59 n === undefined ? '?' : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
60 const key = meta.key === undefined ? '' : `; winnow_recall ${meta.key}`
61 return `winnow: hid ${meta.hidden ?? '?'} of ${meta.blocks ?? '?'} blocks of ${tool} (${k(meta.before)} to ${k(meta.after)} chars${key})`
62}
63
64async function post(
65 $: EngineInterface,
66 url: string,
67 payload: unknown,
68): Promise<{ output: HookOutput | undefined; meta: Meta | undefined } | undefined> {
69 let res
70 try {
71 res = await $.http.fetch(url, {
72 method: 'POST',
73 headers: { 'content-type': 'application/json' },
74 body: JSON.stringify(payload),
75 })
76 } catch (err) {
77 $.ui.log(`winnow: sidecar not answering at ${url}: ${String(err)}`, { to: 'debug' })
78 return undefined
79 }
80 if (!res.ok) {
81 $.ui.log(`winnow: sidecar answered ${res.status} at ${url}`, { to: 'debug' })
82 return undefined
83 }
84 let meta: Meta | undefined
85 const raw = res.headers['x-winnow']
86 if (raw !== undefined) {
87 try {
88 meta = JSON.parse(raw) as Meta
89 } catch {
90 meta = undefined
91 }
92 }
93 if (res.text.trim() === '') return { output: undefined, meta } // empty 2xx: pass-through
94 try {
95 return { output: JSON.parse(res.text) as HookOutput, meta }
96 } catch {
97 $.ui.log('winnow: sidecar sent something that is not JSON', { to: 'debug' })
98 return undefined
99 }
100}
101
102async function whereami($: EngineInterface): Promise<{ session_id: string; cwd: string }> {
103 const [session_id, cwd] = await Promise.all([$.session.id().catch(() => ''), $.session.cwd().catch(() => '')])
104 return { session_id, cwd }
105}
106
107export const register: Register = (on) => {
108 const base = `http://127.0.0.1:${PORT}`
109
110 for (const tool of TOOLS) {
111 on('tool.call', { tool }, async ($, e, next) => {
112 const answer = await next(e)
113 if (!('result' in answer) || answer.result === undefined) return answer
114 if (JSON.stringify(answer.result).length < MIN_CHARS) return answer
115
116 const { tool: toolName, tool_use_id, agentId, ...input } = e as ToolCallEvent
117 const [messages, where] = await Promise.all([$.session.messages().catch(() => [] as SessionMessage[]), whereami($)])
118 const res = await post($, `${base}/hook/post-tool-use`, {
119 hook_event_name: 'PostToolUse',
120 source: 'function-hook',
121 ...where,
122 agent_id: agentId,
123 tool_name: toolName,
124 tool_input: input,
125 tool_response: answer.result,
126 tool_use_id,
127 task: taskFrom(messages),
128 })
129 if (typeof res?.output?.systemMessage === 'string') $.ui.toast(res.output.systemMessage)
130 const updated = res?.output?.hookSpecificOutput?.updatedToolOutput
131 if (updated === undefined) return answer
132 if (res?.meta !== undefined) $.ui.toast(describeMeta(toolName, res.meta))
133 return { ...answer, result: updated }
134 })
135 }
136
137 on('prompt.submit', async ($, e, next) => {
138 if (e.text.trim().length < 12) return next(e)
139 const where = await whereami($)
140 const res = await post($, `${base}/hook/user-prompt-submit`, {
141 hook_event_name: 'UserPromptSubmit',
142 source: 'function-hook',
143 ...where,
144 prompt: e.text,
145 })
146 if (typeof res?.output?.systemMessage === 'string') $.ui.toast(res.output.systemMessage)
147 const extra = res?.output?.hookSpecificOutput?.additionalContext
148 if (typeof extra !== 'string' || extra === '') return next(e)
149 return next({ ...e, context: [...(e.context ?? []), extra] })
150 })
151}
152fast-jev/src/compact.ts 310 lines1import { noulAnswer } from './request.js';
2import { collectToolCalls, estimateTokens, fitState } from './state.js';
3import type {
4 CallAnswer,
5 CallDecision,
6 CompactOptions,
7 CompactResult,
8 CompactionState,
9 JevAsker,
10 JevQuestions,
11 Message,
12 ResolvedCompactOptions,
13 ToolCall,
14 ToolUse,
15} from './types.js';
16
17export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
18 goal: '',
19 keepThreshold: 0.5,
20 preserveRecentMessages: 6,
21 maxStateTokens: 25_000,
22 maxRequestTokens: 30_000,
23 truncateHeadChars: 300,
24};
25
26/** Tokens the request envelope (`model`, key names) adds around state and questions. */
27const REQUEST_OVERHEAD_TOKENS = 20;
28
29function finite(value: number | undefined, fallback: number): number {
30 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
31}
32
33export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
34 return {
35 goal: options.goal ?? DEFAULT_OPTIONS.goal,
36 keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
37 preserveRecentMessages: Math.max(
38 0,
39 Math.floor(
40 finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
41 ),
42 ),
43 maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
44 maxRequestTokens: Math.max(
45 1,
46 finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
47 ),
48 truncateHeadChars: Math.max(
49 0,
50 Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
51 ),
52 };
53}
54
55/** The two `noul` questions asked about one call: keep the call, keep its result. */
56export function questionsFor(call: ToolCall): JevQuestions {
57 return {
58 [`call_${call.id}`]: {
59 type: 'noul',
60 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`,
61 },
62 [`result_${call.id}`]: {
63 type: 'noul',
64 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`,
65 },
66 };
67}
68
69/**
70 * Splits the candidate calls into batches whose questions, together with the
71 * (always complete) state, fit one request.
72 */
73export function batchCalls(
74 calls: readonly ToolCall[],
75 stateTokens: number,
76 options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
77): ToolCall[][] {
78 const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
79 const batches: ToolCall[][] = [];
80 let current: ToolCall[] = [];
81 let currentTokens = 0;
82 for (const call of calls) {
83 const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
84 if (current.length > 0 && currentTokens + tokens > budget) {
85 batches.push(current);
86 current = [];
87 currentTokens = 0;
88 }
89 if (current.length === 0 && tokens > budget) {
90 throw new Error(
91 `state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
92 );
93 }
94 current.push(call);
95 currentTokens += tokens;
96 }
97 if (current.length > 0) batches.push(current);
98 return batches;
99}
100
101export function decideCall(
102 call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
103 answer: CallAnswer,
104 options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
105): CallDecision {
106 const base = { id: call.id, tool: call.tool, ...answer };
107 if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
108 if (answer.keepResult >= options.keepThreshold) {
109 return { ...base, action: 'keep', reason: 'kept' };
110 }
111 if (answer.keepCall >= options.keepThreshold) {
112 return { ...base, action: 'drop_result', reason: 'result_dropped' };
113 }
114 return { ...base, action: 'drop_call', reason: 'call_dropped' };
115}
116
117async function askBatch(
118 asker: JevAsker,
119 state: CompactionState,
120 batch: readonly ToolCall[],
121): Promise<Map<string, CallAnswer>> {
122 const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
123 const { answers } = await asker.ask(state, questions);
124 return new Map(
125 batch.map((call) => [
126 call.id,
127 {
128 keepCall: noulAnswer(answers, `call_${call.id}`),
129 keepResult: noulAnswer(answers, `result_${call.id}`),
130 },
131 ]),
132 );
133}
134
135function truncatedResultText(text: string, isError: boolean, headChars: number): string {
136 if (text.length <= headChars + 120) return text;
137 const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
138 return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
139 isError ? ' (error)' : ''
140 }; re-run the tool if needed]`;
141}
142
143/**
144 * Rebuilds the conversation from the decisions. A dropped call disappears
145 * together with its result; a dropped result keeps a bounded head and note.
146 * Messages that lose all their content are removed; untouched messages are
147 * returned as the same objects they came in as.
148 */
149export function applyDecisions(
150 messages: readonly Message[],
151 decisions: readonly CallDecision[],
152 calls: readonly ToolCall[],
153 headChars: number,
154): Message[] {
155 const byId = new Map(calls.map((call) => [call.id, call]));
156 const actions = new Map<string, CallDecision['action']>();
157 for (const decision of decisions) {
158 const call = byId.get(decision.id);
159 if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
160 }
161 const kept: Message[] = [];
162 for (const message of messages) {
163 const touched =
164 message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
165 (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
166 if (!touched) {
167 kept.push(message);
168 continue;
169 }
170 const toolUses = message.toolUses
171 .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
172 .map((tool) => {
173 if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
174 const text = truncatedResultText(
175 tool.text ?? '',
176 tool.isError ?? false,
177 headChars,
178 );
179 if ((tool.text ?? '') === text) return tool;
180 const copy: ToolUse = {
181 tool_use_id: tool.tool_use_id,
182 tool: tool.tool,
183 input: tool.input,
184 text,
185 };
186 if (tool.isError) copy.isError = true;
187 return copy;
188 });
189 const toolResults = (message.toolResults ?? [])
190 .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
191 .map((result) => {
192 if (actions.get(result.tool_use_id) !== 'drop_result') return result;
193 const text = truncatedResultText(result.text, result.isError ?? false, headChars);
194 return text === result.text
195 ? result
196 : {
197 tool_use_id: result.tool_use_id,
198 text,
199 isError: result.isError,
200 };
201 });
202 if (
203 !message.toolUses.some(
204 (tool) => actions.get(tool.tool_use_id) === 'drop_call',
205 ) &&
206 !(message.toolResults ?? []).some(
207 (result) => actions.get(result.tool_use_id) === 'drop_call',
208 ) &&
209 toolUses.every((tool, index) => tool === message.toolUses[index]) &&
210 toolResults.every(
211 (result, index) => result === message.toolResults?.[index],
212 )
213 ) {
214 kept.push(message);
215 continue;
216 }
217 if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
218 continue;
219 }
220 const rebuilt: Message = { role: message.role, text: message.text, toolUses };
221 if (toolResults.length > 0) rebuilt.toolResults = toolResults;
222 kept.push(rebuilt);
223 }
224 return kept;
225}
226
227/** Characters of text, tool input and tool output a message holds. */
228export function messageChars(message: Message): number {
229 let total = message.text.length;
230 for (const tool of message.toolUses) {
231 try {
232 total += JSON.stringify(tool.input).length;
233 } catch {
234 total += 20;
235 }
236 }
237 for (const result of message.toolResults ?? []) total += result.text.length;
238 return total;
239}
240
241export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
242 const { charsBefore, charsAfter } = result.stats;
243 return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
244}
245
246function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
247 return decisions.filter((decision) => decision.reason === reason).length;
248}
249
250/**
251 * Compacts a transcript by asking Jev, for every tool call outside the pinned
252 * first and newest messages, whether the call and whether its result must
253 * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
254 * sent as state with every batch of questions. Throws when Jev fails or the
255 * history cannot be fitted; the caller decides whether to fall back.
256 */
257export async function compact(
258 messages: readonly Message[],
259 asker: JevAsker,
260 options: CompactOptions = {},
261): Promise<CompactResult> {
262 const started = Date.now();
263 const resolved = resolveOptions(options);
264 const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
265 const candidates = calls.filter((call) => !call.pinned);
266 const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
267
268 let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
269 let batches: ToolCall[][] = [];
270 const answers = new Map<string, CallAnswer>();
271 if (candidates.length > 0) {
272 const state = fitState(messages, calls, resolved);
273 fitted = state;
274 batches = batchCalls(candidates, state.tokens, resolved);
275 const answered = await Promise.all(
276 batches.map((batch) => askBatch(asker, state.state, batch)),
277 );
278 for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
279 }
280
281 const decisions = calls.map((call) =>
282 decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
283 );
284 const kept = applyDecisions(
285 messages,
286 decisions,
287 calls,
288 resolved.truncateHeadChars,
289 );
290 return {
291 messages: kept,
292 decisions,
293 stats: {
294 messagesBefore: messages.length,
295 messagesAfter: kept.length,
296 charsBefore,
297 charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
298 calls: calls.length,
299 kept: count(decisions, 'kept'),
300 resultsDropped: count(decisions, 'result_dropped'),
301 callsDropped: count(decisions, 'call_dropped'),
302 pinned: count(decisions, 'pinned'),
303 stateTokens: fitted.tokens,
304 stateStage: fitted.stage,
305 requests: batches.length,
306 ms: Date.now() - started,
307 },
308 };
309}
310fast-jev/src/request.ts 125 lines1import { estimateTokens } from './state.js';
2import type { JevAnswer, JevQuestions, JevResponse, JevState } from './types.js';
3
4export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
5export const DEFAULT_MODEL = 'jev-latest';
6
7/**
8 * Jev reads at most about 32k tokens of state plus the longest question and
9 * answers 400 max_tokens_exceeded past it. A caller that hands over a whole
10 * prompt (the router, the opencode hooks) gets its longest texts abridged,
11 * head and tail kept, until the state fits with room for the questions.
12 */
13export const MAX_STATE_TOKENS = 28_000;
14
15export function boundState(state: JevState, maxTokens = MAX_STATE_TOKENS): JevState {
16 let copy = JSON.parse(JSON.stringify(state)) as unknown;
17 for (let round = 0; round < 24; round++) {
18 const tokens = estimateTokens(JSON.stringify(copy));
19 if (tokens <= maxTokens) break;
20 const keepRatio = Math.max(0.1, (maxTokens / tokens) * 0.95);
21 if (typeof copy === 'string') {
22 copy = abridge(copy, keepRatio);
23 continue;
24 }
25 let longest: { holder: Record<string | number, unknown>; key: string | number; size: number } | null = null;
26 const visit = (value: unknown, holder: Record<string | number, unknown> | null, key: string | number | null): void => {
27 if (typeof value === 'string') {
28 if (holder && key !== null && (!longest || value.length > longest.size)) longest = { holder, key, size: value.length };
29 } else if (Array.isArray(value)) {
30 value.forEach((item, index) => visit(item, value as unknown as Record<number, unknown>, index));
31 } else if (value && typeof value === 'object') {
32 for (const [name, item] of Object.entries(value)) visit(item, value as Record<string, unknown>, name);
33 }
34 };
35 visit(copy, null, null);
36 if (!longest) break;
37 const target = longest as { holder: Record<string | number, unknown>; key: string | number };
38 target.holder[target.key] = abridge(target.holder[target.key] as string, keepRatio);
39 }
40 return copy as JevState;
41}
42
43function abridge(text: string, keepRatio: number): string {
44 const keep = Math.max(400, Math.floor(text.length * keepRatio));
45 if (keep >= text.length) return text;
46 const head = Math.floor(keep * 0.7);
47 return `${text.slice(0, head)}\n[… ${text.length - keep} chars omitted …]\n${text.slice(text.length - (keep - head))}`;
48}
49
50export interface JevRequest {
51 url: string;
52 method: 'POST';
53 headers: Record<string, string>;
54 body: string;
55}
56
57/** The HTTP request for one Jev call, for any fetch-like transport. */
58export function buildJevRequest(
59 params: {
60 apiKey: string;
61 model?: string;
62 baseUrl?: string;
63 },
64 state: JevState,
65 questions: JevQuestions,
66): JevRequest {
67 return {
68 url: params.baseUrl ?? SYSTEM_ONE_URL,
69 method: 'POST',
70 headers: {
71 authorization: `Bearer ${params.apiKey}`,
72 'content-type': 'application/json',
73 },
74 body: JSON.stringify({
75 model: params.model ?? DEFAULT_MODEL,
76 state: boundState(state),
77 questions,
78 }),
79 };
80}
81
82/** Validates a Jev response body; throws on anything but an `answers` object. */
83export function parseJevResponse(
84 status: number,
85 ok: boolean,
86 text: string,
87): JevResponse {
88 if (!ok) {
89 throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
90 }
91 let parsed: unknown;
92 try {
93 parsed = JSON.parse(text);
94 } catch {
95 throw new Error('Jev returned malformed JSON');
96 }
97 if (
98 parsed === null ||
99 typeof parsed !== 'object' ||
100 !('answers' in parsed) ||
101 parsed.answers === null ||
102 typeof parsed.answers !== 'object'
103 ) {
104 throw new Error('Jev response is missing answers');
105 }
106 return parsed as JevResponse;
107}
108
109/** The `noul` probability of one answer; throws when it is not there. */
110export function noulAnswer(
111 answers: Record<string, JevAnswer>,
112 name: string,
113): number {
114 const answer = answers[name];
115 if (
116 !answer ||
117 !('noul' in answer) ||
118 typeof answer.noul !== 'number' ||
119 !Number.isFinite(answer.noul)
120 ) {
121 throw new Error(`Invalid Jev answer for ${name}`);
122 }
123 return answer.noul;
124}
125fast-jev/src/types.ts 203 lines1export type Role = 'user' | 'assistant';
2
3/**
4 * A tool_use block of an assistant message. `text` and `isError` mirror the
5 * outcome once the transcript holds it (Claude Code attaches them).
6 */
7export interface ToolUse {
8 tool_use_id: string;
9 tool: string;
10 input: Record<string, unknown>;
11 text?: string;
12 isError?: boolean;
13}
14
15/** A tool_result block of a user message. */
16export interface ToolResult {
17 tool_use_id: string;
18 text: string;
19 isError?: boolean;
20}
21
22/**
23 * One transcript message. The shape is a subset of Claude Code's
24 * `SessionMessage`, so a session transcript can be passed in as is.
25 */
26export interface Message {
27 role: Role;
28 text: string;
29 toolUses: ToolUse[];
30 toolResults?: ToolResult[];
31}
32
33/** A tool call paired with its result by `tool_use_id`. */
34export interface ToolCall {
35 /** Short id used in the Jev state and question names (`t1`, `t2`, ...). */
36 id: string;
37 tool_use_id: string;
38 tool: string;
39 input: Record<string, unknown>;
40 /** Index of the message holding the tool_use block. */
41 callIndex: number;
42 /** Index of the message holding the tool_result block. */
43 resultIndex: number;
44 resultChars: number;
45 isError: boolean;
46 /** In the first or the newest preserved messages; never a candidate. */
47 pinned: boolean;
48}
49
50export interface CallAnswer {
51 /** Jev's probability that the call itself still matters. */
52 keepCall: number;
53 /** Jev's probability that the full result still needs to stay verbatim. */
54 keepResult: number;
55}
56
57export type CallAction = 'keep' | 'drop_result' | 'drop_call';
58
59export interface CallDecision extends CallAnswer {
60 id: string;
61 tool: string;
62 action: CallAction;
63 reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
64}
65
66export interface HistoryToolCall {
67 id: string;
68 tool: string;
69 input: string;
70 result: string;
71}
72
73export interface HistoryEntry {
74 i: number;
75 role: Role;
76 text: string;
77 /** Structured per call, or one compact line per call once the state has to shrink. */
78 tool_calls?: HistoryToolCall[] | string[];
79}
80
81/** The state sent with every Jev request: the whole history, results omitted. */
82export interface CompactionState {
83 context: string;
84 goal: string;
85 history: HistoryEntry[];
86}
87
88export interface FittedState {
89 state: CompactionState;
90 tokens: number;
91 /** Which fitting stage produced the state, for diagnostics. */
92 stage: string;
93}
94
95export interface CompactOptions {
96 /** Ongoing task description; defaults to the last few user prompts. */
97 goal?: string;
98 /** Minimum keep probability for a call or result to stay. Default 0.5. */
99 keepThreshold?: number;
100 /** Newest messages never touched (the first message is always kept). Default 6. */
101 preserveRecentMessages?: number;
102 /** Estimated token ceiling for the state. Default 25000. */
103 maxStateTokens?: number;
104 /** Estimated token ceiling for state plus one batch of questions. Default 30000. */
105 maxRequestTokens?: number;
106 /** Characters of a dropped tool result to retain. Default 300. */
107 truncateHeadChars?: number;
108}
109
110export interface ResolvedCompactOptions {
111 goal: string;
112 keepThreshold: number;
113 preserveRecentMessages: number;
114 maxStateTokens: number;
115 maxRequestTokens: number;
116 truncateHeadChars: number;
117}
118
119export interface CompactResult {
120 /** The compacted transcript; untouched messages are the input objects. */
121 messages: Message[];
122 decisions: CallDecision[];
123 stats: {
124 messagesBefore: number;
125 messagesAfter: number;
126 charsBefore: number;
127 charsAfter: number;
128 calls: number;
129 kept: number;
130 resultsDropped: number;
131 callsDropped: number;
132 pinned: number;
133 stateTokens: number;
134 /** Which fitting stage the state needed, '' when no request was made. */
135 stateStage: string;
136 requests: number;
137 ms: number;
138 };
139}
140
141/** The `state` of a Jev request: a string or any JSON-serialisable object. */
142export type JevState = string | object;
143
144export interface NoulQuestion {
145 type: 'noul';
146 instructions: string;
147 criteria?: {
148 true?: string;
149 false?: string;
150 };
151}
152
153export interface ChoiceQuestion {
154 type: 'choice';
155 instructions: string;
156 criteria: Record<string, string | null>;
157}
158
159export interface ScoreQuestion {
160 type: 'score';
161 instructions: string;
162 criteria: string[];
163}
164
165export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
166export type JevQuestions = Record<string, JevQuestion>;
167
168export interface NoulAnswer {
169 type?: 'noul';
170 noul: number;
171}
172
173export interface ChoiceAnswer {
174 type?: 'choice';
175 choice: string;
176 confidence: number;
177 probabilities: Record<string, number>;
178}
179
180export interface ScoreAnswer {
181 type?: 'score';
182 score: number;
183 confidence: number;
184 probabilities: Record<string, number>;
185}
186
187export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
188
189export interface JevResponse {
190 model?: string;
191 answers: Record<string, JevAnswer>;
192 usage?: {
193 input_tokens?: number;
194 output_tokens?: number;
195 };
196 [key: string]: unknown;
197}
198
199/** Anything that can answer Jev questions: `JevClient`, or a host-provided adapter. */
200export interface JevAsker {
201 ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
202}
203hooks/jev-route-core.ts 201 lines1export type Effort = 'low' | 'medium' | 'high'
2export type SessionEffort = Effort | 'xhigh' | 'max'
3export type Subagent = 'haiku' | 'sonnet'
4
5export type Route = {
6 effort: SessionEffort
7 subagent: Subagent
8 reason: string
9}
10
11export type PolicyInput = {
12 tier: string
13 effort: string
14 risk: number
15 tierConf: number
16 effortConf: number
17 current: SessionEffort
18}
19
20export const ROUTE_TIMEOUT_MS = 2500
21
22export const ROUTE_QUESTIONS = {
23 tier: {
24 type: 'choice' as const,
25 instructions: 'Classify the coding task.',
26 criteria: {
27 mechanical: 'rename, format, typo, boilerplate, one-line fix',
28 standard: 'normal feature or bug with local scope',
29 hard: 'architecture, concurrency, security, wide blast radius',
30 },
31 },
32 effort: {
33 type: 'choice' as const,
34 instructions: 'Reasoning effort this turn needs. Pick the cheapest that still solves the task.',
35 criteria: {
36 low: 'obvious, mechanical, follow an existing pattern; extra thinking is waste',
37 medium: 'normal implementation with local scope',
38 high: 'must think carefully; many tradeoffs or failure modes',
39 xhigh: 'deep architecture, concurrency, security, or wide blast radius; cheaper effort will miss it',
40 },
41 },
42 risk: {
43 type: 'noul' as const,
44 instructions:
45 'Probability this change can cause data loss, a security issue, or a production outage.',
46 },
47}
48
49const NOTCH: Record<string, number> = {
50 low: 0,
51 medium: 1,
52 high: 2,
53 xhigh: 3,
54 max: 4,
55}
56
57export function routeEnabled(flag: string | undefined | null): boolean {
58 const v = (flag ?? '1').trim().toLowerCase()
59 return v !== '0' && v !== 'off' && v !== 'false' && v !== 'no'
60}
61
62export function asSessionEffort(value: unknown, fallback: SessionEffort = 'medium'): SessionEffort {
63 if (value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh' || value === 'max') {
64 return value
65 }
66 return fallback
67}
68
69export function claudeSessionEffort(value: SessionEffort): Effort {
70 return value === 'xhigh' || value === 'max' ? 'high' : value
71}
72
73export function applyPolicy(a: PolicyInput): Route {
74 const current = asSessionEffort(a.current)
75 const wanted = asSessionEffort(a.effort, current)
76 if (a.risk >= 0.7) {
77 const floor: SessionEffort = (NOTCH[wanted] ?? 0) >= 3 ? wanted : 'high'
78 const effort: SessionEffort = (NOTCH[current] ?? 0) >= (NOTCH[floor] ?? 0) ? current : floor
79 return { effort, subagent: 'sonnet', reason: 'risk' }
80 }
81 if (a.tier === 'mechanical' && a.tierConf >= 0.55) {
82 return { effort: 'low', subagent: 'haiku', reason: 'mechanical' }
83 }
84 if (a.tier === 'hard' && a.tierConf >= 0.3) {
85 const effort: SessionEffort = (NOTCH[wanted] ?? 0) >= 3 ? wanted : 'high'
86 return { effort, subagent: 'sonnet', reason: 'hard' }
87 }
88 let effort: SessionEffort = current
89 if (wanted !== current && a.effortConf >= 0.3) effort = wanted
90 return { effort, subagent: 'sonnet', reason: a.tier || 'standard' }
91}
92
93export function parseRoute(
94 answers: Record<string, { choice?: unknown; confidence?: unknown; noul?: unknown }>,
95 current: SessionEffort,
96): Route {
97 const tierA = answers.tier
98 const effortA = answers.effort
99 const riskA = answers.risk
100 return applyPolicy({
101 tier: typeof tierA?.choice === 'string' ? tierA.choice : 'standard',
102 effort: typeof effortA?.choice === 'string' ? effortA.choice : current,
103 risk: typeof riskA?.noul === 'number' && Number.isFinite(riskA.noul) ? riskA.noul : 0,
104 tierConf: typeof tierA?.confidence === 'number' && Number.isFinite(tierA.confidence) ? tierA.confidence : 0,
105 effortConf:
106 typeof effortA?.confidence === 'number' && Number.isFinite(effortA.confidence) ? effortA.confidence : 0,
107 current,
108 })
109}
110
111export function currentEffortFromModelId(modelId: string): SessionEffort | null {
112 const m = modelId.match(/-(low|medium|high|xhigh|max)(?:-fast)?$/)
113 return m ? asSessionEffort(m[1], 'medium') : null
114}
115
116export function swapModelEffort(modelId: string, effort: SessionEffort): string {
117 const m = modelId.match(/^(.*)-(low|medium|high|xhigh|max)(-fast)?$/)
118 if (!m) return modelId
119 const next = asSessionEffort(effort)
120 if (m[2] === next) return modelId
121 return `${m[1]}-${next}${m[3] ?? ''}`
122}
123
124export function defaultRoute(current: SessionEffort): Route {
125 return { effort: current, subagent: 'sonnet', reason: 'fail-open' }
126}
127
128export function assertRoutePolicy(): void {
129 const cases: Array<[PolicyInput, Pick<Route, 'effort' | 'subagent' | 'reason'>]> = [
130 [
131 { tier: 'mechanical', effort: 'low', risk: 0.1, tierConf: 0.8, effortConf: 0.8, current: 'medium' },
132 { effort: 'low', subagent: 'haiku', reason: 'mechanical' },
133 ],
134 [
135 { tier: 'hard', effort: 'high', risk: 0.2, tierConf: 0.5, effortConf: 0.5, current: 'medium' },
136 { effort: 'high', subagent: 'sonnet', reason: 'hard' },
137 ],
138 [
139 { tier: 'standard', effort: 'low', risk: 0.8, tierConf: 0.9, effortConf: 0.9, current: 'medium' },
140 { effort: 'high', subagent: 'sonnet', reason: 'risk' },
141 ],
142 [
143 { tier: 'standard', effort: 'low', risk: 0.8, tierConf: 0.9, effortConf: 0.9, current: 'xhigh' },
144 { effort: 'xhigh', subagent: 'sonnet', reason: 'risk' },
145 ],
146 [
147 { tier: 'standard', effort: 'high', risk: 0.1, tierConf: 0.5, effortConf: 0.4, current: 'medium' },
148 { effort: 'high', subagent: 'sonnet', reason: 'standard' },
149 ],
150 [
151 { tier: 'standard', effort: 'low', risk: 0.1, tierConf: 0.5, effortConf: 0.4, current: 'medium' },
152 { effort: 'low', subagent: 'sonnet', reason: 'standard' },
153 ],
154 [
155 { tier: 'standard', effort: 'xhigh', risk: 0.1, tierConf: 0.5, effortConf: 0.4, current: 'medium' },
156 { effort: 'xhigh', subagent: 'sonnet', reason: 'standard' },
157 ],
158 [
159 { tier: 'hard', effort: 'xhigh', risk: 0.2, tierConf: 0.5, effortConf: 0.5, current: 'medium' },
160 { effort: 'xhigh', subagent: 'sonnet', reason: 'hard' },
161 ],
162 [
163 { tier: 'standard', effort: 'low', risk: 0.1, tierConf: 0.5, effortConf: 0.7, current: 'medium' },
164 { effort: 'low', subagent: 'sonnet', reason: 'standard' },
165 ],
166 ]
167 for (const [input, expected] of cases) {
168 const got = applyPolicy(input)
169 if (got.effort !== expected.effort || got.subagent !== expected.subagent || got.reason !== expected.reason) {
170 throw new Error(`applyPolicy ${JSON.stringify(input)} => ${JSON.stringify(got)} expected ${JSON.stringify(expected)}`)
171 }
172 }
173 if (swapModelEffort('cursor-acp/grok-4.7-medium', 'xhigh') !== 'cursor-acp/grok-4.7-xhigh') {
174 throw new Error('swap medium → xhigh')
175 }
176 if (swapModelEffort('cursor-acp/grok-4.7-medium-fast', 'low') !== 'cursor-acp/grok-4.7-low-fast') {
177 throw new Error('swap medium-fast → low-fast')
178 }
179 if (swapModelEffort('cursor-grok-4.6-medium-fast', 'xhigh') !== 'cursor-grok-4.6-xhigh-fast') {
180 throw new Error('swap medium-fast → xhigh-fast')
181 }
182 if (swapModelEffort('ag/gemini-3.8-flash-medium', 'low') !== 'ag/gemini-3.8-flash-low') {
183 throw new Error('swap gemini medium → low')
184 }
185 if (swapModelEffort('cursor-grok-4.6-xhigh-fast', 'xhigh') !== 'cursor-grok-4.6-xhigh-fast') {
186 throw new Error('keep xhigh')
187 }
188 if (currentEffortFromModelId('gpt-5.6-sol-high') !== 'high') {
189 throw new Error('parse sol-high')
190 }
191 if (swapModelEffort('claude-opus-5', 'high') !== 'claude-opus-5') {
192 throw new Error('no suffix stays')
193 }
194 if (claudeSessionEffort('xhigh') !== 'high' || claudeSessionEffort('max') !== 'high') {
195 throw new Error('claude clamps xhigh/max → high')
196 }
197 if (claudeSessionEffort('low') !== 'low') {
198 throw new Error('claude keeps low')
199 }
200}
201fast-jev/src/state.ts 305 lines1import type {
2 CompactionState,
3 FittedState,
4 HistoryEntry,
5 Message,
6 ResolvedCompactOptions,
7 ToolCall,
8 ToolResult,
9} from './types.js';
10
11export const STATE_CONTEXT =
12 'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
13
14/** Successive caps on the serialised tool input included per call. */
15const INPUT_CHARS = [1000, 200, 60] as const;
16const TEXT_HEAD = 400;
17const TEXT_TAIL = 150;
18
19const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
20
21/**
22 * Estimates tokens without a tokenizer: a word costs one token per six
23 * letters, a digit half a token, any other symbol nine tenths. Calibrated
24 * against the usage Jev reports for real transcripts, where it lands 2–18%
25 * above the true count; a plain characters-per-token ratio undercounts the
26 * JSON-heavy states by up to 40%.
27 */
28export function estimateTokens(text: string): number {
29 let tokens = 0;
30 for (const [piece] of text.matchAll(TOKEN_PIECES)) {
31 const first = piece.charCodeAt(0);
32 if (first >= 48 && first <= 57) tokens += piece.length / 2;
33 else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
34 tokens += 1 + Math.floor((piece.length - 1) / 6);
35 } else tokens += 0.9;
36 }
37 return Math.ceil(tokens);
38}
39
40export function truncate(text: string, limit: number): string {
41 return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
42}
43
44function abridge(text: string, head: number, tail: number): string {
45 if (text.length <= head + tail + 40) return text;
46 const omitted = text.length - head - tail;
47 return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
48}
49
50export function isPinned(
51 index: number,
52 total: number,
53 preserveRecentMessages: number,
54): boolean {
55 return index === 0 || index >= total - preserveRecentMessages;
56}
57
58/**
59 * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
60 * result are not candidates (there is nothing to drop yet).
61 */
62export function collectToolCalls(
63 messages: readonly Message[],
64 preserveRecentMessages: number,
65): ToolCall[] {
66 const results = new Map<string, { index: number; result: ToolResult }>();
67 messages.forEach((message, index) => {
68 for (const result of message.toolResults ?? []) {
69 results.set(result.tool_use_id, { index, result });
70 }
71 });
72 const calls: ToolCall[] = [];
73 messages.forEach((message, callIndex) => {
74 for (const tool of message.toolUses) {
75 const found = results.get(tool.tool_use_id);
76 if (!found) continue;
77 calls.push({
78 id: `t${calls.length + 1}`,
79 tool_use_id: tool.tool_use_id,
80 tool: tool.tool,
81 input: tool.input,
82 callIndex,
83 resultIndex: found.index,
84 resultChars: found.result.text.length,
85 isError: found.result.isError ?? false,
86 pinned:
87 isPinned(callIndex, messages.length, preserveRecentMessages) ||
88 isPinned(found.index, messages.length, preserveRecentMessages),
89 });
90 }
91 });
92 return calls;
93}
94
95function inputText(input: Record<string, unknown>, limit: number): string {
96 let json = '';
97 try {
98 json = JSON.stringify(input);
99 } catch {
100 json = '[unserializable input]';
101 }
102 return truncate(json, limit);
103}
104
105function resultNote(call: ToolCall): string {
106 return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
107}
108
109/** One call as a single line, for when the structured form is too costly. */
110function compactCall(call: ToolCall): string {
111 const input = Object.entries(call.input)
112 .map(([key, value]) => {
113 const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
114 return `${key}=${text.replace(/\s+/g, ' ')}`;
115 })
116 .join(' ');
117 return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
118 call.isError ? 'error' : 'ok'
119 } ${call.resultChars}ch`;
120}
121
122/**
123 * Folds runs of adjacent call-only entries into one entry each, so the
124 * per-entry envelope is paid once per run; the call lines keep their ids.
125 */
126function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
127 const merged: HistoryEntry[] = [];
128 for (const entry of history) {
129 const previous = merged[merged.length - 1];
130 const foldable = (e: HistoryEntry): boolean =>
131 !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
132 if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
133 previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
134 continue;
135 }
136 merged.push({ ...entry });
137 }
138 return merged;
139}
140
141function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
142 const byMessage = new Map<number, ToolCall[]>();
143 for (const call of calls) {
144 const list = byMessage.get(call.callIndex) ?? [];
145 list.push(call);
146 byMessage.set(call.callIndex, list);
147 }
148 return byMessage;
149}
150
151function historyEntries(
152 messages: readonly Message[],
153 calls: readonly ToolCall[],
154 inputChars: number,
155): HistoryEntry[] {
156 const byMessage = callsByMessage(calls);
157 const entries: HistoryEntry[] = [];
158 messages.forEach((message, i) => {
159 const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
160 id: call.id,
161 tool: call.tool,
162 input: inputText(call.input, inputChars),
163 result: resultNote(call),
164 }));
165 if (message.text.trim().length === 0 && toolCalls.length === 0) return;
166 const entry: HistoryEntry = { i, role: message.role, text: message.text };
167 if (toolCalls.length > 0) entry.tool_calls = toolCalls;
168 entries.push(entry);
169 });
170 return entries;
171}
172
173/** The last three user prompts, as the default `goal`. */
174export function goalFromMessages(messages: readonly Message[]): string {
175 return messages
176 .filter(
177 (message) =>
178 message.role === 'user' &&
179 message.text.trim().length > 0 &&
180 (message.toolResults ?? []).length === 0,
181 )
182 .slice(-3)
183 .map((message) => truncate(message.text, 500))
184 .join('\n');
185}
186
187/**
188 * Builds the Jev state from the whole conversation and shrinks it in stages
189 * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
190 * are abridged oldest-first (pinned messages last), then old messages collapse
191 * to a one-line note, then old tool calls shrink to one line each, then old
192 * messages that carry no call are left out, then runs of old call-only
193 * messages are folded into one entry. Throws when even that is too big.
194 */
195export function fitState(
196 messages: readonly Message[],
197 calls: readonly ToolCall[],
198 options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
199): FittedState {
200 const goal = options.goal || goalFromMessages(messages);
201 const stateOf = (history: HistoryEntry[]): CompactionState => ({
202 context: STATE_CONTEXT,
203 goal,
204 history,
205 });
206 const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
207 const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
208 const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
209 state: stateOf(history),
210 tokens,
211 stage,
212 });
213
214 let history: HistoryEntry[] = [];
215 let perEntry: number[] = [];
216 let tokens = 0;
217 const rebuild = (inputChars: number): void => {
218 history = historyEntries(messages, calls, inputChars);
219 perEntry = history.map(entryTokens);
220 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
221 };
222 const fits = (): boolean => tokens <= options.maxStateTokens;
223 const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
224 const entry = history[index];
225 if (!entry) return;
226 change(entry);
227 const now = entryTokens(entry);
228 tokens += now - (perEntry[index] ?? 0);
229 perEntry[index] = now;
230 };
231
232 rebuild(INPUT_CHARS[0]);
233 if (fits()) return fitted(history, tokens, 'full');
234
235 for (const limit of INPUT_CHARS.slice(1)) {
236 rebuild(limit);
237 if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
238 }
239
240 const pinned = (entry: HistoryEntry): boolean =>
241 isPinned(entry.i, messages.length, options.preserveRecentMessages);
242 const indices = history.map((_, index) => index);
243 const order = [
244 ...indices.filter((index) => !pinned(history[index]!)),
245 ...indices.filter((index) => pinned(history[index]!)),
246 ];
247
248 for (const index of order) {
249 const entry = history[index]!;
250 if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
251 shrink(index, (e) => {
252 e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
253 });
254 if (fits()) return fitted(history, tokens, 'texts abridged');
255 }
256
257 for (const index of order) {
258 const entry = history[index]!;
259 if (pinned(entry) || entry.text.length === 0) continue;
260 const original = messages[entry.i]?.text.length ?? entry.text.length;
261 shrink(index, (e) => {
262 e.text = `[… ${original} chars omitted …]`;
263 });
264 if (fits()) return fitted(history, tokens, 'old messages collapsed');
265 }
266
267 const byMessage = callsByMessage(calls);
268 for (const index of order) {
269 const entry = history[index]!;
270 const own = byMessage.get(entry.i);
271 if (pinned(entry) || !own) continue;
272 shrink(index, (e) => {
273 e.tool_calls = own.map(compactCall);
274 });
275 if (fits()) return fitted(history, tokens, 'old calls compacted');
276 }
277
278 const left = new Set<number>();
279 for (const index of order) {
280 const entry = history[index]!;
281 if (pinned(entry) || entry.tool_calls) continue;
282 left.add(index);
283 tokens -= perEntry[index] ?? 0;
284 if (fits()) {
285 return fitted(
286 history.filter((_, i) => !left.has(i)),
287 tokens,
288 'old messages left out',
289 );
290 }
291 }
292
293 history = mergeCallRuns(
294 history.filter((_, i) => !left.has(i)),
295 pinned,
296 );
297 perEntry = history.map(entryTokens);
298 tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
299 if (fits()) return fitted(history, tokens, 'old calls merged');
300
301 throw new Error(
302 `history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
303 );
304}
305