SLOPSHOPPER

jc-gearbox

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

newcommandstatuspromptnetwork
v2.5.0MITupdated 2026-10-26KevinYu1580/jcGearbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jc-gearbox
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /jcGearbox ⎿ jc-gearbox: jcGearbox: shadow (default) ⎿ jc-gearbox: model: not chosen yet ⎿ jc-gearbox: effort: not set yet ⎿ jc-gearbox: tiers: low haiku-5-5 (built-in) · balanced sonnet-5-5 (built-in) · strong opus-5-5 (built-in) · long fable-5-1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jc-gearbox: no API key
README

<!-- 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%">

jcGearbox

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.

  • Model: picked on a session's first prompt, then kept for the whole session to keep the prompt cache.
  • Effort: picked again on every prompt.
  • Tiers and levels follow Anthropic's model selection matrix and effort levels.

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_effort and max_effort to the same level, so only the model is routed. See the reference.

Requirements

  • Claude Code 2.1.286 or newer. Mods are an early-access API, so a Claude Code release can break this one.
  • A TypeSafe API key from console.typesafe.ai.

Install

/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

How it works

Model

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.

TierForDefault
strongComplex agentic coding, large refactors, hard debuggingopus
balancedEveryday coding, data analysissonnet
lowFast, cheap, high-volume taskshaiku
longAgent sessions that run for hours, deep researchfable (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.

Effort

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.

Commands

CommandDoes
/jcGearbox-setupGuided setup
/jcGearbox-status (or /jcGearbox)Mode, model, effort and the model each tier resolves to
/jcGearbox-auto, /jcGearbox-shadow, /jcGearbox-off, /jcGearbox-onMode for this session
/jcGearbox-pin <model>Pin a model for this session
/jcGearbox-log [n]Last n decisions
/jcGearbox-statsDecisions, 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).

Settings

Set them with /jcGearbox-setup or /plugin configure jc-gearbox@jc-gearbox. The ones you are most likely to change:

SettingDefaultMeaning
default_modeshadowshadow, auto or off for new sessions
low_model, balanced_model, strong_model, long_modelhaiku, sonnet, opus, fableThe model for each tier; off disables it
min_effort, max_effortlow, maxRange of efforts the mod may set
send_assistant_tailoff (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.

Privacy

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.

License

MIT

Source 4 files
hooks/register.ts 1276 lines
1import 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 lines
1// 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}
202
hooks/models.ts 87 lines
1// 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}
87
hooks/policy.ts 173 lines
1// 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