Picks the model once per session and the effort every turn, using TypeSafe's Jev to judge how hard the task is.

<!-- Maintainers: this README is a summary. The full rules live in docs/README.md. When behavior or a default changes, update all four: README.md, README.zh-TW.md, docs/README.md, docs/README.zh-TW.md. Keep the install commands here: /jcGearbox-setup points people to them. --> <img src="docs/assets/banner.svg" alt="jcGearbox: Right model. Right effort. Every prompt." width="100%">
English · 繁體中文
In Claude Code you pick one model and one effort level and they apply to every prompt, so simple work overpays and hard work is under-powered. jcGearbox is a Claude Code mod that asks TypeSafe's Jev, a fast judgment model, how hard each prompt is and sets the model and effort to match.
It starts in shadow mode: it shows its pick in the status line but sends your requests unchanged. Switch to auto once /jcGearbox-log has convinced you; the evidence that Jev judges difficulty well is thin.
[!IMPORTANT] A new model or effort costs the prompt cache. Run jcGearbox on an Anthropic API key or a Claude subscription, with every tier on Opus 5.5, Sonnet 5.5, Haiku 5.5 or Fable 5.1: only there does a new effort every prompt keep the cache. Anywhere else (Bedrock, Vertex AI, a gateway, older models), set
min_effortandmax_effortto the same level, so only the model is routed. See the reference.
/plugin marketplace add KevinYu1580/jcGearbox
/plugin install jc-gearbox@jc-gearbox
Then run /jcGearbox-setup, which walks you through the key, the mode and the main settings. Without a key the mod does nothing and the status line says no API key.
Recommended: turn on send_assistant_tail (off by default). It sends the end of Claude's last reply to TypeSafe, but Jev judges much better with it.
echo '{"send_assistant_tail": "true"}' | claude plugin configure jc-gearbox@jc-gearbox --values-stdin
On a session's first prompt, Jev picks a tier. The model moves to a stronger tier at 0.5 probability that one is needed, to a weaker tier only at 0.8 that one will do, and otherwise stays on your model.
| Tier | For | Default |
|---|---|---|
strong | Complex agentic coding, large refactors, hard debugging | opus |
balanced | Everyday coding, data analysis | sonnet |
low | Fast, cheap, high-volume tasks | haiku |
long | Agent sessions that run for hours, deep research | fable (costs the most; off disables it) |
The model is decided once, at the first prompt (diagram 1); after that, only the events in diagram 2 change it. Which model each tier means is up to your settings.
flowchart TD
P["First prompt of a session<br/>(on the model you picked)"] --> Q{"Jev: 0.5 that a stronger tier<br/>is needed, or 0.8 that<br/>a weaker one will do?"}
Q -->|"Yes"| O["The session uses<br/>that tier's model<br/>from here on"]
Q -->|"No, or Jev<br/>errors / times out"| S["The session stays<br/>on your model"]
flowchart LR
C["After the first prompt,<br/>only these change<br/>the model"]
C -->|"/jcGearbox-pin X"| X["Switch to X"]
C -->|"/model to another model"| U["Switch to that model;<br/>the mod stops switching"]
C -->|"/jcGearbox-auto"| A["Pin and /model cleared,<br/>back to the first<br/>prompt's pick"]
C -->|"The chosen model isn't<br/>served or declines"| S["Back to your model,<br/>automatically"]
The diagrams show auto. In shadow mode the same decision is only shown in the status line.
For every prompt you type:
flowchart TD
P["You send a prompt"] --> C{"Only continuing<br/>the last task?"}
C -->|"Yes"| K["Keep the last effort"]
C -->|"No"| J["Use Jev's effort score<br/>(low to max), or the last<br/>effort if Jev isn't sure"]
K --> F{"Saying the last<br/>attempt failed?"}
J --> F
F -->|"Yes"| U["One level up"]
F -->|"No"| R{"Could a mistake do<br/>hard-to-undo damage?"}
U --> R
R -->|"Yes"| X["At least high"]
R -->|"No"| M["Keep it between<br/>min_effort and max_effort"]
X --> M
M --> E(["This prompt's effort"])
"Hard-to-undo damage" means production, data deletion, migrations, auth, money or force-push. The result is finally lowered to what the model takes.
If Jev errors or times out (3 s by default), nothing changes for that prompt. The full rules and the per-model effort table are in the reference.
| Command | Does |
|---|---|
/jcGearbox-setup | Guided setup |
/jcGearbox-status (or /jcGearbox) | Mode, model, effort and the model each tier resolves to |
/jcGearbox-auto, /jcGearbox-shadow, /jcGearbox-off, /jcGearbox-on | Mode for this session |
/jcGearbox-pin <model> | Pin a model for this session |
/jcGearbox-log [n] | Last n decisions |
/jcGearbox-stats | Decisions, Jev's errors and latency, prompt cache hit rates and more |
A /model or /effort of your own after the first prompt stops the mod changing that for the session; /jcGearbox-auto clears those overrides. Plan mode and other details are in Overriding it.
The status line shows what was actually sent in auto (e.g. opus-5-5·high) and Jev's pick in shadow (e.g. shadow ▸ opus-5-5·high).
Set them with /jcGearbox-setup or /plugin configure jc-gearbox@jc-gearbox. The ones you are most likely to change:
| Setting | Default | Meaning |
|---|---|---|
default_mode | shadow | shadow, auto or off for new sessions |
low_model, balanced_model, strong_model, long_model | haiku, sonnet, opus, fable | The model for each tier; off disables it |
min_effort, max_effort | low, max | Range of efforts the mod may set |
send_assistant_tail | off (recommended on) | Also send the end of Claude's last reply |
On Bedrock, Vertex, Foundry or a gateway, give at least two tiers full model ids. Every setting is in the reference.
For each prompt you send, TypeSafe receives the prompt (up to ~6000 characters), your previous prompt (up to ~1000), from the second prompt on the session's current model and last effort, and, with send_assistant_tail on, the end of Claude's last reply. Common key and token shapes are replaced with [redacted] before sending; other secrets you paste go as they are. TypeSafe says it does not train on this data but doesn't say how long it keeps it. The local log lives in ~/.claude/plugins/store/jc-gearbox_*.json; delete it to clear it. Full details in Privacy.
hooks/register.ts 1276 lines1import type { EngineInterface, PluginOptions, Register, TurnStepResult } from 'claude-code'
2
3import { JEV_URL, buildRequest, clip, parseAnswers, redact } from './jev.ts'
4import type { JevRequest, JevResult } from './jev.ts'
5import { newestCatalogFile, resolveModel, surfaceOf, versionAtLeast } from './models.ts'
6import type { Resolved } from './models.ts'
7import { EFFORTS, TIERS, clampEffort, decideEffort, defaultEffort, effortFor, isEffort, pickModel, tierRank } from './policy.ts'
8import type { Effort, Tier } from './policy.ts'
9
10const MIN_VERSION = '2.1.286'
11const KEEP = 200
12const CONTEXT_CHARS = 1000
13const FORK_REPLY_CHARS = 200 // a reply at least this long names the one session that wrote it
14// Prompts the person sent: typed, through the desktop app or an SDK host,
15// Remote Control, or Slack. Anything else (a task notification, a schedule,
16// another session) continues the session as it is and never reaches Jev.
17const USER_ORIGINS = new Set(['composer', 'sdk', 'bridge', 'slack-ping'])
18// claude -p and the Agent SDKs: scripts, whose prompts are no task to judge.
19const SDK_ENTRYPOINTS = ['sdk-cli', 'sdk-ts', 'sdk-py']
20const USAGE = 'Usage: /jcGearbox [status | auto | shadow | off | on | pin <model> | log [n] | stats], or /jcGearbox-<that>'
21// The engine puts this before a Slack message's text by the time its turn starts.
22const NON_USER_FRAME = '[MESSAGE FROM NON-USER SOURCE - NOT USER INPUT]\n'
23// The model setting that runs Opus while planning and Sonnet while executing:
24// a model of the person's choosing, though no model id of its own.
25const OPUSPLAN = /^opusplan(\[\w+\])?$/i
26
27type Mode = 'auto' | 'shadow' | 'off'
28
29type Decision = {
30 at: number
31 mode: Mode
32 first: boolean
33 prompt: string
34 promptLength: number
35 jev: JevResult | null
36 error?: string
37 model?: string
38 effort?: Effort
39 sent?: { model: string; effort: string | number | null } // what the request carried: ours in auto, the engine's own otherwise
40 pick?: string // Jev's own pick as model·effort, which the log shows beside `sent` and the status line in shadow
41 why?: string[] // why it differs from Jev's pick (shadow, pin, /model, effort pinned, plan mode, Jev unsure), and ultracode
42 served?: string // the model the API answered as, when it was not the one sent
43 engine?: { model: string; effort: string | number | null } // the engine's own model and effort for the turn
44 jevMs?: number // how long Jev took, a retry included
45 limits?: Record<string, number> // the account's rate-limit windows: percent used, by kind
46 outcome?: Tally & { reason: string } // how the turn went, once it ended
47}
48
49type Tally = { steps: number; stop: string | null; input: number; output: number; cacheRead: number; cacheWrite: number }
50const NO_REQUESTS: Tally = { steps: 0, stop: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
51
52// One per session id, in $.store, so a resumed session keeps its choice.
53type SessionRecord = {
54 decided?: boolean // the first user turn has chosen the session's model
55 model?: string // that choice; absent keeps the session's own model
56 effort?: Effort // the latest effort decided
57 jevEffort?: Effort // Jev's own latest effort, kept while the person holds theirs: the pick shows it
58 rawEffort?: string | number | null // the engine's own effort at the last turn
59 rawModel?: string // the engine's model at that turn
60 prompt?: string // the last user prompt, for the next turn's context
61 replyTail?: string // the end of Claude's last reply (send_assistant_tail)
62 mode?: Mode // set by /jcGearbox for this session
63 pin?: string // set by /jcGearbox pin
64 userModel?: boolean // the person switched model (/model or the picker)
65 effortPinned?: boolean // the person changed the effort
66 ranPinned?: boolean // the last plain turn ran at the effort the person holds
67 badModels?: string[] // models the API did not answer as
68 answered?: string[] // models a rewritten request of the session got an answer from
69 last?: Decision
70}
71
72type Plan = { apply: boolean; model?: string; effort?: Effort; logKey?: string }
73type Turn = {
74 isUser: boolean
75 text: string
76 command?: boolean // a slash command's turn
77 ultracode?: boolean // the person typed the ultracode keyword: Claude Code may run workflows this turn
78 baseModel?: string
79 baseEffort?: Effort | number // the engine's effort at the turn's first request
80 ownEffort?: boolean // the engine's effort changed mid-turn (a skill's own): it rides the rest of the turn
81 plan?: Plan
82 unanswered?: string // the model the last rewritten request named, when it got no response
83 answered?: string // the model a rewritten request of the turn got an answer from
84 decided?: Promise<Plan | undefined> // the turn's decision, written down once Jev answers
85 tally?: Tally // the turn's requests so far
86}
87type Tiers = Partial<Record<Tier, Resolved>>
88
89const label = (model: string | undefined) => (model ?? '?').replace(/^claude-/, '').replace(/\[\w+\]$/, '')
90// One id names the other's model when it holds it with no further version
91// after it: Vertex's claude-opus-5-5@20261001, Bedrock's
92// us.anthropic.claude-opus-5-5-v1:0, but not claude-opus-5 for claude-opus-5-5.
93// An id that names no Claude model (a Bedrock profile ARN, a gateway's own
94// name) cannot be told apart, so it passes.
95const covers = (long: string, short: string) => {
96 const i = long.indexOf(short)
97 return i >= 0 && !/^-\d{1,2}(?!\d)/.test(long.slice(i + short.length))
98}
99const sameModel = (a: string, b: string) => {
100 const [x, y] = [a.replace('@', '-'), b.replace('@', '-')]
101 return !x.includes('claude-') || !y.includes('claude-') || covers(x, y) || covers(y, x)
102}
103// A model other than the one the request already names: claude-opus-5-5[1m]
104// already runs claude-opus-5-5, with its 1M context.
105const movesTo = (to: string, from: string) => to !== from && to !== from.replace(/\[\w+\]$/, '')
106
107// What one load of the module keeps in memory; $.store keeps the rest.
108type Ctx = {
109 opt: Options
110 disabled?: string
111 resolved?: { session: string; firstParty: boolean; catalog: unknown; version: string; tiers: Tiers }
112 submitted: { text: string; isUser: boolean }[]
113 turns: Map<string, Turn>
114 logged: number
115 answered: Set<string> // models a rewritten request got an answer from, since their last turn died unanswered
116 noted: Set<string> // each session's models written down as having answered (session id and model)
117 joined: Set<string> // sessions that resume or fork a conversation already under way
118 baselined: Set<string> // sessions whose engine effort this process has seen
119 engineModels: Map<string, string> // each session's own model, as its start, its last plain turn or a switch left it
120 forks: Map<string, { model: string; inPlan: boolean } | null> // each subagent seen: the model a fork of the main conversation started on, and whether in a plan-mode turn; else null
121 planMode: Set<string> // the sessions whose last prompt was sent in plan mode
122 failure?: Failure // the main loop's turn that died of an API error, as far as its two reports are in
123 queue: Promise<unknown> // the session record's last change, which the next waits for
124}
125
126// A turn that died of an API error: the model its rewritten request named and
127// got no response from (turn.complete), and why it died (StopFailure). The two
128// come in either order.
129type Failure = { model?: string; error?: string }
130
131// A skill runs under its plugin's name (jc-gearbox:<name>), a registered command under its own.
132const named = (name: string) => new RegExp(`^(?:jc-gearbox:)?${name}$`)
133
134export const register: Register = (on, options) => {
135 const ctx: Ctx = {
136 opt: readOptions(options),
137 submitted: [],
138 turns: new Map(),
139 logged: 0,
140 answered: new Set(),
141 noted: new Set(),
142 joined: new Set(),
143 baselined: new Set(),
144 engineModels: new Map(),
145 forks: new Map(),
146 planMode: new Set(),
147 queue: Promise.resolve(),
148 }
149 const { opt, submitted, turns } = ctx
150
151 on('session.start', async ($, e, next) => {
152 const { version, base } = await $.session.version()
153 const entrypoint = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? ''
154 ctx.disabled = !versionAtLeast(base ?? version, MIN_VERSION)
155 ? `needs Claude Code ${MIN_VERSION}+`
156 : SDK_ENTRYPOINTS.includes(entrypoint)
157 ? 'off in claude -p and SDK runs'
158 : undefined
159 // The session's own model before any turn, so that a command's own model
160 // on the first one is told from it.
161 if (!ctx.disabled) {
162 const id = await $.session.id()
163 const own = await sessionModel($, ctx, id)
164 if (own) ctx.engineModels.set(id, own)
165 }
166 await $.command.register({
167 name: 'jcGearbox',
168 description: 'Jev model router: status, mode, pin, log, stats',
169 argumentHint: '[status | auto | shadow | off | on | pin <model> | log [n] | stats]',
170 })
171 // One command per action, so the typeahead lists them all; /jcGearbox <action> still works.
172 // The mode commands and /jcGearbox-pin are skills (skills/): listed before the session starts, served by this module.
173 const d = 'Jev model router:'
174 await $.command.register({ name: 'jcGearbox-status', description: `${d} this session's mode, model, effort and last request` })
175 await $.command.register({ name: 'jcGearbox-log', description: `${d} recent decisions, across sessions`, argumentHint: '[n]' })
176 await $.command.register({ name: 'jcGearbox-stats', description: `${d} how the logged decisions compare with the engine's own` })
177 await prune($)
178 await showMode($, ctx)
179 return next(e)
180 })
181
182 // A /clear, a /resume or a fork goes on under another session id with no
183 // session.start.
184 on('classic.SessionStart', async ($, e, next) => {
185 // The id the session goes on under: $.session.id() can still be the one it leaves.
186 if (e.model) ctx.engineModels.set(e.session_id, e.model)
187 if (e.source === 'resume' || e.source === 'fork') ctx.joined.add(e.session_id)
188 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
189 await prune($)
190 await showMode($, ctx)
191 }
192 return next(e)
193 })
194
195 on('prompt.submit', ($, e, next) => {
196 submitted.push({ text: e.text, isUser: USER_ORIGINS.has(e.origin.kind) })
197 if (submitted.length > 20) submitted.shift()
198 return next(e)
199 })
200
201 // A turn is the person's when it starts from the text they submitted; a
202 // slash command's turn starts from tags naming the command they typed.
203 on('turn.start', ($, e, next) => {
204 ctx.failure = undefined // half a report of the last turn's failure is not this turn's
205 const text = e.text.startsWith(NON_USER_FRAME) ? e.text.slice(NON_USER_FRAME.length) : e.text
206 const command = /^\s*(?:<command-message>[^<]*<\/command-message>\s*)?<command-name>(\/[^<]*)<\/command-name>/.exec(text)?.[1]
207 const i = submitted.findLastIndex(s => s.text === text || (command !== undefined && typedCommand(s.text, command)))
208 const hit = i >= 0 ? submitted.splice(i, 1)[0] : undefined
209 const isUser = hit?.isUser ?? false
210 const ultracode = isUser && typedUltracode(hit?.text ?? '')
211 const fields = { isUser, text: hit?.text ?? text, command: command !== undefined, ultracode }
212 // A turn whose first request came before it keeps what that request
213 // decides: the step still running holds this very object.
214 const turn = turns.get(e.turnId)
215 if (turn) Object.assign(turn, fields)
216 else turns.set(e.turnId, fields)
217 return next(e)
218 })
219
220 on('turn.step', async function* ($, e, next) {
221 if (e.agentId) {
222 // A fork of the main conversation shares it, so on the session's routed
223 // model it reads the conversation's prompt cache (fork_follows_route).
224 // Any other subagent, a fork that starts on another model than the
225 // session's own (one made in a command's turn, on the command's model),
226 // and a fork's step on the engine's fallback, is left alone.
227 if (!opt.forkFollowsRoute || ctx.disabled) return yield* next(e)
228 if (!ctx.forks.has(e.agentId)) {
229 const agent = (await $.agent.list()).find(a => a.id === e.agentId)
230 const session = await $.session.id()
231 const own = await sessionModel($, ctx, session)
232 const follows = agent?.type === 'fork' && !agent.parentId && (own === undefined || sameModel(e.model, own))
233 // Made in a plan-mode turn, it plans throughout: the floor holds for all its steps.
234 ctx.forks.set(e.agentId, follows ? { model: e.model, inPlan: ctx.planMode.has(session) } : null)
235 }
236 const fork = ctx.forks.get(e.agentId)
237 if (!fork || fork.model !== e.model || !(await apiKey($, opt))) return yield* next(e)
238 const [session, r] = await serial(ctx, () => record($))
239 const routed = (r.mode ?? opt.defaultMode) === 'auto' ? current(r) : undefined
240 const tiers = ctx.resolved?.session === session ? ctx.resolved.tiers : {}
241 const raised = raisedForPlan(e.model, await sessionBase($, fork.inPlan, e.model, tiers), tiers)
242 const model = floored(fork.inPlan, tiers, routed, e.model, raised, r.badModels)
243 return yield* next(model && movesTo(model, e.model) ? { ...e, model } : e)
244 }
245 if (ctx.disabled) return yield* next(e)
246 let turn = turns.get(e.turnId)
247 if (!turn) {
248 turn = { isUser: false, text: '' }
249 turns.set(e.turnId, turn)
250 }
251 let deciding: Promise<Plan | undefined> | undefined
252 if (!turn.plan) {
253 turn.baseModel = e.model
254 turn.baseEffort = e.effort
255 // The request goes as soon as its plan is known, which may be before
256 // Jev answers; a plan that fails leaves the turn as it is, asked once.
257 let early!: (p: Plan) => void
258 const known = new Promise<Plan>(resolve => (early = resolve))
259 deciding = plan($, ctx, e.model, e.effort, turn, next.signal, early).catch(() => undefined)
260 turn.decided = deciding
261 turn.plan = await Promise.race([known, deciding.then(p => p ?? { apply: false })])
262 } else if (e.model !== turn.baseModel) {
263 return tally(turn, yield* next(e)) // the engine fell back to another model: leave it
264 } else if (e.effort !== turn.baseEffort) {
265 turn.ownEffort = true
266 }
267
268 const { apply, model } = turn.plan
269 const effort = turn.ownEffort ? undefined : turn.plan.effort
270 const rewritesModel = apply && model !== undefined && movesTo(model, e.model)
271 const result =
272 !rewritesModel && !(apply && effort)
273 ? yield* next(e)
274 : yield* next({
275 ...e,
276 ...(rewritesModel ? { model } : {}),
277 ...(apply && effort ? { effort } : {}),
278 })
279 tally(turn, result)
280 if (deciding) {
281 // The decision the first request answers for, written down by now.
282 const logKey = (await deciding)?.logKey
283 if (logKey && result.usage) await servedAs($, ctx, logKey, rewritesModel ? model : e.model, result.usage.model)
284 }
285 if (rewritesModel && result.usage && !sameModel(result.usage.model, model)) {
286 turn.plan = { ...turn.plan, model: undefined }
287 turn.unanswered = undefined // blamed here, not again when the turn ends
288 await drop($, ctx, model, `${label(result.usage.model)} answered`)
289 } else if (rewritesModel && result.stopReason === 'refusal') {
290 // A model that declines the conversation would decline it again.
291 turn.plan = { ...turn.plan, model: undefined }
292 turn.unanswered = undefined
293 await drop($, ctx, model, 'refused')
294 } else if (rewritesModel) {
295 const noResponse = !result.usage && result.stopReason === null && !next.signal.aborted
296 turn.unanswered = noResponse ? model : undefined
297 if (result.usage) {
298 ctx.answered.add(model)
299 turn.answered = model
300 }
301 }
302 return result
303 })
304
305 on('turn.complete', async ($, e, next) => {
306 if (!e.agentId) {
307 const turn = turns.get(e.turnId)
308 turns.delete(e.turnId)
309 if (turn?.answered) await noteAnswered($, ctx, turn.answered)
310 // A request with no response is the model's doing (an unknown id, no
311 // access) only when the turn died of it: the engine retries others
312 // itself, a context overflow after compacting, say. Why it died comes
313 // with StopFailure, before or after this.
314 if (turn?.unanswered && e.reason === 'error') await failed($, ctx, { model: turn.unanswered })
315 // Also for a turn interrupted mid-request, whose step went no further.
316 const logKey = (await turn?.decided)?.logKey
317 const d = logKey ? ((await $.store.get(logKey)) as Decision | undefined) : undefined
318 if (logKey && d) await $.store.set(logKey, { ...d, outcome: { reason: e.reason, ...(turn?.tally ?? NO_REQUESTS) } })
319 if (e.answer && !ctx.disabled) {
320 const [id, r] = await record($)
321 if ((r.mode ?? opt.defaultMode) !== 'off' && (await apiKey($, opt))) {
322 // A fork's transcript ends with this reply: it names the session to go on from.
323 if (r.decided && e.answer.trim().length >= FORK_REPLY_CHARS) {
324 await $.store.set(`fork:${await fingerprint(e.answer)}`, id)
325 await trim($, 'fork:', KEEP)
326 }
327 if (opt.sendAssistantTail) {
328 await change($, ctx, latest => {
329 latest.replyTail = redact(e.answer).slice(-opt.tailChars)
330 })
331 }
332 }
333 }
334 }
335 return next(e)
336 })
337
338 // Why a turn of the main loop died of an API error, in Claude Code's own words.
339 on('classic.StopFailure', async ($, e, next) => {
340 if (!e.agent_id) await failed($, ctx, { error: e.error })
341 return next(e)
342 })
343
344 // A switch after the session's model was chosen is the person overriding
345 // it; one before (an app applying its picker at start) is the starting point.
346 // PostModelSwitch: only a switch that took effect, and only the person's
347 // (not a fallback or a plan mode toggled under opusplan, not a resume
348 // restoring the model).
349 on('classic.PostModelSwitch', async ($, e, next) => {
350 ctx.engineModels.set(await $.session.id(), e.to_model)
351 const byPerson = e.source === 'command' || e.source === 'picker' || e.source === 'sdk'
352 if (byPerson && e.from_model !== e.to_model) {
353 await joinedRecord($, ctx) // a resumed or forked conversation was decided before this
354 await change($, ctx, r => {
355 if (!r.decided) return false
356 r.userModel = true
357 delete r.pin
358 })
359 }
360 return next(e)
361 })
362
363 // The permission mode each prompt is sent in: in plan mode the engine runs a
364 // stronger model of its own, which the router's rewrite respects (floored).
365 on('classic.UserPromptSubmit', async ($, e, next) => {
366 const id = await $.session.id()
367 if (e.permission_mode === 'plan') ctx.planMode.add(id)
368 else ctx.planMode.delete(id)
369 return next(e)
370 })
371
372 // /model naming the model in use, or /effort naming a level, is the
373 // person's choice, which no switch reports; so is /model opusplan, whose
374 // model of the moment may well be the one in use. A switch to another model
375 // reports itself; a word that only shows something, the picker or a name
376 // that is no model or level is no choice, nor is a switch the engine
377 // refused (a hook blocked it, no 1M context), as far as its answer says so:
378 // the terminal's /model answers nothing. /effort auto (or unset) hands the
379 // effort back.
380 on('command.run', { command: ['model', 'effort'] }, async ($, e, next) => {
381 const result = await next(e)
382 const arg = e.args.trim()
383 if (!arg || ctx.disabled) return result
384 const [id] = await joinedRecord($, ctx)
385 if (e.command === 'effort' && /^(?:auto|unset)$/i.test(arg)) {
386 // Claude Code drops its own level: the next turn's is a new baseline,
387 // and the router goes on from the level the person last ran at.
388 await change($, ctx, r => {
389 if (r.rawEffort === undefined && !r.effortPinned) return false
390 unpinEffort(r, opt)
391 })
392 return result
393 }
394 const bare = (model: string) => model.replace(/\[\w+\]$/, '')
395 const engine = e.command === 'model' ? await sessionModel($, ctx, id) : undefined
396 // In plan mode the engine may plan on a stronger model than the main
397 // loop's, which /model shows: naming either is no switch it reports.
398 const shown = e.command === 'model' && ctx.planMode.has(id) ? await mainLoopModel($) : undefined
399 const named = e.command === 'model' ? resolveModel(arg, await resolution($, ctx, id)) : undefined
400 // Its answer, where there is one: "Set model to ...", after a substitution notice at most.
401 const refused = e.command === 'model' && !!result?.text && !/^(?:Model "[^\n]*\n)?Set model to /.test(result.text)
402 const chosen =
403 e.command === 'effort'
404 ? isEffort(arg.toLowerCase()) || /^\d+$/.test(arg)
405 : !refused && (OPUSPLAN.test(arg) || (!!named && [engine, shown].some(m => !!m && bare(named.id) === bare(m))))
406 if (chosen) {
407 await change($, ctx, r => {
408 if (!r.decided) return false
409 if (e.command === 'effort') r.effortPinned = true
410 else {
411 r.userModel = true
412 delete r.pin
413 }
414 })
415 }
416 return result
417 })
418
419 on('command.run', { command: 'jcGearbox' }, ($, e) => {
420 const [sub = '', arg] = e.args.trim().split(/\s+/)
421 return jcGearbox($, ctx, sub, arg)
422 })
423 on('command.run', { command: 'jcGearbox-status' }, $ => jcGearbox($, ctx, 'status', undefined))
424 on('command.run', { command: named('jcGearbox-auto') }, $ => jcGearbox($, ctx, 'auto', undefined))
425 on('command.run', { command: named('jcGearbox-shadow') }, $ => jcGearbox($, ctx, 'shadow', undefined))
426 on('command.run', { command: named('jcGearbox-off') }, $ => jcGearbox($, ctx, 'off', undefined))
427 on('command.run', { command: named('jcGearbox-on') }, $ => jcGearbox($, ctx, 'on', undefined))
428 on('command.run', { command: named('jcGearbox-pin') }, ($, e) => jcGearbox($, ctx, 'pin', e.args.trim().split(/\s+/)[0] || undefined))
429 on('command.run', { command: 'jcGearbox-log' }, ($, e) => jcGearbox($, ctx, 'log', e.args.trim().split(/\s+/)[0] || undefined))
430 on('command.run', { command: 'jcGearbox-stats' }, $ => jcGearbox($, ctx, 'stats', undefined))
431}
432
433async function jcGearbox($: EngineInterface, ctx: Ctx, sub: string, arg: string | undefined) {
434 const { opt } = ctx
435 if (sub === 'log') {
436 const n = Number.parseInt(arg ?? '', 10)
437 return { text: await logText($, n > 0 ? n : 20) }
438 }
439 if (sub === 'stats') return { text: await statsText($, opt) }
440 const [id, r] = await joinedRecord($, ctx)
441 if (sub === '' || sub === 'status') {
442 const tiers = ctx.disabled ? undefined : (await resolution($, ctx, id)).tiers
443 return { text: report(r, opt, tiers) }
444 }
445 // Back on drops the session's own mode (auto when the default is off) and
446 // keeps any pin: it undoes /jcGearbox-off, not /model or /jcGearbox-pin.
447 if (sub === 'on') {
448 const latest = await change($, ctx, r => {
449 if (opt.defaultMode === 'off') r.mode = 'auto'
450 else delete r.mode
451 })
452 await showMode($, ctx)
453 return { text: `jcGearbox: ${latest.mode ?? opt.defaultMode} for this session.` }
454 }
455 if (sub === 'auto' || sub === 'shadow' || sub === 'off') {
456 await change($, ctx, r => {
457 r.mode = sub
458 if (sub === 'auto') {
459 delete r.pin
460 delete r.userModel
461 unpinEffort(r, opt)
462 delete r.badModels // asked for again: an account's trouble may have passed
463 }
464 })
465 await showMode($, ctx)
466 return { text: sub === 'auto' ? 'jcGearbox: auto for this session; pins cleared, unused models asked for again.' : `jcGearbox: ${sub} for this session.` }
467 }
468 if (sub === 'pin' && arg) {
469 const pinned = resolveModel(arg, await resolution($, ctx, id))
470 if (!pinned) return { text: `jcGearbox: cannot resolve "${arg}" here; give a full model id.` }
471 if (r.badModels?.includes(pinned.id)) {
472 return { text: `jcGearbox: ${pinned.id} was not used when asked for earlier in this session; not pinning it (/jcGearbox-auto asks for it again).` }
473 }
474 await change($, ctx, r => {
475 r.pin = pinned.id
476 delete r.userModel
477 })
478 return { text: `jcGearbox: model pinned to ${pinned.id} for this session (/jcGearbox-auto clears it).` }
479 }
480 return { text: USAGE }
481}
482
483async function record($: EngineInterface): Promise<[string, SessionRecord]> {
484 const id = await $.session.id()
485 return [id, ((await $.store.get(`session:${id}`)) as SessionRecord | undefined) ?? {}]
486}
487
488// keys() lists keys in the order they were first set: deleting first puts
489// this session last, so pruning drops the sessions used longest ago.
490async function save($: EngineInterface, id: string, r: SessionRecord) {
491 await $.store.delete(`session:${id}`)
492 await $.store.set(`session:${id}`, r)
493}
494
495// $.store keeps each read and write whole but has no transactions: one hook
496// at a time reads the session's record, changes it and saves it.
497function serial<T>(ctx: Ctx, fn: () => Promise<T>): Promise<T> {
498 const run = ctx.queue.then(fn)
499 ctx.queue = run.catch(() => undefined)
500 return run
501}
502
503// Saves the session's record as `edit` changes it, unless it says no change.
504function change($: EngineInterface, ctx: Ctx, edit: (r: SessionRecord) => boolean | void): Promise<SessionRecord> {
505 return serial(ctx, async () => {
506 const [id, r] = await record($)
507 if (edit(r) !== false) await save($, id, r)
508 return r
509 })
510}
511
512async function apiKey($: EngineInterface, opt: Options) {
513 return opt.apiKey || (await $.env.get('TYPESAFE_API_KEY'))
514}
515
516// Aliases resolve once per session: the catalog is Claude Code's own model
517// picker, which only a first-party login has.
518async function resolution($: EngineInterface, ctx: Ctx, session: string) {
519 if (ctx.resolved?.session === session) return ctx.resolved
520 const firstParty = (await $.session.authorize()) !== null
521 const { version, base } = await $.session.version()
522 const catalog = firstParty ? await readCatalog($) : undefined
523 const resolveCtx = { firstParty, catalog, version: base ?? version }
524 const tiers: Tiers = {}
525 for (const tier of TIERS) {
526 const r = resolveModel(ctx.opt.tierModels[tier], resolveCtx)
527 if (r) tiers[tier] = r
528 }
529 ctx.resolved = { session, ...resolveCtx, tiers }
530 return ctx.resolved
531}
532
533async function plan(
534 $: EngineInterface,
535 ctx: Ctx,
536 model: string,
537 rawEffort: Effort | number | undefined,
538 turn: Turn,
539 signal: AbortSignal,
540 early: (request: Plan) => void,
541): Promise<Plan> {
542 const { opt } = ctx
543 const [id, joined] = await joinedRecord($, ctx)
544 const mode = joined.mode ?? opt.defaultMode
545 if (mode === 'off') {
546 $.ui.status(undefined)
547 return { apply: false }
548 }
549 const key = await apiKey($, opt)
550 if (!key) {
551 $.ui.status('no API key')
552 return { apply: false }
553 }
554
555 const first = !joined.decided
556 // Shadow sends every request as it is; after the first turn the model is
557 // set, so a pinned effort, or a model that takes none, leaves Jev nothing
558 // to change. The request goes ahead and the answer is written down after.
559 if (mode === 'shadow') early({ apply: false })
560 const { tiers } = await resolution($, ctx, id)
561 const inPlan = ctx.planMode.has(id)
562 // A command's own model is not the session's.
563 const own = turn.command ? await sessionModel($, ctx, id) : undefined
564 const base = await sessionBase($, inPlan, own ?? model, tiers)
565 const raised = raisedForPlan(model, base, tiers)
566 if (mode !== 'shadow' && turn.isUser && !first && !turn.command) {
567 const routed = floored(inPlan, tiers, current(joined), model, raised, joined.badModels)
568 if (joined.effortPinned || !takesEffort(routed ?? model)) early({ apply: true, model: routed })
569 }
570 const offered = TIERS.filter(t => tiers[t])
571 // The tiers the session's own model stands for: the first turn's pick is
572 // made relative to them. The engine's tier is always among the choices,
573 // with or without a model of its own, so that Jev can give "stay on this
574 // model" its share: asked about weaker tiers only, its answer would add up
575 // to one below the engine and the session would move down whatever the task.
576 const anchor = first ? engineTiers(base ?? own ?? model, tiers) : []
577 const choices = TIERS.filter(t => offered.includes(t) || anchor.includes(t))
578 let asked: { result?: JevResult; error?: string } | undefined
579 let jevMs: number | undefined
580 if (turn.isUser) {
581 const distinct = new Set(offered.map(t => tiers[t]?.id)).size
582 const body = buildRequest({
583 jevModel: opt.jevModel,
584 prompt: turn.text,
585 isFirstTurn: first,
586 tiers: distinct >= 2 ? choices : undefined,
587 previousPrompt: joined.prompt,
588 replyTail: opt.sendAssistantTail ? joined.replyTail?.slice(-opt.tailChars) : undefined,
589 // A Bedrock ARN names the AWS account: what follows it is enough.
590 previous: first ? undefined : { model: (current(joined) ?? model).replace(/^arn:[^/]*\//, ''), effort: lastEffort(joined) },
591 })
592 const started = await $.clock.now()
593 asked = await askJev($, key, body, signal, opt.jevTimeoutMs)
594 // The first turn picks the session's model: a quick network error or 5xx
595 // is asked again while a second is left.
596 const left = opt.jevTimeoutMs - ((await $.clock.now()) - started)
597 if (first && /^(network error|HTTP 5\d\d)$/.test(asked.error ?? '') && left > 1000 && !signal.aborted) {
598 asked = await askJev($, key, body, signal, left)
599 }
600 jevMs = (await $.clock.now()) - started
601 if (signal.aborted) return { apply: false } // interrupted: nothing decided, the next prompt asks again
602 }
603
604 // Read again, to keep what /model or /jcGearbox changed while Jev answered.
605 return serial(ctx, async (): Promise<Plan> => {
606 const [, r] = await record($)
607
608 // The engine's effort is the session's setting or the model's default each
609 // turn; ours only ever rides one request. A change between two turns on the
610 // same model is the person's doing, once this process has seen the one
611 // before (a resumed session's may have changed for other reasons). A
612 // command's own effort and model ride its turn alone: they are left as
613 // they are and pin nothing (and with no baseline to tell, a command's
614 // effort is left as it is too).
615 const raw = rawEffort ?? null
616 const baseline = ctx.baselined.has(id) && r.rawEffort !== undefined && r.rawModel === model
617 const changed = baseline && r.rawEffort !== raw
618 const ownModel = !!turn.command && own !== undefined && !sameModel(model, own)
619 if (!turn.command) {
620 ctx.engineModels.set(id, model)
621 if (changed) r.effortPinned = true
622 if (r.effortPinned) r.ranPinned = true
623 else delete r.ranPinned
624 r.rawEffort = raw
625 r.rawModel = model
626 ctx.baselined.add(id)
627 }
628
629 let decided: ReturnType<typeof decideEffort> | undefined
630 if (asked) {
631 decided = decideEffort({
632 signals: asked.result?.signals,
633 // The effort the last turn ran at: the person's own while they hold it
634 // (lastEffort), else the router's; before either, the engine's.
635 previous: isEffort(lastEffort(r)) ? (lastEffort(r) as Effort) : asked.result && isEffort(r.rawEffort) ? r.rawEffort : undefined,
636 isFirstTurn: first,
637 threshold: opt.signalThreshold,
638 continuationThreshold: opt.continuationThreshold,
639 minEffort: opt.minEffort,
640 maxEffort: opt.maxEffort,
641 minConfidence: opt.effortMinConfidence,
642 })
643 if (first) {
644 r.decided = true
645 const ids: Partial<Record<Tier, string>> = {}
646 for (const t of offered) ids[t] = tiers[t]?.id
647 // A tier asked about and left out of the answer got no share of it.
648 const given = asked.result?.tierProbabilities
649 r.model = pickModel({
650 probabilities: given && Object.fromEntries(choices.map(t => [t, given[t] ?? 0])),
651 models: ids,
652 engine: anchor,
653 upgrade: opt.tierUpgradeProbability,
654 downgrade: opt.tierMinProbability,
655 })
656 }
657 // Jev's own effort is written down each turn, for the pick to show. While
658 // the person holds the effort it is not the router's: the next turn goes
659 // on from the level that actually ran.
660 if (decided.effort) {
661 r.jevEffort = decided.effort
662 if (!r.effortPinned) r.effort = decided.effort
663 }
664 r.prompt = clip(redact(turn.text), CONTEXT_CHARS)
665 delete r.replyTail // the reply to this prompt, if it shows one, takes its place
666 }
667
668 // The model the request goes on: the session's, floored in plan mode.
669 const chosen = ownModel ? undefined : floored(inPlan, tiers, current(r), model, raised, r.badModels)
670 // Said only where it changed what runs: an effort of the router's, on a model that takes one.
671 const unsure = !!decided?.unsure && !r.effortPinned && takesEffort(chosen ?? model)
672 // A command with no baseline to tell its own effort from the session's takes
673 // the engine's effort onto the new model for this request only: not recorded.
674 const effort =
675 r.effortPinned || (turn.command && changed)
676 ? undefined
677 : turn.command && !baseline
678 ? effortFor(chosen ?? model, isEffort(raw) ? raw : undefined)
679 : effortFor(chosen ?? model, r.effort)
680 // The ultracode keyword only lets Claude Code run workflows this turn; the
681 // model and effort are routed as on any other turn.
682 const auto = mode === 'auto'
683 // Decided: the request goes now, and the decision is written down after.
684 early({ apply: auto, model: chosen, effort })
685 const sentModel = auto && chosen ? chosen : model
686 // Claude Code takes a request's effort only where it differs from the one
687 // it worked out for its own model: on another model, that level counts as
688 // none. A model rewritten without an effort of ours runs at the level the
689 // person set, else at its own default, so the level is known only when the
690 // engine's was not its model's default (the person's, then) or both
691 // models' defaults agree.
692 const moved = auto && chosen !== undefined && movesTo(chosen, model)
693 const dropped = moved && effort !== undefined && effort === rawEffort && rawEffort === defaultEffort(model) && defaultEffort(chosen) !== effort
694 const unknown = moved && (!effort || dropped) && !r.effortPinned && !(turn.command && changed)
695 const sentEffort = auto && effort && !dropped ? effort : unknown ? '?' : (rawEffort ?? null)
696 const sent = { model: sentModel, effort: takesEffort(sentModel) ? sentEffort : null }
697 const why = [
698 ...(auto ? [] : ['shadow']),
699 ...(turn.ultracode ? ['ultracode'] : []),
700 ...(r.pin ? ['pin'] : r.userModel ? ['/model'] : []),
701 ...(r.effortPinned ? ['effort pinned'] : []),
702 ...(ownModel ? ['command model'] : []),
703 ...(auto && !ownModel && chosen !== current(r) ? ['plan mode'] : []),
704 ...(unsure ? ['Jev unsure'] : []),
705 ]
706 // Jev's own pick: the first turn's model, at the effort Jev decided (the
707 // sent one when it decided none).
708 const jevModel = r.model ?? model
709 const jevEffort = takesEffort(jevModel) ? (effortFor(jevModel, r.jevEffort ?? r.effort) ?? sent.effort ?? defaultEffort(sent.model) ?? '-') : '-'
710 const pick = `${label(jevModel)}·${jevEffort}`
711 let logKey: string | undefined
712 if (asked) {
713 let limits: Record<string, number> = {}
714 try {
715 limits = Object.fromEntries((await $.session.usage()).rateLimits.map(l => [l.kind, l.percentUsed]))
716 } catch {
717 // a Claude Code that cannot tell leaves them out
718 }
719 const error = asked.error
720 r.last = {
721 at: await $.clock.now(),
722 mode,
723 first,
724 prompt: redact(turn.text).slice(0, 200),
725 promptLength: turn.text.length,
726 jev: asked.result ?? null,
727 ...(error ? { error } : {}),
728 ...(r.model ? { model: r.model } : {}),
729 ...(decided?.effort ? { effort: decided.effort } : {}),
730 engine: { model, effort: raw },
731 ...(jevMs !== undefined ? { jevMs } : {}),
732 ...(Object.keys(limits).length > 0 ? { limits } : {}),
733 sent,
734 pick,
735 ...(why.length > 0 ? { why } : {}),
736 }
737 logKey = `log:${r.last.at}:${id.slice(0, 8)}:${ctx.logged++}`
738 await $.store.set(logKey, { ...r.last, session: id })
739 await trim($, 'log:', KEEP)
740 }
741 await save($, id, r)
742
743 // Only the model and effort: in auto what was sent, in shadow Jev's pick
744 // (what was sent is the session's own). The why is in /jcGearbox-log; a Jev
745 // error is said, or an unjudged prompt would look judged.
746 const head = auto ? '' : `${mode} ▸ `
747 $.ui.status(`${head}${auto ? (sent.effort === '?' ? `${label(sent.model)}·${jevEffort === '?' ? '-' : jevEffort}` : sentLabel(sent)) : pick}${asked?.error ? ` · Jev ${asked.error}` : ''}`)
748
749 return { apply: auto, model: chosen, effort, logKey }
750 })
751}
752
753// The tier a model's family stands for in Anthropic's matrix; none for an id
754// that names no family.
755const FAMILY_TIERS = [['haiku', 'low'], ['sonnet', 'balanced'], ['opus', 'strong'], ['fable', 'long'], ['mythos', 'long']] as const
756const familyTier = (model: string | undefined): Tier | undefined => FAMILY_TIERS.find(([name]) => model?.includes(name))?.[1]
757
758// The tiers the engine's model is: those naming it, else its family's.
759function engineTiers(model: string, tiers: Tiers): Tier[] {
760 const id = model.replace(/\[\w+\]$/, '')
761 const named = TIERS.filter(t => tiers[t]?.id.replace(/\[\w+\]$/, '') === id)
762 if (named.length > 0) return named
763 const tier = familyTier(id)
764 return tier ? [tier] : []
765}
766
767// The model the session's picks are measured from: the one it runs on outside
768// plan mode. In plan mode the engine itself runs a stronger one (Opus under
769// opusplan, Sonnet for a session on the haiku alias), while the main loop's
770// model, as /model shows it, still names the one chosen: Sonnet under opusplan,
771// Haiku on the alias. None where the engine cannot tell.
772async function sessionBase($: EngineInterface, inPlan: boolean, model: string, tiers: Tiers): Promise<string | undefined> {
773 if (!inPlan) return model
774 const own = await mainLoopModel($)
775 if (!own) return undefined
776 return rankOf(own, tiers) >= 0 && rankOf(own, tiers) < rankOf(model, tiers) ? own : model
777}
778
779// The main loop's model, as /model shows it.
780async function mainLoopModel($: EngineInterface): Promise<string | undefined> {
781 try {
782 return await $.session.model()
783 } catch {
784 return undefined // a Claude Code that cannot tell
785 }
786}
787
788// Whether plan mode raised the session to the model the engine runs: one
789// stronger than the session's own outside plan mode. Where the engine cannot
790// tell, it may have.
791function raisedForPlan(model: string, base: string | undefined, tiers: Tiers): boolean {
792 return base === undefined || rankOf(model, tiers) > rankOf(base, tiers)
793}
794
795// The highest rung the model stands on, or -1 for one that names no tier.
796function rankOf(model: string, tiers: Tiers): number {
797 const ranks = engineTiers(model, tiers).map(tierRank)
798 return ranks.length > 0 ? Math.max(...ranks) : -1
799}
800
801// In plan mode the engine itself runs a stronger model (opusplan's Opus, and
802// Sonnet for a session on Haiku): the router then rewrites to nothing weaker
803// than the balanced tier (one the API answers as), and never pulls a model the
804// engine raised back down. A session of its own on a stronger model was raised
805// by nothing: it plans on the routed model, unless that stays below the
806// balanced tier for want of a balanced model. A model that names no tier is
807// below nothing.
808function floored(inPlan: boolean, tiers: Tiers, planned: string | undefined, engineModel: string, raised: boolean, bad: readonly string[] = []): string | undefined {
809 if (planned === undefined || !inPlan) return planned
810 const rank = rankOf(planned, tiers)
811 if (rank < 0) return planned
812 const balanced = tiers.balanced && !bad.includes(tiers.balanced.id) ? tiers.balanced.id : undefined
813 const target = balanced && rank < tierRank('balanced') ? balanced : planned
814 const stands = raised || rankOf(target, tiers) < tierRank('balanced')
815 return stands && rankOf(engineModel, tiers) > rankOf(target, tiers) ? undefined : target
816}
817
818// The model the session runs on: a pin, else the first turn's choice unless
819// the person switched since; never one the API did not answer as.
820function current(r: SessionRecord): string | undefined {
821 const model = r.pin ?? (r.userModel ? undefined : r.model)
822 return model && !r.badModels?.includes(model) ? model : undefined
823}
824
825// The session's own model: as its start, its last plain turn or a switch left
826// it, else as the engine reports it now.
827async function sessionModel($: EngineInterface, ctx: Ctx, id: string): Promise<string | undefined> {
828 const known = ctx.engineModels.get(id)
829 if (known) return known
830 try {
831 return await $.session.model()
832 } catch {
833 return undefined // a Claude Code that cannot tell
834 }
835}
836
837// The session's record. A resumed or forked conversation it has none of is
838// not a new one: a fork goes on as the session it came from; any other keeps
839// its model rather than one picked from a prompt in mid-conversation. Done
840// once, before a command or the first decision touches the record.
841function joinedRecord($: EngineInterface, ctx: Ctx): Promise<[string, SessionRecord]> {
842 return serial(ctx, async (): Promise<[string, SessionRecord]> => {
843 const [id, r] = await record($)
844 if (r.decided || !ctx.joined.delete(id)) return [id, r]
845 const joined = { ...(await parentRecord($, id)), ...r, decided: true }
846 await save($, id, joined)
847 return [id, joined]
848 })
849}
850
851// The record of the session a fork came from, named by the newest of the
852// transcript's long replies that a session wrote down (the last 20 tried); the
853// engine's model and effort of its last turn stay behind.
854async function parentRecord($: EngineInterface, id: string): Promise<SessionRecord | undefined> {
855 const known = new Set((await $.store.keys()).filter(k => k.startsWith('fork:')))
856 const replies = (await $.session.messages()).filter(m => m.role === 'assistant' && m.text.trim().length >= FORK_REPLY_CHARS)
857 for (const reply of replies.slice(-20).reverse()) {
858 const key = `fork:${await fingerprint(reply.text)}`
859 if (!known.has(key)) continue
860 const parent = await $.store.get(key)
861 if (typeof parent !== 'string' || parent === id) return undefined
862 const found = (await $.store.get(`session:${parent}`)) as SessionRecord | undefined
863 return found?.decided ? { ...found, rawEffort: undefined, rawModel: undefined, ranPinned: undefined, last: undefined } : undefined
864 }
865 return undefined
866}
867
868// A short hash of a reply's end, which a fork's transcript repeats as it was.
869async function fingerprint(text: string): Promise<string> {
870 const end = new TextEncoder().encode(text.trim().slice(-FORK_REPLY_CHARS))
871 const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', end))
872 return [...hash.slice(0, 8)].map(b => b.toString(16).padStart(2, '0')).join('')
873}
874
875// The effort the last turn ran at: the person's own once they pinned it.
876function lastEffort(r: SessionRecord): string | undefined {
877 return r.effortPinned && typeof r.rawEffort === 'string' ? r.rawEffort : r.effort
878}
879
880// The person hands the effort back: the router goes on from the level the
881// last turn ran at, within its bounds. That is theirs once a turn ran at it
882// (a number among them: then from none in particular), else the router's
883// own, which stays as it is.
884function unpinEffort(r: SessionRecord, opt: Options) {
885 if (r.effortPinned && (r.ranPinned || r.effort === undefined)) {
886 if (isEffort(r.rawEffort)) r.effort = clampEffort(r.rawEffort, opt.minEffort, opt.maxEffort)
887 else delete r.effort
888 }
889 delete r.ranPinned
890 delete r.effortPinned
891 delete r.rawEffort
892}
893
894async function drop($: EngineInterface, ctx: Ctx, model: string, answered: string) {
895 await change($, ctx, r => {
896 if (r.badModels?.includes(model)) return false
897 r.badModels = [...(r.badModels ?? []), model]
898 })
899 $.ui.status(`${label(model)} was not used (${answered}); not switching to it again`)
900}
901
902// Errors that say nothing of the model: ones Claude Code itself takes for
903// passing (an overload, a server error), and the account's own, which any
904// model would meet (billing, an account on hold or barred from OAuth, cloud
905// credentials that would not load).
906const NOT_THE_MODEL = new Set(['overloaded', 'server_error', 'billing_error', 'account_on_hold', 'oauth_org_not_allowed', 'cloud_credential_error'])
907// Errors that are the account's once the model has answered in the session,
908// and before that how a model the account cannot use fails: one not enabled on
909// Bedrock or Vertex (a failed sign-in), one billed to usage credits the
910// account lacks (a rate limit).
911const UNLESS_ANSWERED = new Set(['authentication_failed', 'verification_required', 'rate_limit'])
912
913// Blames the model a turn's rewritten request named once both reports of the
914// turn's failure are in. Any other error is its doing, unless it has answered
915// since its last such turn, which is given one more.
916async function failed($: EngineInterface, ctx: Ctx, report: Failure) {
917 const { model, error } = { ...ctx.failure, ...report }
918 if (model === undefined || error === undefined) {
919 ctx.failure = { model, error }
920 return
921 }
922 ctx.failure = undefined
923 if (NOT_THE_MODEL.has(error)) return
924 if (UNLESS_ANSWERED.has(error) && (await record($))[1].answered?.includes(model)) return
925 if (!ctx.answered.delete(model)) await drop($, ctx, model, error === 'unknown' ? 'no response' : error.replace(/_/g, ' '))
926}
927
928// Writes down, once, that a model answered a rewritten request of the session.
929// In place: a write the store will not take leaves the record as it was, and
930// the model's next answer tries again.
931async function noteAnswered($: EngineInterface, ctx: Ctx, model: string) {
932 const key = `${await $.session.id()} ${model}`
933 if (ctx.noted.has(key)) return
934 try {
935 await serial(ctx, async () => {
936 const [id, r] = await record($)
937 if (!r.answered?.includes(model)) await $.store.set(`session:${id}`, { ...r, answered: [...(r.answered ?? []), model] })
938 })
939 ctx.noted.add(key)
940 } catch {
941 // not written down
942 }
943}
944
945// The API answered as another model than the one the request named: say so
946// in that turn's log line and in /jcGearbox.
947async function servedAs($: EngineInterface, ctx: Ctx, logKey: string, sent: string, answered: string) {
948 if (sameModel(answered, sent)) return
949 const d = (await $.store.get(logKey)) as (Decision & { session: string }) | undefined
950 if (!d) return
951 await $.store.set(logKey, { ...d, served: answered })
952 await change($, ctx, r => {
953 if (r.last?.at !== d.at) return false
954 r.last.served = answered
955 })
956}
957
958const takesEffort = (model: string) => defaultEffort(model) !== undefined
959
960// Counts a request toward its turn's log entry.
961function tally(turn: Turn, result: TurnStepResult): TurnStepResult {
962 const t = (turn.tally ??= { ...NO_REQUESTS })
963 t.steps++
964 t.stop = result.stopReason
965 t.input += result.usage?.input_tokens ?? 0
966 t.output += result.usage?.output_tokens ?? 0
967 t.cacheRead += result.usage?.cache_read_input_tokens ?? 0
968 t.cacheWrite += result.usage?.cache_creation_input_tokens ?? 0
969 return result
970}
971
972// Claude Code's own test for a typed keyword: not in a slash command, not
973// inside quotes or brackets, not part of a path, a flag or a question about it.
974const CLOSERS: Record<string, string> = { '`': '`', '"': '"', '<': '>', '{': '}', '[': ']', '(': ')', "'": "'" }
975function typedUltracode(text: string): boolean {
976 if (text.startsWith('/') || !/ultracode/i.test(text)) return false
977 const word = (c: string | undefined) => !!c && /[\p{L}\p{N}_]/u.test(c)
978 const spans: [number, number][] = []
979 let open: string | null = null
980 let from = 0
981 for (let i = 0; i < text.length; i++) {
982 const c = text[i] as string
983 if (open) {
984 if (open === '[' && c === '[') from = i
985 else if (c === CLOSERS[open] && !(open === "'" && word(text[i + 1]))) {
986 spans.push([from, i + 1])
987 open = null
988 }
989 } else if ((c === '<' && /[a-zA-Z/]/.test(text[i + 1] ?? '')) || (c === "'" && !word(text[i - 1])) || (c !== '<' && c !== "'" && c in CLOSERS)) {
990 open = c
991 from = i
992 }
993 }
994 for (const m of text.matchAll(/\bultracode\b/gi)) {
995 const at = m.index
996 const end = at + m[0].length
997 const before = text[at - 1]
998 const after = text[end]
999 if (spans.some(([a, b]) => at >= a && at < b)) continue
1000 if (before === '/' || before === '\\' || before === '-') continue
1001 if (after === '/' || after === '\\' || after === '-' || after === '?') continue
1002 if (after === '.' && word(text[end + 1])) continue
1003 return true
1004 }
1005 return false
1006}
1007
1008// "/review x" as typed starts a turn naming "/review", or "/plugin:review"
1009// once the engine resolves a short name.
1010function typedCommand(typed: string, name: string): boolean {
1011 const word = typed.trimStart().split(/\s/, 1)[0] ?? ''
1012 return word.startsWith('/') && (word === name || name.endsWith(`:${word.slice(1)}`))
1013}
1014
1015// The status line before the session's next decision: its mode, or why the
1016// router stands aside.
1017async function showMode($: EngineInterface, ctx: Ctx) {
1018 const [, r] = await record($)
1019 const mode = r.mode ?? ctx.opt.defaultMode
1020 const key = await apiKey($, ctx.opt)
1021 $.ui.status(ctx.disabled ?? (mode === 'off' ? undefined : !key ? 'no API key' : mode === 'auto' ? 'auto' : mode))
1022}
1023
1024const pct = (p: number) => p.toFixed(2)
1025// No effort on the request means the model's default, which is what runs.
1026const sentLabel = (sent: NonNullable<Decision['sent']>) => `${label(sent.model)}·${sent.effort ?? defaultEffort(sent.model) ?? '-'}`
1027const notesOf = (d: Decision) => [...(d.why ?? []), ...(d.served ? [`served ${label(d.served)}`] : [])].map(n => ` · ${n}`).join('')
1028
1029function report(r: SessionRecord, opt: Options, tiers: Tiers | undefined): string {
1030 const { defaultMode } = opt
1031 const dropped = (m: string) => (r.badModels?.includes(m) ? '; not served, no longer asked for' : '')
1032 const model = r.pin
1033 ? `pinned to ${r.pin}${dropped(r.pin)}`
1034 : r.userModel
1035 ? 'your choice (/model)'
1036 : !r.decided
1037 ? 'not chosen yet'
1038 : r.model
1039 ? `${r.model} (chosen on the first turn${dropped(r.model)})`
1040 : "the session's own (no clear pick)"
1041 const lines = [
1042 `jcGearbox: ${r.mode ?? defaultMode} (${r.mode ? 'this session' : 'default'})`,
1043 `model: ${model}`,
1044 `effort: ${r.effortPinned ? 'yours (you changed it)' : (r.effort ?? 'not set yet')}`,
1045 ]
1046 if (tiers) {
1047 const named = (t: Tier) => {
1048 const m = tiers[t]
1049 // A value that resolves to nothing but is not off is an alias, which needs an Anthropic login.
1050 if (!m) return /^(off)?$/i.test(opt.tierModels[t]?.trim() ?? '') ? 'off' : 'off (alias needs an Anthropic login)'
1051 const notes = [...(m.source === 'builtin' ? ['built-in'] : []), ...(r.badModels?.includes(m.id) ? ['not served'] : [])]
1052 return `${m.id.replace(/^claude-/, '')}${notes.length > 0 ? ` (${notes.join(', ')})` : ''}`
1053 }
1054 lines.push(`tiers: ${[...TIERS].reverse().map(t => `${t} ${named(t)}`).join(' · ')}`)
1055 }
1056 const last = r.last
1057 if (last) {
1058 const s = last.jev?.signals
1059 const parts: string[] = []
1060 const tiers = last.jev?.tierProbabilities
1061 if (tiers) parts.push(`tier ${Object.entries(tiers).map(([t, p]) => `${t} ${pct(p)}`).join(' / ')}`)
1062 if (s?.effortScore !== undefined) parts.push(`effort ${pct(s.effortScore)}`)
1063 if (s?.continuation !== undefined) parts.push(`continuation ${pct(s.continuation)}`)
1064 if (s?.escalate !== undefined) parts.push(`complaint ${pct(s.escalate)}`)
1065 if (s?.highStakes !== undefined) parts.push(`high stakes ${pct(s.highStakes)}`)
1066 const by = last.error ? ` (Jev ${last.error})` : last.jev?.version ? ` (${last.jev.version})` : ''
1067 lines.push(`last turn: ${parts.join(' · ') || 'no answer'}${by}`)
1068 if (last.sent) lines.push(`sent: ${sentLabel(last.sent)}${notesOf(last)}`)
1069 }
1070 return lines.join('\n')
1071}
1072
1073async function logText($: EngineInterface, n: number): Promise<string> {
1074 const pad = (v: number) => String(v).padStart(2, '0')
1075 const lines: string[] = []
1076 for (const key of (await $.store.keys()).filter(k => k.startsWith('log:')).slice(-n)) {
1077 const d = (await $.store.get(key)) as Decision | undefined
1078 if (!d) continue
1079 const t = new Date(d.at)
1080 const when = `${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}`
1081 const flags = `${d.first ? ' first' : ''}${d.error ? ` Jev ${d.error}` : ''}`
1082 const jev = d.pick ?? `${label(d.model ?? d.sent?.model)}·${d.effort ?? '-'}`
1083 const shown = d.sent ? sentLabel(d.sent) : jev
1084 const other = shown === jev ? '' : ` jev ${jev}`
1085 lines.push(`${when} ${d.mode.padEnd(6)} ${shown}${other}${notesOf(d)}${flags} ${JSON.stringify(d.prompt.slice(0, 60))}`)
1086 }
1087 // The prompts as typed, escaped and fenced, so none of them reads as markdown.
1088 return lines.length > 0 ? ['```', ...lines, '```'].join('\n') : 'jcGearbox: no decisions logged yet.'
1089}
1090
1091// How the logged decisions Jev answered compare with what the engine would
1092// have run (in shadow the sent request is the engine's own, in auto the log
1093// keeps it), and how their turns went.
1094async function statsText($: EngineInterface, opt: Options): Promise<string> {
1095 const ds: (Decision & { session?: string })[] = []
1096 for (const key of (await $.store.keys()).filter(k => k.startsWith('log:'))) {
1097 const d = (await $.store.get(key)) as (Decision & { session?: string }) | undefined
1098 if (d) ds.push(d)
1099 }
1100 if (ds.length === 0) return 'jcGearbox: no decisions logged yet.'
1101 const engine = (d: Decision) => d.engine ?? (d.mode === 'auto' ? undefined : d.sent)
1102 const pct = (n: number, of: number) => `${Math.round((n / of) * 100)}%`
1103 const day = (at: number) => {
1104 const t = new Date(at)
1105 return `${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`
1106 }
1107 const ats = ds.map(d => d.at)
1108
1109 // A first turn moved up or down the tiers, or kept the engine's model.
1110 const rank = (model: string | undefined) => {
1111 const tier = familyTier(model)
1112 return tier ? tierRank(tier) : undefined
1113 }
1114 const models = { up: 0, same: 0, down: 0 }
1115 const answered = ds.filter(d => d.jev)
1116 for (const d of answered.filter(d => d.first)) {
1117 const from = rank(engine(d)?.model)
1118 const to = rank(d.model ?? engine(d)?.model)
1119 if (from === undefined || to === undefined) continue
1120 models[to > from ? 'up' : to < from ? 'down' : 'same']++
1121 }
1122 const efforts = { higher: 0, same: 0, lower: 0 }
1123 for (const d of answered) {
1124 const from = engine(d)?.effort
1125 if (!isEffort(d.effort) || !isEffort(from)) continue
1126 const diff = EFFORTS.indexOf(d.effort) - EFFORTS.indexOf(from)
1127 efforts[diff > 0 ? 'higher' : diff < 0 ? 'lower' : 'same']++
1128 }
1129
1130 const errors = ds.filter(d => d.error).length
1131 const unsure = ds.filter(d => d.why?.includes('Jev unsure')).length
1132 const times = answered.flatMap(d => (d.jevMs === undefined ? [] : [d.jevMs])).sort((a, b) => a - b)
1133 const at = (q: number) => times[Math.max(0, Math.ceil(q * times.length) - 1)]
1134 const timing = times.length > 0 ? ` · answers took p50 ${at(0.5)} ms, p95 ${at(0.95)} ms` : ''
1135 const later = answered.filter(d => !d.first)
1136 const complaints = later.filter(d => (d.jev?.signals.escalate ?? 0) > opt.signalThreshold).length
1137 const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0)
1138
1139 // How the turns went, and how much of their prompt the cache served: a new
1140 // model, or on most models a new effort, has a request read the whole
1141 // conversation again. Each turn is set against the session's last logged one.
1142 const ended = ds.flatMap(d => (d.outcome ? [d.outcome] : []))
1143 const count = (reason: string) => ended.filter(o => o.reason === reason).length
1144 const read = (ts: Tally[]) => {
1145 const prompt = ts.reduce((n, t) => n + t.input + t.cacheRead + t.cacheWrite, 0)
1146 return prompt > 0 ? `${pct(ts.reduce((n, t) => n + t.cacheRead, 0), prompt)} (${ts.length})` : undefined
1147 }
1148 const before = new Map<string | undefined, Decision>()
1149 const after: Record<'same' | 'effort' | 'model', Tally[]> = { same: [], effort: [], model: [] }
1150 for (const d of ds) {
1151 const last = before.get(d.session)
1152 before.set(d.session, d)
1153 if (!d.outcome || !last?.sent || !d.sent) continue
1154 after[last.sent.model !== d.sent.model ? 'model' : last.sent.effort !== d.sent.effort ? 'effort' : 'same'].push(d.outcome)
1155 }
1156 const cache = [
1157 ['all', read(ended)],
1158 ['same model and effort', read(after.same)],
1159 ['new effort', read(after.effort)],
1160 ['new model', read(after.model)],
1161 ].flatMap(([name, share]) => (share ? [`${name} ${share}`] : []))
1162 // The rate-limit windows as the last decision found them, and at their fullest.
1163 const limits = new Map<string, { last: number; peak: number }>()
1164 for (const d of ds) {
1165 for (const [kind, used] of Object.entries(d.limits ?? {})) limits.set(kind, { last: used, peak: Math.max(used, limits.get(kind)?.peak ?? used) })
1166 }
1167
1168 return [
1169 `jcGearbox stats: the last ${ds.length} decisions, ${new Set(ds.map(d => d.session)).size} sessions, ${day(Math.min(...ats))} to ${day(Math.max(...ats))}`,
1170 `first-turn model, Jev's vs the engine's (${sum(models)}): up ${models.up} · same ${models.same} · down ${models.down}`,
1171 `effort, the router's vs the engine's (${sum(efforts)}): higher ${efforts.higher} · same ${efforts.same} · lower ${efforts.lower}`,
1172 `Jev: ${errors} error${errors === 1 ? '' : 's'} of ${ds.length} (${pct(errors, ds.length)})${unsure > 0 ? ` · unsure of the effort ${unsure}` : ''}${timing}`,
1173 later.length > 0
1174 ? `complaints: ${complaints} of ${later.length} later turns Jev answered (${pct(complaints, later.length)})`
1175 : 'complaints: no later turns Jev answered yet',
1176 ...(ended.length > 0
1177 ? [`turns: ${count('answer')} answered · ${count('error')} error${count('error') === 1 ? '' : 's'} · ${count('aborted')} interrupted · ${count('refusal')} refused`]
1178 : []),
1179 ...(cache.length > 0 ? [`cache reads, share of the prompt: ${cache.join(' · ')}`] : []),
1180 ...(limits.size > 0 ? [`rate limits, last (peak): ${[...limits].map(([kind, l]) => `${kind} ${l.last}% (${l.peak}%)`).join(' · ')}`] : []),
1181 ].join('\n')
1182}
1183
1184type Options = ReturnType<typeof readOptions>
1185
1186function readOptions(options: PluginOptions) {
1187 const text = (key: string) => (typeof options[key] === 'string' ? (options[key] as string) : undefined)
1188 const num = (key: string) => options[key] as number
1189 return {
1190 apiKey: text('api_key'),
1191 defaultMode: text('default_mode') as Mode,
1192 tierModels: {
1193 low: text('low_model'),
1194 balanced: text('balanced_model'),
1195 strong: text('strong_model'),
1196 long: text('long_model'),
1197 } as Record<Tier, string | undefined>,
1198 tierMinProbability: num('tier_min_probability'), // to move to a weaker model
1199 tierUpgradeProbability: num('tier_upgrade_probability'),
1200 signalThreshold: num('signal_threshold'),hooks/jev.ts 202 lines1// The Jev request and its answers (https://docs.typesafe.ai/api.md). Pure.
2
3import { EFFORTS, TIERS } from './policy.ts'
4import type { Signals, Tier } from './policy.ts'
5
6export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
7
8const PROMPT_CHARS = 6000
9
10const TIER_CRITERIA: Record<Tier, string> = {
11 low: 'Lowest latency and price: real-time or high-volume work, cost-sensitive tasks and sub-agent style tasks, such as a lookup, an explanation, a rename, formatting, running a command or a one-line edit',
12 balanced:
13 'Speed and capability for everyday coding, agents and enterprise work: code generation, data analysis, content creation, visual understanding and agentic tool use, such as a typical feature or bug fix within one module, writing tests or a single-file refactor',
14 strong:
15 'Complex agentic coding and enterprise work: autonomous coding agents running for hours, large-scale refactoring, complex systems engineering, vision-heavy workflows and computer use, such as cross-module design, hard debugging (concurrency, performance, memory), architecture or security decisions',
16 long: 'The highest capability available: agent sessions that run for hours, multistep deep research, and analysis carried through to a finished document, spreadsheet or deck',
17}
18
19const ASK_ORDER: readonly Tier[] = ['strong', 'balanced', 'low', 'long']
20
21const QUESTIONS = {
22 effort: {
23 type: 'score',
24 instructions:
25 'Which effort level does the work in `prompt` call for? When `prompt` is short, judge it as part of the task in `previous_prompt` and `previous_reply_tail`.',
26 criteria: [
27 'Low: a simple, scoped task that needs the fastest, cheapest answer, such as a lookup, a rename or a one-line edit',
28 'Medium: a balance of speed, cost and quality, such as a routine agentic task or a well-specified change',
29 'High: complex reasoning, a difficult coding problem or a demanding agentic task',
30 'Xhigh: long-horizon agentic or coding work, over 30 minutes, with a large token budget',
31 'Max: the deepest possible reasoning and most thorough analysis, with no limit on tokens',
32 ],
33 },
34 continuation: {
35 type: 'noul',
36 instructions:
37 'Is `prompt` only approving, answering or continuing the task from `previous_prompt`, rather than starting a new task?',
38 criteria: {
39 true: 'ok / continue / go ahead / yes / a short answer or minor follow-up on the same task',
40 false: 'A new or substantially different task',
41 },
42 },
43 escalate: {
44 type: 'noul',
45 instructions: 'Does `prompt` say the previous attempt was wrong or failed?',
46 criteria: {
47 true: "Still broken, wrong, didn't work, try again",
48 false: 'No complaint about the previous attempt',
49 },
50 },
51 high_stakes: {
52 type: 'noul',
53 instructions: 'Could a mistake while doing `prompt` cause hard-to-reverse damage?',
54 criteria: {
55 true: 'Production systems, data deletion, database migrations, auth or security, money, force-push or history rewrite',
56 false: 'Local and reversible',
57 },
58 },
59} as const
60
61type Question = { type: string; instructions: string; criteria: unknown }
62
63export type JevState = {
64 prompt: string
65 previous_prompt?: string
66 previous_reply_tail?: string
67 previous?: { model?: string; effort?: string }
68}
69
70export type JevRequest = {
71 model: string
72 state: JevState
73 questions: { tier?: Question & { criteria: Partial<Record<Tier, string>> } } & Record<string, Question>
74}
75
76export type JevResult = {
77 tierProbabilities?: Partial<Record<Tier, number>>
78 signals: Signals
79 version?: string // the Jev model that answered, which jev-latest does not name
80}
81
82// Keeps the head and the tail: a pasted log often ends with the actual ask.
83export function clip(text: string, max: number): string {
84 if (text.length <= max) return text
85 const half = Math.floor(max / 2)
86 return `${text.slice(0, half)}\n…\n${text.slice(text.length - half)}`
87}
88
89// Common key and token shapes, so that a pasted secret reaches neither Jev
90// nor the plugin's store.
91const SECRETS = [
92 // A private key, or its headers and lines as far as it was pasted.
93 /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|(?:\s*(?:[A-Za-z-]+: [^\n]*|[A-Za-z0-9+/=]{16,}))+)/g,
94 // A random key mixes capitals and digits, which a kebab-case name does not.
95 /\bsk-(?:ant-|proj-)?(?=[\w-]*[A-Z])(?=[\w-]*\d)[\w-]{20,}/g,
96 // A lowercase one (OpenRouter's sk-or-v1-, DeepSeek's) is a run of 32 or more.
97 /\bsk-(?:[a-z0-9]{1,8}-){0,3}[A-Za-z0-9]{32,}/g,
98 // An AWS secret or session token after its name: in a shell or a config
99 // file, in JSON or a CLI's output (SecretAccessKey), after a full-width colon.
100 /(?<=(?:aws)?[_-]?secret[_-]?(?:access)?[_-]?key["']?\s*[=::=]\s*["']?)[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/gi,
101 /(?<=(?:aws)?[_-]?session[_-]?token["']?\s*[=::=]\s*["']?)[A-Za-z0-9/+=]{100,}/gi,
102 /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g,
103 /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
104 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
105 /\bAIza[0-9A-Za-z_-]{35}/g,
106 /\b[rs]k_live_[0-9A-Za-z]{20,}/g,
107 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
108]
109
110export function redact(text: string): string {
111 return SECRETS.reduce((t, secret) => t.replace(secret, '[redacted]'), text)
112}
113
114// Drops the half of an emoji that a cut left behind: with the u flag a whole
115// pair is one character, so only a lone surrogate matches.
116const whole = (text: string) => text.replace(/\p{Cs}/gu, '')
117
118// The first turn asks for a tier among `tiers` (two at least, or there is
119// nothing to choose); later turns ask whether the prompt continues the task
120// or complains about the last attempt.
121export function buildRequest(input: {
122 jevModel: string
123 prompt: string
124 isFirstTurn: boolean
125 tiers?: readonly Tier[]
126 previousPrompt?: string
127 replyTail?: string
128 previous?: { model?: string; effort?: string }
129}): JevRequest {
130 const { jevModel, prompt, isFirstTurn, tiers, previousPrompt, replyTail, previous } = input
131 const state: JevState = { prompt: whole(clip(redact(prompt), PROMPT_CHARS)) }
132 if (previousPrompt) state.previous_prompt = whole(redact(previousPrompt)) // the caller keeps 1000 chars of it
133 if (replyTail) state.previous_reply_tail = whole(redact(replyTail)) // the caller keeps assistant_tail_chars of it
134 if (previous) state.previous = previous
135
136 const questions: JevRequest['questions'] = {}
137 if (isFirstTurn && tiers && tiers.length >= 2) {
138 // Jev leans toward the option listed first. Anthropic's matrix starts
139 // most work on Opus, so strong goes first, then downward, and long last:
140 // Fable is the step up when Opus falls short.
141 const criteria: Partial<Record<Tier, string>> = {}
142 for (const tier of ASK_ORDER) if (tiers.includes(tier)) criteria[tier] = TIER_CRITERIA[tier]
143 questions.tier = {
144 type: 'choice',
145 instructions: 'Which model tier is needed to reliably complete the coding task that starts with `prompt`?',
146 criteria,
147 }
148 }
149 questions.effort = QUESTIONS.effort
150 if (!isFirstTurn) {
151 questions.continuation = QUESTIONS.continuation
152 questions.escalate = QUESTIONS.escalate
153 }
154 questions.high_stakes = QUESTIONS.high_stakes
155 return { model: jevModel, state, questions }
156}
157
158const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
159
160function field(answer: unknown, key: string): unknown {
161 return typeof answer === 'object' && answer !== null ? (answer as Record<string, unknown>)[key] : undefined
162}
163
164// Undefined when the body is not an answer at all; otherwise every answer
165// that has the expected shape, the rest left out.
166export function parseAnswers(text: string): JevResult | undefined {
167 let body: unknown
168 try {
169 body = JSON.parse(text)
170 } catch {
171 return undefined
172 }
173 const answers = field(body, 'answers')
174 if (typeof answers !== 'object' || answers === null) return undefined
175
176 const signals: Signals = {}
177 const effort = field(answers, 'effort')
178 const score = field(effort, 'score')
179 if (isNumber(score)) signals.effortScore = score
180 const levels = EFFORTS.map((_, i) => field(field(effort, 'probabilities'), String(i)))
181 if (levels.every(isNumber)) signals.effortProbabilities = levels
182 const confidence = field(effort, 'confidence')
183 if (isNumber(confidence)) signals.effortConfidence = confidence
184 const nouls = { continuation: 'continuation', escalate: 'escalate', highStakes: 'high_stakes' } as const
185 for (const [key, id] of Object.entries(nouls)) {
186 const p = field(field(answers, id), 'noul')
187 if (isNumber(p)) signals[key as keyof typeof nouls] = p
188 }
189
190 const result: JevResult = { signals }
191 const version = field(body, 'model')
192 if (typeof version === 'string') result.version = version
193 const probabilities = field(field(answers, 'tier'), 'probabilities')
194 const tierProbabilities: Partial<Record<Tier, number>> = {}
195 for (const tier of TIERS) {
196 const p = field(probabilities, tier)
197 if (isNumber(p)) tierProbabilities[tier] = p
198 }
199 if (Object.keys(tierProbabilities).length > 0) result.tierProbabilities = tierProbabilities
200 return result
201}
202hooks/models.ts 87 lines1// Model aliases to the full ids a request names. Pure: register.ts reads the
2// catalog file and hands its JSON in.
3
4import type { FsEntry } from 'claude-code'
5
6const FAMILIES = ['haiku', 'sonnet', 'opus', 'fable'] as const
7export type Family = (typeof FAMILIES)[number]
8
9// Used when Claude Code's catalog cannot be read. Update on new releases.
10const BUILTIN: Record<Family, string> = {
11 haiku: 'claude-haiku-5-5',
12 sonnet: 'claude-sonnet-5-5',
13 opus: 'claude-opus-5-5',
14 fable: 'claude-fable-5-1',
15}
16
17export type Surface = 'cc' | 'ccd' | 'ccr'
18export type Resolved = { id: string; source: 'catalog' | 'builtin' | 'config' }
19
20const DESKTOP_ENTRYPOINTS = ['claude-desktop', 'claude-desktop-3p', 'local-agent']
21
22// "2.1.286-dev.20260920" compares as 2.1.286.
23export function versionAtLeast(version: string, minimum: string): boolean {
24 const parts = (v: string) => (v.split('-')[0] ?? '').split('.').map(n => Number.parseInt(n, 10) || 0)
25 const a = parts(version)
26 const b = parts(minimum)
27 for (let i = 0; i < Math.max(a.length, b.length); i++) {
28 const d = (a[i] ?? 0) - (b[i] ?? 0)
29 if (d !== 0) return d > 0
30 }
31 return true
32}
33
34// Which catalog the engine keeps for this surface, as Claude Code decides it.
35export function surfaceOf(entrypoint: string | undefined, remote: string | undefined): Surface {
36 if (remote) return 'ccr'
37 return entrypoint && DESKTOP_ENTRYPOINTS.includes(entrypoint) ? 'ccd' : 'cc'
38}
39
40export function newestCatalogFile(entries: readonly FsEntry[], surface: Surface): string | undefined {
41 const served = entries.filter(
42 e => e.kind === 'file' && !e.name.startsWith('published-') && e.name.endsWith(`-${surface}.json`),
43 )
44 return served.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.name
45}
46
47// The models of a catalog in the shape this mod reads, or none.
48function modelsOf(catalog: unknown): unknown[] | undefined {
49 const models = (catalog as { catalog?: { config?: { models?: unknown } } } | null)?.catalog?.config?.models
50 return Array.isArray(models) ? models : undefined
51}
52
53// The family's model in the "main" section of Claude Code's model picker,
54// skipping one that needs a newer Claude Code than `version`.
55export function catalogModel(catalog: unknown, family: Family, version: string): string | undefined {
56 for (const m of modelsOf(catalog) ?? []) {
57 if (typeof m !== 'object' || m === null) continue
58 const { id, short_name, section, min_claude_code_version, disabled } = m as Record<string, unknown>
59 if (typeof id !== 'string' || section !== 'main' || disabled === true) continue
60 if (typeof short_name !== 'string' || short_name.toLowerCase() !== family) continue
61 if (typeof min_claude_code_version === 'string' && !versionAtLeast(version, min_claude_code_version)) continue
62 return id
63 }
64 return undefined
65}
66
67// An alias resolves only on a first-party login, whose ids the catalog and
68// the table hold; anything else is a full id, used as written. "off" (or an
69// empty value) disables the tier: the config menu cannot store an empty one.
70// An alias may end in [1m], as Claude Code's do, and its id keeps it.
71export function resolveModel(
72 value: string | undefined,
73 ctx: { firstParty: boolean; catalog?: unknown; version: string },
74): Resolved | undefined {
75 const name = value?.trim()
76 if (!name || name.toLowerCase() === 'off') return undefined
77 const suffix = /\[1m\]$/i.test(name) ? '[1m]' : ''
78 const family = FAMILIES.find(f => f === name.slice(0, name.length - suffix.length).toLowerCase())
79 if (!family) return { id: name, source: 'config' }
80 if (!ctx.firstParty) return undefined
81 const fromCatalog = catalogModel(ctx.catalog, family, ctx.version)
82 if (fromCatalog) return { id: fromCatalog + suffix, source: 'catalog' }
83 // A catalog that offers the family nothing (no access to it, or a Claude
84 // Code too old for it) turns the tier off; one that can't be read falls back.
85 return modelsOf(ctx.catalog) ? undefined : { id: BUILTIN[family] + suffix, source: 'builtin' }
86}
87hooks/policy.ts 173 lines1// Pure decision rules: no `$`, so the tests drive them directly.
2
3export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
4export type Effort = (typeof EFFORTS)[number]
5
6// Strongest first, so a tie between models goes to the stronger one.
7export const TIERS = ['long', 'strong', 'balanced', 'low'] as const
8export type Tier = (typeof TIERS)[number]
9
10export type Signals = {
11 effortScore?: number
12 effortProbabilities?: number[] // Jev's probability for each level, low to max; kept for comparing ways to decide
13 effortConfidence?: number
14 continuation?: number
15 escalate?: number
16 highStakes?: number
17}
18
19export function isEffort(value: unknown): value is Effort {
20 return typeof value === 'string' && (EFFORTS as readonly string[]).includes(value)
21}
22
23export function effortFromScore(score: number): Effort {
24 const index = Math.min(EFFORTS.length - 1, Math.max(0, Math.round(score)))
25 return EFFORTS[index] ?? 'low'
26}
27
28function atLeast(effort: Effort, floor: Effort): Effort {
29 return EFFORTS.indexOf(effort) >= EFFORTS.indexOf(floor) ? effort : floor
30}
31
32function atMost(effort: Effort, ceiling: Effort): Effort {
33 return EFFORTS.indexOf(effort) <= EFFORTS.indexOf(ceiling) ? effort : ceiling
34}
35
36export function clampEffort(effort: Effort, floor: Effort, ceiling: Effort): Effort {
37 return atMost(atLeast(effort, floor), ceiling)
38}
39
40function oneUp(effort: Effort): Effort {
41 return EFFORTS[Math.min(EFFORTS.length - 1, EFFORTS.indexOf(effort) + 1)] ?? effort
42}
43
44// Anthropic's effort table (build-with-claude/effort): Haiku 4.5 and older and
45// the models before Opus 4.5 and Sonnet 4.6 take no effort; Opus 4.5 takes up
46// to high; Opus 4.6, Sonnet 4.6 and Mythos Preview take max but not xhigh. Any
47// other model, Haiku 5.5 and ids we do not know yet included, takes every level.
48const NO_EFFORT = /haiku-[34]|claude-3|(?:sonnet|opus)-4(?:-[01])?(?:-\d{8}|-v\d|$|[@:[])|sonnet-4-5/
49const HIGH_AT_MOST = /opus-4-5/
50const NO_XHIGH = /opus-4-6|sonnet-4-6|mythos-preview/
51
52export function effortFor(model: string, effort: Effort | undefined): Effort | undefined {
53 if (!effort || NO_EFFORT.test(model)) return undefined
54 if (HIGH_AT_MOST.test(model)) return atMost(effort, 'high')
55 if (NO_XHIGH.test(model) && effort === 'xhigh') return 'high'
56 return effort
57}
58
59// The level a request without effort runs at, as Claude Code's model table
60// has it: medium on Opus, Sonnet and Haiku 5.5, xhigh on Opus 4.7, high on
61// every other model that takes effort.
62export function defaultEffort(model: string): Effort | undefined {
63 if (NO_EFFORT.test(model)) return undefined
64 if (/(?:opus|sonnet|haiku)-5-5/.test(model)) return 'medium'
65 return /opus-4-7/.test(model) ? 'xhigh' : 'high'
66}
67
68// Start from the previous effort on a continuation, else from Jev's score,
69// unless Jev is unsure of the score (below minConfidence): then the previous
70// effort holds, and with none (a first turn, or a continuation of turns that
71// set none) nothing changes. Then a complaint raises it a level and a
72// high-stakes task lifts it to high (xhigh is for long-running work). The
73// floor applies next and the ceiling last, so the ceiling wins.
74export function decideEffort(input: {
75 signals: Signals | undefined
76 previous: Effort | undefined
77 isFirstTurn: boolean
78 threshold: number
79 continuationThreshold: number
80 minEffort: Effort
81 maxEffort: Effort
82 minConfidence?: number
83}): { effort: Effort | undefined; unsure?: true } {
84 const { signals, previous, isFirstTurn, threshold, continuationThreshold, minEffort, maxEffort, minConfidence = 0 } = input
85 if (!signals) return { effort: isFirstTurn || !previous ? undefined : atMost(previous, maxEffort) }
86
87 const holds = (p: number | undefined) => p !== undefined && p > threshold
88 const isContinuation = !isFirstTurn && signals.continuation !== undefined && signals.continuation > continuationThreshold
89 const escalated = !isFirstTurn && holds(signals.escalate)
90 const unsure = !(isContinuation && previous) && signals.effortConfidence !== undefined && signals.effortConfidence < minConfidence
91 const said = unsure ? ({ unsure: true } as const) : {}
92
93 let effort =
94 isContinuation && previous
95 ? previous
96 : !unsure && signals.effortScore !== undefined
97 ? effortFromScore(signals.effortScore)
98 : previous
99 // Nothing to hold or score: only a risky task sets an effort.
100 if (!effort) return { effort: holds(signals.highStakes) ? clampEffort('high', minEffort, maxEffort) : undefined, ...said }
101
102 // A complaint goes up from the attempt it complains about, even when Jev
103 // takes it for a new task and scores the short complaint itself.
104 if (escalated) effort = oneUp(previous ? atLeast(effort, previous) : effort)
105 if (holds(signals.highStakes)) effort = atLeast(effort, 'high')
106 return { effort: clampEffort(effort, minEffort, maxEffort), ...said }
107}
108
109// Tiers weakest first.
110const LADDER: readonly Tier[] = ['low', 'balanced', 'strong', 'long']
111export function tierRank(tier: Tier): number {
112 return LADDER.indexOf(tier)
113}
114
115// The model the first turn moves the session to, or none to stay on the
116// engine's. Moving up to a tier takes `upgrade` of Jev's probability that the
117// task needs that tier or a stronger one; moving down, `downgrade` that it
118// needs that tier or a weaker one. The engine's model stands for the tiers it
119// is (`engine`); one that is none of them goes to the likeliest model, pooled
120// across the tiers on it, at `downgrade`.
121export function pickModel(input: {
122 probabilities: Partial<Record<Tier, number>> | undefined
123 models: Partial<Record<Tier, string>>
124 engine: readonly Tier[]
125 upgrade: number
126 downgrade: number
127}): string | undefined {
128 const { probabilities, models, engine, upgrade, downgrade } = input
129 if (!probabilities) return undefined
130 const p = (i: number) => probabilities[LADDER[i] as Tier] ?? 0
131 const sum = (from: number, to: number) => {
132 let total = 0
133 for (let i = from; i <= to; i++) total += p(i)
134 return total
135 }
136
137 if (engine.length > 0) {
138 // Asked about no tier of the engine's, Jev's answer adds up to one on one
139 // side of it: it could not say "stay", so nothing is compared against it.
140 // (The router asks about the engine's tier and gives one left out of the
141 // answer 0; this holds for any other caller.)
142 if (!engine.some(t => probabilities[t] !== undefined)) return undefined
143 const ranks = engine.map(tierRank)
144 const lo = Math.min(...ranks)
145 const hi = Math.max(...ranks)
146 for (let i = LADDER.length - 1; i > hi; i--) {
147 const model = models[LADDER[i] as Tier]
148 if (model && sum(i, LADDER.length - 1) >= upgrade) return model
149 }
150 for (let i = 0; i < lo; i++) {
151 const model = models[LADDER[i] as Tier]
152 if (model && sum(0, i) >= downgrade) return model
153 }
154 return undefined
155 }
156
157 const pooled = new Map<string, number>()
158 for (const tier of TIERS) {
159 const model = models[tier]
160 const q = probabilities[tier]
161 if (model && q !== undefined) pooled.set(model, (pooled.get(model) ?? 0) + q)
162 }
163 let best: string | undefined
164 let bestP = -1
165 for (const [model, q] of pooled) {
166 if (q > bestP) {
167 best = model
168 bestP = q
169 }
170 }
171 return best !== undefined && bestP >= downgrade ? best : undefined
172}
173