SLOPSHOPPER

lane-stack

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

newguardtoastpromptnetworkagents
★ 122v1.64.8MITupdated 2026-10-04VKirill/claude-lane-stack/plugins/lane-stack
A shopper browsing a rack in a slop shop
README

lane-stack (Claude Code plugin)

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.

Source 9 files
hooks/lane-mods.ts 13 lines
1import 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}
13
hooks/fast-jev.ts 312 lines
1// 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 };
312
hooks/jev-router.ts 129 lines
1import 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}
129
hooks/winnow.ts 152 lines
1// 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}
152
fast-jev/src/compact.ts 310 lines
1import { noulAnswer } from './request.js';
2import { collectToolCalls, estimateTokens, fitState } from './state.js';
3import type {
4  CallAnswer,
5  CallDecision,
6  CompactOptions,
7  CompactResult,
8  CompactionState,
9  JevAsker,
10  JevQuestions,
11  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}
310
fast-jev/src/request.ts 125 lines
1import { 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}
125
fast-jev/src/types.ts 203 lines
1export type Role = 'user' | 'assistant';
2
3/**
4 * A tool_use block of an assistant message. `text` and `isError` mirror the
5 * outcome once the transcript holds it (Claude Code attaches them).
6 */
7export interface ToolUse {
8  tool_use_id: string;
9  tool: string;
10  input: Record<string, unknown>;
11  text?: string;
12  isError?: boolean;
13}
14
15/** A tool_result block of a user message. */
16export interface ToolResult {
17  tool_use_id: string;
18  text: string;
19  isError?: boolean;
20}
21
22/**
23 * One transcript message. The shape is a subset of Claude Code's
24 * `SessionMessage`, so a session transcript can be passed in as is.
25 */
26export interface Message {
27  role: Role;
28  text: string;
29  toolUses: ToolUse[];
30  toolResults?: ToolResult[];
31}
32
33/** A tool call paired with its result by `tool_use_id`. */
34export interface ToolCall {
35  /** Short id used in the Jev state and question names (`t1`, `t2`, ...). */
36  id: string;
37  tool_use_id: string;
38  tool: string;
39  input: Record<string, unknown>;
40  /** Index of the message holding the tool_use block. */
41  callIndex: number;
42  /** Index of the message holding the tool_result block. */
43  resultIndex: number;
44  resultChars: number;
45  isError: boolean;
46  /** In the first or the newest preserved messages; never a candidate. */
47  pinned: boolean;
48}
49
50export interface CallAnswer {
51  /** Jev's probability that the call itself still matters. */
52  keepCall: number;
53  /** Jev's probability that the full result still needs to stay verbatim. */
54  keepResult: number;
55}
56
57export type CallAction = 'keep' | 'drop_result' | 'drop_call';
58
59export interface CallDecision extends CallAnswer {
60  id: string;
61  tool: string;
62  action: CallAction;
63  reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
64}
65
66export interface HistoryToolCall {
67  id: string;
68  tool: string;
69  input: string;
70  result: string;
71}
72
73export interface HistoryEntry {
74  i: number;
75  role: Role;
76  text: string;
77  /** Structured per call, or one compact line per call once the state has to shrink. */
78  tool_calls?: HistoryToolCall[] | string[];
79}
80
81/** The state sent with every Jev request: the whole history, results omitted. */
82export interface CompactionState {
83  context: string;
84  goal: string;
85  history: HistoryEntry[];
86}
87
88export interface FittedState {
89  state: CompactionState;
90  tokens: number;
91  /** Which fitting stage produced the state, for diagnostics. */
92  stage: string;
93}
94
95export interface CompactOptions {
96  /** Ongoing task description; defaults to the last few user prompts. */
97  goal?: string;
98  /** Minimum keep probability for a call or result to stay. Default 0.5. */
99  keepThreshold?: number;
100  /** Newest messages never touched (the first message is always kept). Default 6. */
101  preserveRecentMessages?: number;
102  /** Estimated token ceiling for the state. Default 25000. */
103  maxStateTokens?: number;
104  /** Estimated token ceiling for state plus one batch of questions. Default 30000. */
105  maxRequestTokens?: number;
106  /** Characters of a dropped tool result to retain. Default 300. */
107  truncateHeadChars?: number;
108}
109
110export 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}
203
hooks/jev-route-core.ts 201 lines
1export 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}
201
fast-jev/src/state.ts 305 lines
1import type {
2  CompactionState,
3  FittedState,
4  HistoryEntry,
5  Message,
6  ResolvedCompactOptions,
7  ToolCall,
8  ToolResult,
9} from './types.js';
10
11export const STATE_CONTEXT =
12  'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
13
14/** Successive caps on the serialised tool input included per call. */
15const INPUT_CHARS = [1000, 200, 60] as const;
16const TEXT_HEAD = 400;
17const TEXT_TAIL = 150;
18
19const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
20
21/**
22 * Estimates tokens without a tokenizer: a word costs one token per six
23 * letters, a digit half a token, any other symbol nine tenths. Calibrated
24 * against the usage Jev reports for real transcripts, where it lands 2–18%
25 * above the true count; a plain characters-per-token ratio undercounts the
26 * JSON-heavy states by up to 40%.
27 */
28export function estimateTokens(text: string): number {
29  let tokens = 0;
30  for (const [piece] of text.matchAll(TOKEN_PIECES)) {
31    const first = piece.charCodeAt(0);
32    if (first >= 48 && first <= 57) tokens += piece.length / 2;
33    else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
34      tokens += 1 + Math.floor((piece.length - 1) / 6);
35    } else tokens += 0.9;
36  }
37  return Math.ceil(tokens);
38}
39
40export function truncate(text: string, limit: number): string {
41  return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
42}
43
44function abridge(text: string, head: number, tail: number): string {
45  if (text.length <= head + tail + 40) return text;
46  const omitted = text.length - head - tail;
47  return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
48}
49
50export function isPinned(
51  index: number,
52  total: number,
53  preserveRecentMessages: number,
54): boolean {
55  return index === 0 || index >= total - preserveRecentMessages;
56}
57
58/**
59 * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
60 * result are not candidates (there is nothing to drop yet).
61 */
62export function collectToolCalls(
63  messages: readonly Message[],
64  preserveRecentMessages: number,
65): ToolCall[] {
66  const results = new Map<string, { index: number; result: ToolResult }>();
67  messages.forEach((message, index) => {
68    for (const result of message.toolResults ?? []) {
69      results.set(result.tool_use_id, { index, result });
70    }
71  });
72  const calls: ToolCall[] = [];
73  messages.forEach((message, callIndex) => {
74    for (const tool of message.toolUses) {
75      const found = results.get(tool.tool_use_id);
76      if (!found) continue;
77      calls.push({
78        id: `t${calls.length + 1}`,
79        tool_use_id: tool.tool_use_id,
80        tool: tool.tool,
81        input: tool.input,
82        callIndex,
83        resultIndex: found.index,
84        resultChars: found.result.text.length,
85        isError: found.result.isError ?? false,
86        pinned:
87          isPinned(callIndex, messages.length, preserveRecentMessages) ||
88          isPinned(found.index, messages.length, preserveRecentMessages),
89      });
90    }
91  });
92  return calls;
93}
94
95function inputText(input: Record<string, unknown>, limit: number): string {
96  let json = '';
97  try {
98    json = JSON.stringify(input);
99  } catch {
100    json = '[unserializable input]';
101  }
102  return truncate(json, limit);
103}
104
105function resultNote(call: ToolCall): string {
106  return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
107}
108
109/** One call as a single line, for when the structured form is too costly. */
110function compactCall(call: ToolCall): string {
111  const input = Object.entries(call.input)
112    .map(([key, value]) => {
113      const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
114      return `${key}=${text.replace(/\s+/g, ' ')}`;
115    })
116    .join(' ');
117  return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
118    call.isError ? 'error' : 'ok'
119  } ${call.resultChars}ch`;
120}
121
122/**
123 * Folds runs of adjacent call-only entries into one entry each, so the
124 * per-entry envelope is paid once per run; the call lines keep their ids.
125 */
126function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
127  const merged: HistoryEntry[] = [];
128  for (const entry of history) {
129    const previous = merged[merged.length - 1];
130    const foldable = (e: HistoryEntry): boolean =>
131      !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
132    if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
133      previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
134      continue;
135    }
136    merged.push({ ...entry });
137  }
138  return merged;
139}
140
141function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
142  const byMessage = new Map<number, ToolCall[]>();
143  for (const call of calls) {
144    const list = byMessage.get(call.callIndex) ?? [];
145    list.push(call);
146    byMessage.set(call.callIndex, list);
147  }
148  return byMessage;
149}
150
151function historyEntries(
152  messages: readonly Message[],
153  calls: readonly ToolCall[],
154  inputChars: number,
155): HistoryEntry[] {
156  const byMessage = callsByMessage(calls);
157  const entries: HistoryEntry[] = [];
158  messages.forEach((message, i) => {
159    const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
160      id: call.id,
161      tool: call.tool,
162      input: inputText(call.input, inputChars),
163      result: resultNote(call),
164    }));
165    if (message.text.trim().length === 0 && toolCalls.length === 0) return;
166    const entry: HistoryEntry = { i, role: message.role, text: message.text };
167    if (toolCalls.length > 0) entry.tool_calls = toolCalls;
168    entries.push(entry);
169  });
170  return entries;
171}
172
173/** The last three user prompts, as the default `goal`. */
174export function goalFromMessages(messages: readonly Message[]): string {
175  return messages
176    .filter(
177      (message) =>
178        message.role === 'user' &&
179        message.text.trim().length > 0 &&
180        (message.toolResults ?? []).length === 0,
181    )
182    .slice(-3)
183    .map((message) => truncate(message.text, 500))
184    .join('\n');
185}
186
187/**
188 * Builds the Jev state from the whole conversation and shrinks it in stages
189 * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
190 * are abridged oldest-first (pinned messages last), then old messages collapse
191 * to a one-line note, then old tool calls shrink to one line each, then old
192 * messages that carry no call are left out, then runs of old call-only
193 * messages are folded into one entry. Throws when even that is too big.
194 */
195export function fitState(
196  messages: readonly Message[],
197  calls: readonly ToolCall[],
198  options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
199): FittedState {
200  const goal = options.goal || goalFromMessages(messages);
201  const stateOf = (history: HistoryEntry[]): CompactionState => ({
202    context: STATE_CONTEXT,
203    goal,
204    history,
205  });
206  const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
207  const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
208  const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
209    state: stateOf(history),
210    tokens,
211    stage,
212  });
213
214  let history: HistoryEntry[] = [];
215  let perEntry: number[] = [];
216  let tokens = 0;
217  const rebuild = (inputChars: number): void => {
218    history = historyEntries(messages, calls, inputChars);
219    perEntry = history.map(entryTokens);
220    tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
221  };
222  const fits = (): boolean => tokens <= options.maxStateTokens;
223  const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
224    const entry = history[index];
225    if (!entry) return;
226    change(entry);
227    const now = entryTokens(entry);
228    tokens += now - (perEntry[index] ?? 0);
229    perEntry[index] = now;
230  };
231
232  rebuild(INPUT_CHARS[0]);
233  if (fits()) return fitted(history, tokens, 'full');
234
235  for (const limit of INPUT_CHARS.slice(1)) {
236    rebuild(limit);
237    if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
238  }
239
240  const pinned = (entry: HistoryEntry): boolean =>
241    isPinned(entry.i, messages.length, options.preserveRecentMessages);
242  const indices = history.map((_, index) => index);
243  const order = [
244    ...indices.filter((index) => !pinned(history[index]!)),
245    ...indices.filter((index) => pinned(history[index]!)),
246  ];
247
248  for (const index of order) {
249    const entry = history[index]!;
250    if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
251    shrink(index, (e) => {
252      e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
253    });
254    if (fits()) return fitted(history, tokens, 'texts abridged');
255  }
256
257  for (const index of order) {
258    const entry = history[index]!;
259    if (pinned(entry) || entry.text.length === 0) continue;
260    const original = messages[entry.i]?.text.length ?? entry.text.length;
261    shrink(index, (e) => {
262      e.text = `[… ${original} chars omitted …]`;
263    });
264    if (fits()) return fitted(history, tokens, 'old messages collapsed');
265  }
266
267  const byMessage = callsByMessage(calls);
268  for (const index of order) {
269    const entry = history[index]!;
270    const own = byMessage.get(entry.i);
271    if (pinned(entry) || !own) continue;
272    shrink(index, (e) => {
273      e.tool_calls = own.map(compactCall);
274    });
275    if (fits()) return fitted(history, tokens, 'old calls compacted');
276  }
277
278  const left = new Set<number>();
279  for (const index of order) {
280    const entry = history[index]!;
281    if (pinned(entry) || entry.tool_calls) continue;
282    left.add(index);
283    tokens -= perEntry[index] ?? 0;
284    if (fits()) {
285      return fitted(
286        history.filter((_, i) => !left.has(i)),
287        tokens,
288        'old messages left out',
289      );
290    }
291  }
292
293  history = mergeCallRuns(
294    history.filter((_, i) => !left.has(i)),
295    pinned,
296  );
297  perEntry = history.map(entryTokens);
298  tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
299  if (fits()) return fitted(history, tokens, 'old calls merged');
300
301  throw new Error(
302    `history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
303  );
304}
305