SLOPSHOPPER

model-picker

Checks every prompt locally (zero tokens). When the current model is likely too weak or overkill, it blocks the prompt and shows a dialog ("Don't use X for…

newguardcommandtoaststatusprompt
★ 1v0.6.1MITupdated 2026-10-14samuelbfernandes/moddeck/mods/model-picker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-picker
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ model-picker │ ⏺ Read(src/auth.ts) │ model-picker · muted for this session. The │ ⎿ Read 6 lines │ status line stays. │ ⏺ 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 › /model-picker ⎿ model-picker: model-picker · on · Opus ⎿ model-picker: Blocks and asks on a mismatch (confidence ≥ 0.60). Esc holds the prompt. ⎿ model-picker: Haiku 1x · Sonnet 20x · Opus 40x (rough, set in costRatios) ⎿ model-picker: 0 accepted · 1 dismissed ⎿ model-picker: Questions muted this session. Type /model-picker on to lift it. ⎿ model-picker: Recent ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ model-picker: model-picker: review, Codex available
README

model-picker

Install (Claude Code 2.1.287 or later): /plugin install model-picker --marketplace samuelbfernandes/moddeck

Checks every prompt you type and tells you when your current model looks wrong for it, and which model to use instead. Nothing changes unless you pick it.

Routing without a switch (new in 0.6.0)

  • A new conversation starts on the cheapest model that fits. On the first prompt of a conversation (or after /clear), when the rules are confident a cheaper model will do, the whole conversation runs on it: every request names that model, so the cache is never thrown away. A toast says so, and the moddeck Session box shows your model → Haiku (routed). It never goes above the model you chose, and /model ends it until the next conversation. Setting autoRoute.
  • Subagents get a fitting model. A subagent Claude starts without naming a model gets the cheapest one its task needs, never above the session's. A named model or a fork is left alone. Setting routeSubagents.
  • Both are local rules: no model call, no proxy. The idea of routing once per conversation comes from laya-router (MIT).

What it does

  • Every prompt is checked locally with keyword and size rules: no model call, zero tokens, instant. Slash commands, bare "yes/ok", and pasted tool output are skipped. A prompt the rules cannot place stays silent.
  • Two risks, named plainly. UNDER-POWERED: "Don't use Sonnet for this task" (proofs, derivations, design, hard debugging, big refactors, statistical-method changes, review of method code, REML/GWAS/kinship/simulation, Rust unsafe/lifetimes). OVER-POWERED: "Opus is overkill for this" (typos, renames, formatting, simple lookups). The cues that matched are shown as the reason.
  • Recommends the cheapest model predicted to be good enough, with a small table, for example Haiku: weak, 1x; Sonnet: ok, 20x; Opus: best, 40x. If you are on Fable, a cheaper model can be recommended for light or standard work; Fable is never a target.
  • Review work (review, verify, falsify, audit a method change) is a third case even when your model is strong: the model that wrote the code should not approve it. If codex --version works, the dialog says "A different model (Codex) catches more than Opus reviewing its own work" and offers Keep or Ask Codex; the status line says model-picker: review, Codex available. Without Codex it only says your model fits.
  • Effort-watch (setting effortWatch, on): the status line adds a note when the session's effort level does not fit the task, such as effort max is more than this needs · /effort medium for light or standard work, or effort low may be too little for this · /effort high for hard work. Advice only, never a dialog. The level is read from each model request; on Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.1 changing effort keeps the prompt cache.
  • Always-on status line after every checked prompt: model-picker: Opus fits, model-picker: overkill, Haiku would do (~40x cheaper), model-picker: weak for this, use Opus.
  • A mismatch at or above the confidence bar blocks the prompt and opens a dialog, every time. No cooldown, no "already asked about this" memory, and dismissing dialogs never raises the bar. The dialog offers Keep (the default, so Enter changes nothing), Switch session, Run this task as a subagent (hidden if your prompt leans on the chat), and for review work Ask Codex for a read-only review (it spends Codex usage). Switching mid-session makes the new model re-cache the whole conversation (and again on the way back), which usually costs more than a cheaper model saves on a task or two. So when a task is merely overkill for your model and the context is over 20k tokens with a warm cache, Switch is not offered: the dialog offers a subagent (a fresh, small context) instead, or, when the prompt leans on the chat and no subagent fits, it does not ask at all and the prompt goes as typed. Switch comes back once the cache has gone cold, the context is small, or after /clear. A task too hard for your model still offers Switch, with the re-cache cost stated.
  • Esc does not send the prompt. It is held, and your text is shown in the transcript so you can copy and resend it (set onDismiss to send if you prefer the old behaviour). The only ways the dialog stops asking are your own choices: "don't suggest again this session", model-picker off, or enabled = false.
  • Ordinary (standard-tier) tasks are usually fine on Sonnet, so by default they only get a status-line hint. Set askOnStandard to true and they block too (a borderline or roomier-than-needed model).

What the dialog looks like

Don't use Sonnet for this task
Deep task · proof or derivation, REML
Haiku 1x weak · Sonnet 20x weak · Opus 40x best
Opus is the cheapest model that should handle it. Costlier and slower.

Options: Keep Sonnet · Switch session to Opus · Run this task as a subagent on Opus · Keep and don't suggest again.

Use

The on/off switch is in the moddeck side bar (green suggest on, red suggest off; click it to flip). It is the same switch as /model-picker on|off and is remembered across sessions.

/model-picker [status|on|off|explain <text>]: status with the last 5 checks, the switch, and explain (a free dry run that prints the cues, the per-model ratings, costs and what it would do).

Turn off

/model-picker off, enabled = false in /config, or /plugin disable model-picker.

Settings

minConfidence (0.6; a mismatch needs at least this much confidence to block), askOnStandard (false), onDismiss (hold or send), minWords (3; shorter prompts are ignored unless a strong rule matches), statusLine (true), enabled, useClassifier (false; when on, an unplaced prompt is sent once to haiku, which costs a small call). costRatios (haiku=1,sonnet=3,opus=5, fable= optional) is a rough ratio of list prices; it goes stale, so edit it. Bad pieces are ignored.

Limits

  • Ratings and verdicts are predictions from hand-written rules, not measured accuracy. Costs are rough ratios you maintain, not real prices. It is wrong sometimes; the tests only guard against regressions.
  • Review work on a model that already fits (Opus) no longer opens a Codex dialog, since that is not a mismatch.
  • Prompts are never saved; the last 5 checks keep a 50-character snippet in memory for the session. Only prompts you type are checked, not plugin or scheduled prompts. An error in the checker sends your prompt unchanged; only an answered or dismissed dialog holds it.
  • Not verified live: the status line and dialog in a real session, the model switch, and anything on Windows (Codex there may need codex.cmd on PATH).
Source 10 files
hooks/register.tsx 500 lines
1// model-picker: every prompt the person types is checked locally (keyword rules, no model call by
2// default, zero tokens) for two risks: the current model is likely too WEAK for the task, or it is
3// OVERKILL for it. Each check refreshes a one-line status ($.ui.status); only a mismatch at or above
4// the confidence bar opens a dialog and BLOCKS the prompt until it is answered: switch the session,
5// run just this task as a subagent, ask Codex for a read-only review, or keep going. There is no
6// cooldown and no "already asked about this" memory: every mismatch above the bar asks, every time. The
7// recommendation is the cheapest model predicted to be at least "ok" for the task, from rough, editable cost ratios. All of it is a prediction, never a
8// measurement.
9//
10// HOW THE DIALOG HOLDS THE PROMPT: a hook has 10 s of its OWN time, but the clock stops while
11// a `$` call is in flight, and `$.ui.ask` is one (the pattern command-guard uses). So the hook
12// simply awaits the question; the person may take minutes at no cost.
13//
14// FAIL OPEN for everything except the question itself: an error in the predictor, state, switch,
15// spawn or Codex sends the prompt exactly as typed. The deliberate drops are: a prompt sent to a subagent the person asked for, and a prompt
16// HELD because the person dismissed the dialog (Esc) with `onDismiss` = hold, the default. A held
17// prompt is echoed back in full so nothing typed is lost; `/model-picker off` ends any trap.
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { Run } from './actions'
22import { codexContext, delegateText, manualSwitchHint, probeCodex, runCodex, spawnSubagent, switchModel } from './actions'
23import { buildDialog, choiceOf, compactTable, isKeepOnly } from './dialog'
24import { dropWith } from './reply'
25import type { Choice } from './dialog'
26import { cheaperBy, effortAdvice, effortNote, judge, nameOf, parseCosts, ratingTable, recommend, statusText } from './fit'
27import type { Costs, Fit } from './fit'
28import { canRunAlone, familyOf, pushHistory, snippet } from './gate'
29import type { Alias, Family } from './gate'
30import { isNotATask } from './input'
31import { TIER_WORD, isStrong, predict, wordCount } from './predict'
32import type { Prediction } from './predict'
33import { MODEL_ID, cheaperFit } from './route'
34import type { SuggestModelEntry } from '../types'
35
36type Dollar = EngineInterface
37
38const seen = atom({ plugin: 'model-picker', key: 'seen' } as const, 0)
39const isMuted = atom({ plugin: 'model-picker', key: 'isMuted' } as const, false)
40/** Mirror of the persisted on/off switch (`$.store` 'off'), read by the moddeck side bar so it redraws when ANY path flips it. */
41const isOff = atom({ plugin: 'model-picker', key: 'isOff' } as const, false)
42const codexOk = atom({ plugin: 'model-picker', key: 'codexOk' } as const, null)
43const recent = atom({ plugin: 'model-picker', key: 'recent' } as const, [])
44/** The session's effort level as the last model request named it (empty: none seen, or a model without effort). */
45/** What the side bar's model row shows beside the effort when it does not fit the last task (`medium would do`). */
46/** The routed model's name for the moddeck Session box ('' when not routed). */
47const routedTo = atom({ plugin: 'model-picker', key: 'routedTo' } as const, '')
48const effortHint = atom({ plugin: 'model-picker', key: 'effortAdvice' } as const, '')
49const effortNow = atom({ plugin: 'model-picker', key: 'effort' } as const, '')
50
51// Only the person typing is ever analysed. 'bridge' is the same person on a phone or the web.
52const PERSON = new Set(['composer', 'bridge'])
53const KEEP_RECENT = 5
54const CLASSIFIER_MODEL = 'haiku'
55
56type Cfg = {
57  minConfidence: number
58  minWords: number
59  /** Ordinary (standard-tier) tasks also open the dialog when the model is borderline or has spare capacity. */
60  askOnStandard: boolean
61  /** What Esc does: 'hold' keeps the prompt from being sent, 'send' sends it as typed. */
62  onDismiss: 'hold' | 'send'
63  useClassifier: boolean
64  isEnabled: boolean
65  statusLine: boolean
66  /** Add a short note to the status line when the effort level does not match the task. */
67  effortWatch: boolean
68  /** Draw the on/off button in the band above the prompt. */
69  costs: Costs
70  /** The first prompt of a conversation picks a cheaper model for the whole conversation when one will do. */
71  autoRoute: boolean
72  /** A subagent started without a model gets the cheapest one that fits its task (never above the session's). */
73  routeSubagents: boolean
74  /** Pieces of the cost setting that were ignored (shown by explain and status). */
75  costProblems: string[]
76}
77
78function numberOf(v: unknown, fallback: number, lo: number, hi: number): number {
79  return typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback
80}
81
82export function readConfig(o: Readonly<Record<string, unknown>>): Cfg {
83  const { costs, problems } = parseCosts(o.costRatios)
84  return {
85    minConfidence: numberOf(o.minConfidence, 0.6, 0.5, 0.95),
86    minWords: Math.round(numberOf(o.minWords, 3, 1, 50)),
87    askOnStandard: o.askOnStandard === true,
88    onDismiss: o.onDismiss === 'send' ? 'send' : 'hold',
89    useClassifier: o.useClassifier === true,
90    isEnabled: o.enabled !== false,
91    statusLine: o.statusLine !== false,
92    effortWatch: o.effortWatch !== false,
93    costs,
94    autoRoute: o.autoRoute !== false,
95    routeSubagents: o.routeSubagents !== false,
96    costProblems: problems,
97  }
98}
99
100async function quietly<T>(f: () => T | Promise<T>): Promise<T | undefined> {
101  try {
102    return await f()
103  } catch {
104    return undefined
105  }
106}
107
108// ------------------------------------------------------------------ persisted choices
109
110type Counters = { accepted: number; dismissed: number; history: string[] }
111
112async function readCounters($: Dollar): Promise<Counters> {
113  const n = async (k: string) => {
114    const v = await quietly(() => $.store.get(k))
115    return typeof v === 'number' ? v : 0
116  }
117  const h = await quietly(() => $.store.get('history'))
118  const history = Array.isArray(h) ? h.filter((x): x is string => x === 'a' || x === 'd') : []
119  return { accepted: await n('accepted'), dismissed: await n('dismissed'), history }
120}
121
122/** Counts an answer: totals for the status report, a short history for the confidence nudge. */
123async function tally($: Dollar, kind: 'a' | 'd'): Promise<void> {
124  await quietly(async () => {
125    const c = await readCounters($)
126    await $.store.set(kind === 'a' ? 'accepted' : 'dismissed', (kind === 'a' ? c.accepted : c.dismissed) + 1)
127    await $.store.set('history', pushHistory(c.history, kind))
128  })
129}
130
131const OFF_STATUS = 'model-picker: off · type /model-picker on'
132
133/** The one place the switch changes, for the moddeck side bar and /model-picker alike. */
134async function setSwitch($: Dollar, cfg: Cfg, off: boolean): Promise<void> {
135  if (off) {
136    await $.store.set('off', true)
137    await showStatus($, cfg, OFF_STATUS) // visible even where the band is not drawn
138  } else {
139    await $.store.delete('off')
140    await showStatus($, cfg, undefined)
141    await update($, isMuted, () => false) // turning it back on also lifts a session mute
142  }
143  await quietly(() => update($, isOff, () => off))
144}
145
146async function isOn($: Dollar, cfg: Cfg): Promise<boolean> {
147  if (!cfg.isEnabled) return false
148  return (await quietly(() => $.store.get('off'))) !== true
149}
150
151// ------------------------------------------------------------------------ bookkeeping
152
153async function note($: Dollar, text: string, p: Prediction, outcome: string): Promise<void> {
154  const entry: SuggestModelEntry = { snippet: snippet(text), tier: p.tier, confidence: p.confidence, reasons: p.reasons.slice(0, 3), outcome }
155  await quietly(() => update($, recent, list => [...list, entry].slice(-KEEP_RECENT)))
156}
157
158const setOutcome = ($: Dollar, outcome: string) =>
159  quietly(() => update($, recent, list => list.map((x, i) => (i === list.length - 1 ? { ...x, outcome } : x))))
160
161/** The always-on one-liner. Non-blocking: a failure here changes nothing. */
162const showStatus = ($: Dollar, cfg: Cfg, text: string | undefined) =>
163  cfg.statusLine ? quietly(() => $.ui.status(text)) : Promise.resolve()
164
165// ------------------------------------------------------------------------ prediction
166
167/** One haiku call for a prompt the rules could not place; it can only confirm or replace the tier. */
168async function refine($: Dollar, text: string, p: Prediction, minConfidence: number): Promise<Prediction> {
169  const label = await quietly(() => $.model.classify(text, ['light', 'standard', 'deep'], { model: CLASSIFIER_MODEL }))
170  if (label !== 'light' && label !== 'standard' && label !== 'deep') return p
171  const confidence = Math.max(p.confidence, minConfidence) // the classifier settles the doubt either way
172  const reasons = [`small-model classifier says ${label}`, ...(label === p.tier ? p.reasons : [])]
173  return { tier: label, score: p.score, confidence, reasons }
174}
175
176const isAmbiguous = (p: Prediction, minConfidence: number): boolean => p.tier !== 'review' && p.confidence < minConfidence
177
178type Outcome = { kind: 'pass' } | { kind: 'drop'; reason: string } | { kind: 'context'; context: string[] } | { kind: 'rewrite'; text: string }
179const PASS: Outcome = { kind: 'pass' }
180
181type Mismatch = Extract<Fit, { kind: 'mismatch' }>
182type Ask = Mismatch | { kind: 'review' }
183
184// ------------------------------------------------------------------------ the hook
185
186async function review($: Dollar, e: { text: string; origin: { kind: string }; turnId?: string }, cfg: Cfg): Promise<Outcome> {
187  if (!(await isOn($, cfg))) return PASS
188  if (!PERSON.has(e.origin.kind) || e.turnId !== undefined) return PASS
189  const text = e.text.trim()
190  if (text === '' || isNotATask(text)) return PASS // slash commands, "yes", pasted output: nothing to judge
191
192  await update($, seen, n => n + 1)
193  let p = predict(text)
194  if (wordCount(text) < cfg.minWords && !isStrong(p)) return PASS
195
196  const chosen = familyOf((await quietly(() => $.session.model())) ?? '')
197  // The first prompt of a conversation: nothing is cached yet, so running it on a cheaper model costs nothing extra.
198  // The pick holds for the whole conversation (turn.step names it on every request), so the cache is never thrown away.
199  if (cfg.autoRoute && routed === undefined && lastResponseAt === undefined && !routeOff) {
200    const pick = cheaperFit(p, chosen, cfg.costs, cfg.minConfidence)
201    if (pick !== undefined) {
202      routed = pick
203      routedFrom = chosen
204      await quietly(() => update($, routedTo, () => nameOf(pick)))
205      await note($, text, p, `routed to ${pick}`)
206      await setOutcome($, `routed this conversation to ${pick}`)
207      $.ui.toast(`model-picker · this conversation runs on ${nameOf(pick)} (${p.tier} task) · /model to choose yourself`, { timeoutMs: 10_000 })
208      await showStatus($, cfg, `model-picker: on ${nameOf(pick)} for this conversation`)
209      return PASS
210    }
211  }
212  const current = routed ?? chosen
213  const minConfidence = cfg.minConfidence // fixed: dismissing a dialog never raises the bar
214  let fit = judge(p, current, minConfidence, cfg.costs, cfg.askOnStandard)
215
216  if (fit.kind === 'none' && cfg.useClassifier && current !== undefined && isAmbiguous(p, minConfidence)) {
217    p = await refine($, text, p, minConfidence)
218    fit = judge(p, current, minConfidence, cfg.costs, cfg.askOnStandard)
219  }
220  // A review on a model that fits is only worth a word when Codex can really do it; the probe runs once.
221  if (fit.kind === 'review' && !(await quietly(() => hasCodex($)))) fit = { kind: 'fits' }
222  await note($, text, p, 'not suggested')
223  const verdict = statusText(fit, current, cfg.costs)
224  const effortLevel = await read($, effortNow)
225  const effort = cfg.effortWatch && p.confidence >= minConfidence ? effortNote(p.tier, effortLevel) : undefined
226  await quietly(() => update($, effortHint, () => (cfg.effortWatch && p.confidence >= minConfidence ? (effortAdvice(p.tier, effortLevel) ?? '') : '')))
227  await showStatus($, cfg, verdict !== undefined && effort !== undefined ? `${verdict} · ${effort}` : (verdict ?? (effort !== undefined ? `model-picker: ${effort}` : undefined))) // a prompt the rules cannot place clears the line
228  if (fit.kind !== 'mismatch' && fit.kind !== 'review') {
229    return await skip($, fit.kind === 'none' ? `no: ${fit.why}` : fit.kind === 'fits' ? 'fits: no dialog' : 'hint on the status line only')
230  }
231  if (current === undefined) return PASS
232  if (await read($, isMuted)) return await skip($, 'no: muted this session')
233
234  const surfaces = await quietly(() => $.session.surfaces())
235  if (surfaces !== undefined && surfaces.length === 0) return await skip($, 'no: nobody to ask (headless run)')
236
237  return await offer($, cfg, e.text, p, fit, current)
238}
239
240const skip = async ($: Dollar, outcome: string): Promise<Outcome> => {
241  await setOutcome($, outcome)
242  return PASS
243}
244
245const runner = ($: Dollar): Run => (argv, init) => $.process.run(argv, init)
246
247async function hasCodex($: Dollar): Promise<boolean> {
248  const known = await read($, codexOk)
249  if (known !== null) return known
250  const ok = await probeCodex(runner($))
251  await update($, codexOk, () => ok)
252  return ok
253}
254
255/** When the main conversation last got a response: its cache lives a TTL from there. undefined = none yet (nothing to lose). */
256let lastResponseAt: number | undefined
257
258/** The cache is probably warm: a response inside the TTL (60 min on a subscription, 5 otherwise, as Claude Code defaults). */
259async function isCacheWarm($: Dollar, windows: readonly { kind: string }[]): Promise<boolean> {
260  if (lastResponseAt === undefined) return false
261  const ttlMs = (windows.some(w => w.kind === 'five_hour' || w.kind === 'seven_day') ? 60 : 5) * 60_000
262  const now = await $.clock.now().catch(() => Date.now())
263  return now - lastResponseAt < ttlMs
264}
265
266/** The model this conversation was routed to at its first prompt; undefined = the session's own. */
267let routed: Alias | undefined
268/** A model you picked yourself (/model) ends routing until the next conversation. */
269let routeOff = false
270/** The session model when routing began: a different one later means you switched with /model. */
271let routedFrom: Family | undefined
272
273async function offer($: Dollar, cfg: Cfg, text: string, p: Prediction, m: Ask, current: Family): Promise<Outcome> {
274  const usage = await quietly(() => $.session.usage())
275  const dialog = buildDialog({
276    prediction: p,
277    current,
278    risk: m.kind === 'review' ? 'self' : m.risk,
279    target: m.kind === 'review' ? undefined : m.recommended,
280    table: ratingTable(p.tier, current, cfg.costs),
281    cheaper: m.kind === 'review' ? '' : cheaperBy(cfg.costs, current, m.recommended),
282    contextTokens: usage?.context.tokens ?? 0,
283    isCacheWarm: await isCacheWarm($, usage?.rateLimits ?? []),
284    canSubagent: canRunAlone(text),
285    hasCodex: p.tier === 'review' && (await hasCodex($)),
286  })
287
288  if (isKeepOnly(dialog)) {
289    // The only real move would be a switch, and mid-session it costs more than it saves: no question, the prompt goes as typed.
290    await setOutcome($, 'kept the model: a switch would re-cache the conversation')
291    return PASS
292  }
293  let answer: string
294  try {
295    answer = await $.ui.ask(dialog.question, { options: dialog.order.map(c => dialog.labels[c]), header: 'Model choice' })
296  } catch {
297    // Esc or an interrupt. By default the prompt is HELD (not sent), and echoed so nothing typed is lost.
298    if (cfg.onDismiss === 'send') {
299      await setOutcome($, 'dialog dismissed: sent as typed')
300      return PASS
301    }
302    await setOutcome($, 'dialog dismissed: prompt held')
303    return { kind: 'drop', reason: heldMessage(text) }
304  }
305  const choice: Choice = choiceOf(dialog, answer) ?? 'keep' // free text under "Other" is read as keep
306  await setOutcome($, `chose: ${choice}`)
307  return await act($, cfg, choice, text, m.kind === 'review' ? undefined : m.recommended)
308}
309
310/** After Esc: a one-line reason (the drop box); the prompt text goes to the transcript so nothing typed is lost. */
311export function heldMessage(text: string): string {
312  const shown = text.length > 2000 ? `${text.slice(0, 2000)}...` : text
313  return ['model-picker blocked your prompt · nothing was sent. Send it again to choose, or type /model-picker off.', shown].join('\n')
314}
315
316async function act($: Dollar, cfg: Cfg, choice: Choice, text: string, alias: Alias | undefined): Promise<Outcome> {
317  if (choice === 'mute') {
318    await update($, isMuted, () => true)
319    await tally($, 'd')
320    $.ui.toast('model-picker · muted for this session. The status line stays.')
321    return PASS
322  }
323  if (choice === 'switch' && alias !== undefined) {
324    await tally($, 'a')
325    const r = await switchModel({ list: () => $.config.list(), set: (key, value) => $.config.set({ key, value }) }, alias)
326    $.ui.toast(r.isSwitched ? `model-picker · switched to ${nameOf(alias)}` : manualSwitchHint(alias))
327    if (r.isSwitched) await showStatus($, cfg, `model-picker: switched to ${nameOf(alias)}`)
328    return PASS // the prompt itself continues; the new model reads it if the switch took
329  }
330  if (choice === 'subagent' && alias !== undefined) {
331    await tally($, 'a')
332    const started = await spawnSubagent(args => $.agent.spawn(args), text, alias)
333    if (started.ok) return { kind: 'drop', reason: `Sent to a ${alias} subagent; its answer will appear when done` }
334    // A subagent a mod starts is refused where your own prompt did not ask for one (auto mode's check, for one). So the
335    // request goes into your prompt instead: Claude starts the subagent itself, as you asked.
336    $.ui.log(`model-picker · could not start the subagent directly (${started.why}); asking Claude to delegate instead`)
337    $.ui.toast(`Asking Claude to run this as a ${nameOf(alias)} subagent`, { timeoutMs: 6000 })
338    return { kind: 'rewrite', text: delegateText(text, alias) }
339  }
340  if (choice === 'codex') {
341    await tally($, 'a')
342    $.ui.toast('model-picker · asking Codex, read-only (can take a few minutes)', { timeoutMs: 8000 })
343    const r = await runCodex(runner($), await quietly(() => $.session.cwd()), text)
344    if (!r.ok) {
345      $.ui.toast(`model-picker · Codex did not answer (${r.why}), sending as typed`, { timeoutMs: 8000 })
346      return PASS
347    }
348    $.ui.log(`Codex review · read-only\n${r.text}`)
349    return { kind: 'context', context: [codexContext(r.text)] }
350  }
351  await tally($, 'd') // keep (or free text)
352  return PASS
353}
354
355// ----------------------------------------------------------------------- commands
356
357function verdictText(fit: Fit, current: Family | undefined): string {
358  if (fit.kind === 'none') return `no suggestion (${fit.why})`
359  const me = current === undefined ? 'the current model' : nameOf(current)
360  if (fit.kind === 'fits') return `${me} fits`
361  if (fit.kind === 'review') return `offers a Codex review (if installed): ${me} should not approve its own work`
362  const rec = nameOf(fit.recommended)
363  if (fit.kind === 'hint') return `status line only (${fit.why === 'borderline' ? `${me} is borderline` : 'spare capacity'}, ${rec} is the alternative; askOnStandard makes it ask)`
364  return fit.risk === 'under' ? `blocks with "Don't use ${me} for this task" and offers ${rec}` : `blocks with "${me} is overkill for this" and offers ${rec}`
365}
366
367async function explain($: Dollar, text: string, cfg: Cfg): Promise<string> {
368  if (text.trim() === '') return 'Usage: /model-picker explain <text to test>'
369  const p = predict(text)
370  const current = familyOf((await quietly(() => $.session.model())) ?? '')
371  const fit = judge(p, current, cfg.minConfidence, cfg.costs, cfg.askOnStandard)
372  const short = wordCount(text) < cfg.minWords && !isStrong(p) ? ' · too short, would be ignored' : ''
373  const skipped = isNotATask(text.trim()) ? ' · not a task, would not be checked' : ''
374  return [
375    `${TIER_WORD[p.tier].charAt(0)}${TIER_WORD[p.tier].slice(1).toLowerCase()} task · confidence ${p.confidence.toFixed(2)}${short}${skipped}`,
376    `Because ${p.reasons.join(', ')}`,
377    compactTable(ratingTable(p.tier, current, cfg.costs)),
378    `On ${current === undefined ? 'an unrecognised model' : nameOf(current)}: ${verdictText(fit, current)}.`,
379    `Dry run, nothing sent. Rough predictions, not measurements. Cheapest good enough: ${nameOf(recommend(p.tier, cfg.costs))}.`,
380    ...(cfg.costProblems.length > 0 ? [`Ignored in costRatios: ${cfg.costProblems.join(', ')}.`] : []),
381  ].join('\n')
382}
383
384async function status($: Dollar, cfg: Cfg): Promise<string> {
385  const on = await isOn($, cfg)
386  const c = await readCounters($)
387  const total = c.accepted + c.dismissed
388  const model = familyOf((await quietly(() => $.session.model())) ?? '')
389  const list = await read($, recent)
390  const rows = list.map(r => `${TIER_WORD[r.tier].toLowerCase()} ${r.confidence.toFixed(2)} “${r.snippet}” · ${r.reasons.join(', ')} → ${r.outcome}`)
391  const costs = (['haiku', 'sonnet', 'opus', 'fable'] as const).filter(f => cfg.costs[f] !== undefined).map(f => `${nameOf(f)} ${cfg.costs[f]}x`).join(' · ')
392  return [
393    `model-picker · ${on ? 'on' : 'off'} · ${model === undefined ? 'unrecognised model' : nameOf(model)}`,
394    on
395      ? `Blocks and asks on a mismatch (confidence ≥ ${cfg.minConfidence.toFixed(2)})${cfg.askOnStandard ? ', ordinary tasks too' : ''}. Esc ${cfg.onDismiss === 'hold' ? 'holds the prompt' : 'sends it'}.`
396      : 'Not checking prompts. Type /model-picker on to resume.',
397    `${costs} (rough, set in costRatios)`,
398    total === 0 ? 'No answers yet' : `${c.accepted} accepted · ${c.dismissed} dismissed`,
399    ...((await read($, isMuted)) ? ['Questions muted this session. Type /model-picker on to lift it.'] : []),
400    ...(cfg.costProblems.length > 0 ? [`Ignored in costRatios: ${cfg.costProblems.join(', ')}.`] : []),
401    ...(rows.length > 0 ? ['Recent', ...rows.map(r => `  ${r}`)] : []),
402  ].join('\n')
403}
404
405async function runCommand($: Dollar, args: string, cfg: Cfg): Promise<string> {
406  const [word = '', ...rest] = args.trim().split(/\s+/)
407  const arg = word.toLowerCase()
408  if (arg === 'explain') return explain($, rest.join(' '), cfg)
409  if (arg === 'off') {
410    await setSwitch($, cfg, true)
411    return 'model-picker · off\nNo prompt is checked. Type /model-picker on to resume.'
412  }
413  if (arg === 'on') {
414    if (!cfg.isEnabled) return 'model-picker · disabled\nThe enabled setting is false. Change it in /config.'
415    await setSwitch($, cfg, false)
416  }
417  const text = await status($, cfg)
418  return arg === '' || arg === 'on' || arg === 'status' ? text : `${text}\nTry: /model-picker · /model-picker on · /model-picker off · /model-picker explain <text>`
419}
420
421// --------------------------------------------------------------------- register
422
423export const register: Register = (on, options) => {
424  const cfg = readConfig(options)
425
426  // The main conversation's last response: its cache is warm for a TTL from here (subagents keep caches of their own).
427  // A subagent Claude starts without naming a model gets the cheapest one its task needs (never above the session's).
428  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
429    const input = e as unknown as { prompt?: unknown; model?: unknown; subagent_type?: unknown }
430    if (!cfg.routeSubagents || input.model !== undefined || input.subagent_type === 'fork' || typeof input.prompt !== 'string') return next(e)
431    const parent = routed ?? familyOf((await quietly(() => $.session.model())) ?? '')
432    const pick = cheaperFit(predict(input.prompt), parent, cfg.costs, cfg.minConfidence)
433    return next(pick === undefined ? e : ({ ...e, model: pick } as typeof e))
434  }).catch(($, e, next) => next(e))
435
436  // /clear starts an empty conversation: nothing cached to lose, and routing may pick again.
437  on('session.end', async ($, e, next) => {
438    if (e.reason === 'clear') {
439      lastResponseAt = undefined
440      routed = undefined
441      routeOff = false
442      await quietly(() => update($, routedTo, () => ''))
443    }
444    return next(e)
445  })
446
447  on('turn.complete', async ($, e, next) => {
448    if (e.agentId === undefined) lastResponseAt = await $.clock.now().catch(() => Date.now())
449    return next(e)
450  })
451
452  on('session.start', async ($, e, next) => {
453    await quietly(async () => {
454      const stored = (await $.store.get('off')) === true // the switch outlives the session
455      await update($, isOff, () => stored)
456      if (stored && cfg.isEnabled) await showStatus($, cfg, OFF_STATUS)
457    })
458    await quietly(() =>
459      $.command.register({
460        name: 'model-picker',
461        description: 'Suggest model: status, on, off, or explain <text>',
462        argumentHint: 'status|on|off|explain <text>',
463      }),
464    )
465    return next(e)
466  }).catch(($, e, next) => next(e))
467
468  on('command.run', { command: 'model-picker' }, async ($, e) => ({ text: await runCommand($, e.args, cfg) })).catch(() => ({
469    text: 'model-picker · that did not work, nothing changed',
470  }))
471
472  // Effort-watch only reads: the level of each main-thread request is kept (off the request's path) for the next prompt's status line.
473  // Each main-loop request: note its effort for the status line, and in a routed conversation name the routed model, so
474  // the conversation stays on one model (and one cache) throughout. A model you pick yourself with /model wins: routing
475  // stops until the next conversation.
476  on('turn.step', async function* ($, e, next) {
477    if (e.agentId !== undefined) return yield* next(e)
478    const level = typeof e.effort === 'string' ? e.effort : '' // state holds JSON data: empty means none
479    $.clock.after(1, () => void quietly(() => update($, effortNow, () => level)))
480    if (routed === undefined) return yield* next(e)
481    const now = familyOf((await quietly(() => $.session.model())) ?? '')
482    if (now !== routedFrom) {
483      routed = undefined
484      routeOff = true
485      await quietly(() => update($, routedTo, () => ''))
486      return yield* next(e)
487    }
488    return yield* next({ ...e, model: MODEL_ID[routed] })
489  })
490
491  // Exactly one `next` per path. A drop is answered WITHOUT `next`: after a subagent really started
492  // (the current model must not also answer).
493  on('prompt.submit', async ($, e, next) => {
494    const out = await review($, e, cfg).catch((): Outcome => PASS)
495    if (out.kind === 'drop') return dropWith(note => $.ui.log(note), out.reason)
496    if (out.kind === 'rewrite') return next({ ...e, text: out.text })
497    return next(out.kind === 'context' ? { ...e, context: [...(e.context ?? []), ...out.context] } : e)
498  }).catch(($, e, next) => next(e)) // fail open; if `next` already ran this replays its result
499}
500
hooks/actions.ts 134 lines
1// The three things a person can choose, each written to fail soft: an error is reported in
2// a toast or the log and the prompt then goes on untouched.
3import type { ConfigRow, ConfigSetResult, ProcessRunInit, ProcessRunResult } from 'claude-code'
4
5import { describeTask } from './gate'
6import type { Alias } from './gate'
7
8// The engine validator follows `$` only inside the file that names it, so these helpers take
9// small closures built in register.tsx (`run`, `config`, `spawn`) instead of `$` itself.
10export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
11export type ConfigApi = { list: () => Promise<ConfigRow[]>; set: (key: string, value: string) => Promise<ConfigSetResult> }
12export type Spawn = (args: { prompt: string; description: string; model: string }) => Promise<{ deny?: string }>
13
14const CODEX_TIMEOUT_MS = 300_000
15const CODEX_PROBE_TIMEOUT_MS = 8_000
16const CODEX_MAX_PROMPT_CHARS = 8_000 // argv limits are tight on Windows
17const CODEX_MAX_REPLY_CHARS = 20_000
18
19export const CODEX_INSTRUCTIONS =
20  'Please review the following request. Try to falsify it rather than confirm it. Cite file:line for every finding, ' +
21  'and give PASS, FAIL or UNVERIFIABLE for each claim. Do not edit anything.'
22
23/**
24 * The auto-mode classifier sometimes answers an action started by a hook (not by a model request) with "gave no verdict ...
25 * Issue the action again once, as-is". That is a transient refusal: the same action is tried once more, never a third time.
26 */
27export const isNoVerdict = (why: string | undefined): boolean => why !== undefined && /no verdict|issue the action again/i.test(why)
28
29async function retriedOnce<T extends { deny?: string }>(attempt: () => Promise<T>): Promise<T> {
30  const first = await attempt()
31  return isNoVerdict(first.deny) ? await attempt() : first
32}
33
34// ------------------------------------------------------------------- model switch
35
36/**
37 * The /config row that holds the session model. Prefer the engine's `model` row; otherwise
38 * an ENGINE row whose key or label mentions "model" (never another plugin's `reflect.model`
39 * or similar) and which is not about fast mode, subagents or pricing.
40 */
41export function findModelRow(rows: readonly ConfigRow[]): ConfigRow | undefined {
42  const exact = rows.find(r => r.key === 'model')
43  if (exact) return exact
44  return rows.find(
45    r =>
46      r.provider.plugin === 'engine' &&
47      (r.kind === 'choice' || r.kind === 'text') &&
48      /model/i.test(`${r.key} ${r.label}`) &&
49      !/fast|small|subagent|agent|plan|pricing|effort|override/i.test(`${r.key} ${r.label}`),
50  )
51}
52
53/** The row's value for an alias: an exact option, else the shortest option containing it; the alias itself for a free-text row. */
54export function valueFor(row: ConfigRow, alias: Alias): string | undefined {
55  if (row.options === undefined) return row.kind === 'text' ? alias : undefined
56  const lower = alias.toLowerCase()
57  const options = row.options
58  return (
59    options.find(o => o.toLowerCase() === lower) ??
60    [...options.filter(o => o.toLowerCase().includes(lower))].sort((a, b) => a.length - b.length)[0]
61  )
62}
63
64export type SwitchResult = { isSwitched: true } | { isSwitched: false; why: string }
65
66/** Sets the model row; never throws. Callers toast `/model <alias>` on a failure. */
67export async function switchModel(config: ConfigApi, alias: Alias): Promise<SwitchResult> {
68  try {
69    const row = findModelRow(await config.list())
70    if (row === undefined) return { isSwitched: false, why: 'no model row in /config' }
71    if (row.isLocked) return { isSwitched: false, why: 'the model is set by policy' }
72    const value = valueFor(row, alias)
73    if (value === undefined) return { isSwitched: false, why: `no ${alias} option in /config` }
74    const result = await retriedOnce(() => config.set(row.key, value))
75    return result.deny === undefined ? { isSwitched: true } : { isSwitched: false, why: result.deny }
76  } catch (error) {
77    return { isSwitched: false, why: error instanceof Error ? error.message : 'the change was refused' }
78  }
79}
80
81export const manualSwitchHint = (alias: Alias): string =>
82  `model-picker: could not switch automatically. Type /model ${alias} yourself.`
83
84// ---------------------------------------------------------------------- subagent
85
86/** Starts the subagent in the background. Never throws: `ok`, or `why` (the engine's own reason, cut short). */
87export async function spawnSubagent(spawn: Spawn, text: string, alias: Alias): Promise<{ ok: true } | { ok: false; why: string }> {
88  try {
89    const r = await retriedOnce(() => spawn({ prompt: text, description: describeTask(text), model: alias }))
90    return r.deny === undefined ? { ok: true } : { ok: false, why: String(r.deny).slice(0, 160) }
91  } catch (error) {
92    return { ok: false, why: (error instanceof Error ? error.message : 'the call failed').slice(0, 160) }
93  }
94}
95
96/** Your prompt with your choice spelled out, so Claude delegates it: the subagent is then one you asked for. */
97export const delegateText = (text: string, alias: Alias): string =>
98  `${text}\n\n(I chose to run this as a subagent on ${alias}: use the Agent tool with model "${alias}" and give it this task in full; do not do the task yourself.)`
99
100// ------------------------------------------------------------------------- codex
101
102/** `codex --version` works? Both a missing program (rejects) and a failing one count as no. */
103export async function probeCodex(run: Run): Promise<boolean> {
104  try {
105    return (await run(['codex', '--version'], { timeoutMs: CODEX_PROBE_TIMEOUT_MS })).exitCode === 0
106  } catch {
107    return false
108  }
109}
110
111export type CodexResult = { ok: true; text: string } | { ok: false; why: string }
112
113/** One read-only, ephemeral Codex review of the person's prompt. Best effort: never throws. */
114export async function runCodex(run: Run, cwd: string | undefined, prompt: string): Promise<CodexResult> {
115  try {
116    const body = prompt.length > CODEX_MAX_PROMPT_CHARS ? `${prompt.slice(0, CODEX_MAX_PROMPT_CHARS)} [cut]` : prompt
117    const r = await run(
118      ['codex', 'exec', '-s', 'read-only', '--ephemeral', ...(cwd ? ['-C', cwd] : []), `${CODEX_INSTRUCTIONS}\n\n${body}`],
119      { timeoutMs: CODEX_TIMEOUT_MS },
120    )
121    const out = r.stdout.trim()
122    if (r.exitCode !== 0) return { ok: false, why: firstLine(r.stderr) || `exit code ${r.exitCode}` }
123    if (out === '') return { ok: false, why: 'Codex returned nothing' }
124    return { ok: true, text: out.length > CODEX_MAX_REPLY_CHARS ? `${out.slice(0, CODEX_MAX_REPLY_CHARS)}\n[cut]` : out }
125  } catch (error) {
126    return { ok: false, why: error instanceof Error ? error.message : 'could not run codex' }
127  }
128}
129
130const firstLine = (s: string): string => s.trim().split(/\r?\n/)[0]?.slice(0, 160) ?? ''
131
132export const codexContext = (reply: string): string =>
133  `A read-only Codex review of the request above, run before this turn. Treat it as a second opinion: check its claims before relying on them.\n\n${reply}`
134
hooks/dialog.ts 109 lines
1// The question and its options. Pure text, so the dialog is the same on every surface
2// (the engine draws `$.ui.ask` itself).
3import { fmtRatio, nameOf } from './fit'
4import type { Risk, Row } from './fit'
5import { BIG_CONTEXT_TOKENS, fmtTokens } from './gate'
6import type { Alias, Family } from './gate'
7import { TIER_WORD } from './predict'
8import type { Prediction } from './predict'
9
10export type DialogInput = {
11  prediction: Prediction
12  current: Family
13  /** 'self': a review by the model that would also have written the code; no model move is offered. */
14  risk: Risk | 'self'
15  /** The model to move to: the cheapest one predicted to handle the task. Absent for 'self'. */
16  target?: Alias
17  /** Per-model fit and rough relative cost, as shown in the question. */
18  table: readonly Row[]
19  /** "~40x cheaper" or ''. */
20  cheaper: string
21  contextTokens: number
22  /** The current model's prompt cache is probably still warm: a switch would throw it away and re-cache the context. */
23  isCacheWarm: boolean
24  canSubagent: boolean
25  /** Codex is offered (review work and `codex --version` worked). */
26  hasCodex: boolean
27}
28
29export type Choice = 'keep' | 'switch' | 'subagent' | 'codex' | 'mute'
30export type Dialog = { question: string; labels: Record<Choice, string>; order: Choice[] }
31
32export function buildDialog(d: DialogInput): Dialog {
33  const me = nameOf(d.current)
34  const to = d.target === undefined ? '' : nameOf(d.target)
35  const labels: Record<Choice, string> = {
36    keep: `Keep ${me}`,
37    switch: `Switch session to ${to}`,
38    subagent: `Run this task as a subagent on ${to}`,
39    codex: 'Ask Codex for a read-only review',
40    mute: `Keep ${me} and don't suggest again this session`,
41  }
42  const isBig = d.contextTokens > BIG_CONTEXT_TOKENS
43  const isReview = d.prediction.tier === 'review'
44  // Mid-session, a switch re-caches the whole conversation on the new model (and again on the way back), which costs more
45  // than a cheaper model saves on a task or two. So for an overkill task with a warm cache and a big context the move is
46  // a subagent (a fresh, small context) or nothing; a switch is offered when the cache is cold or the context small.
47  const isCostlySwitch = isBig && d.isCacheWarm
48  const noSwitch = isCostlySwitch && d.risk === 'over'
49  // The ask dialog takes at most 4 options, so a review trades the subagent for Codex.
50  const move: Choice[] =
51    d.target === undefined ? ['codex'] : isReview ? (noSwitch ? ['codex'] : ['switch', 'codex']) : noSwitch ? ['subagent'] : isBig && d.canSubagent ? ['subagent', 'switch'] : ['switch', 'subagent']
52  const order = ['keep' as const, ...move.filter(c => available(c, d)), 'mute' as const]
53  return { question: questionText(d, isBig, noSwitch), labels, order }
54}
55
56function available(c: Choice, d: DialogInput): boolean {
57  if (c === 'subagent') return d.canSubagent
58  if (c === 'codex') return d.hasCodex
59  return true
60}
61
62const taskWord = (p: Prediction): string => `${TIER_WORD[p.tier].charAt(0)}${TIER_WORD[p.tier].slice(1).toLowerCase()} task`
63
64/** "Haiku 1x weak · Sonnet 20x weak · Opus 40x best": one quiet line instead of a sentence. */
65export function compactTable(rows: readonly Row[]): string {
66  return rows.map(r => `${nameOf(r.family)} ${r.cost === undefined ? '' : `${fmtRatio(r.cost)} `}${r.rating}`).join(' · ')
67}
68
69function questionText(d: DialogInput, isBig: boolean, noSwitch: boolean): string {
70  const { prediction: p, current } = d
71  const me = nameOf(current)
72  if (d.risk === 'self' || d.target === undefined) return selfReviewText(d, me)
73  const target = nameOf(d.target)
74  const why = p.reasons.slice(0, 3).join(', ')
75  const lines = [
76    d.risk === 'under' ? `Don't use ${me} for this task` : `${me} is overkill for this`,
77    `${taskWord(p)} · ${why}`,
78    compactTable(d.table),
79    d.risk === 'under'
80      ? `${target} is the cheapest model that should handle it. Costlier and slower.`
81      : `${target} would do${d.cheaper ? ` · ${d.cheaper}` : ''}.`,
82  ]
83  const notes: string[] = []
84  if (noSwitch) notes.push(`Not switching: the session would re-cache ~${fmtTokens(d.contextTokens)} tokens on ${target}, more than this task saves. A subagent starts fresh. Switch at a natural break (after /clear, or once the cache has gone cold).`)
85  else if (isBig && d.isCacheWarm) notes.push(`Switching re-caches ~${fmtTokens(d.contextTokens)} tokens on ${target} (and again on the way back); a subagent starts fresh.`)
86  else if (isBig) notes.push(`The cache has gone cold, so switching now costs little extra; a subagent starts fresh.`)
87  else if (d.canSubagent && p.tier !== 'review') notes.push('A subagent has no memory of this chat.')
88  if (d.hasCodex) notes.push('Codex reviews read-only and uses Codex credit.')
89  return [...lines, ...(notes.length > 0 ? ['', ...notes] : [])].join('\n')
90}
91
92function selfReviewText(d: DialogInput, me: string): string {
93  return [
94    `Get a second opinion on this`,
95    `Review · ${d.prediction.reasons.slice(0, 3).join(', ')}`,
96    `A different model (Codex) catches more than ${me} reviewing its own work.`,
97    '',
98    'Read-only · uses Codex credit.',
99  ].join('\n')
100}
101
102/** True when the dialog offers nothing but keeping the model: not worth asking. */
103export const isKeepOnly = (dialog: Dialog): boolean => dialog.order.every(c => c === 'keep' || c === 'mute')
104
105/** Maps the exact label the person chose back to a choice; anything else (free text under Other) is undefined. */
106export function choiceOf(dialog: Dialog, answer: string): Choice | undefined {
107  return dialog.order.find(c => dialog.labels[c] === answer)
108}
109
hooks/reply.ts 31 lines
1/**
2 * A `prompt.submit` drop reason must be ONE line: the engine draws a "\n" in it as a stray "?" mark.
3 * `dropWith` splits a multi-line text: the first line becomes the reason, the rest goes to the transcript
4 * as one dim, multi-line note (`$.ui.log`, never sent to the model).
5 */
6
7// Control characters (tabs, carriage returns, escapes, ...) that would break a one-line reason.
8const CONTROL = /[\u0000-\u001f\u007f-\u009f]+/g
9
10/** A single line with control characters turned into spaces and the ends trimmed. */
11export const oneLine = (text: string): string => text.replace(CONTROL, ' ').replace(/ {2,}/g, ' ').trim()
12
13/** `log` is the caller's `(note) => $.ui.log(note)`: the engine refuses `$.ui` handed around as a value. */
14export function dropWith(log: (text: string) => void, text: string): { drop: string } {
15  const lines = text
16    .split(/\r\n|\r|\n/)
17    .map(line => line.trim())
18    .filter(line => line !== '')
19  const drop = oneLine(lines[0] ?? '') || 'model-picker'
20  const rest = lines.slice(1)
21  if (rest.length > 0) {
22    try {
23      // A note is a courtesy: a failing log must never undo the drop.
24      void Promise.resolve(log(rest.join('\n'))).catch(() => undefined)
25    } catch {
26      // keep the drop
27    }
28  }
29  return { drop }
30}
31
hooks/fit.ts 171 lines
1// Per-model fit and cost: for a predicted tier, rate every candidate model, recommend the
2// cheapest one rated at least "ok", and turn that into a verdict. Pure functions.
3//
4// Everything here is PREDICTED from keyword rules and ROUGH list-price ratios the person can edit.
5// Nothing is measured: no real prices, no benchmark scores.
6import type { Alias, Family } from './gate'
7import { RANK } from './gate'
8import type { Prediction, Tier } from './predict'
9
10export type Rating = 'weak' | 'ok' | 'best'
11export type Costs = Partial<Record<Family, number>>
12
13export const CANDIDATES: readonly Alias[] = ['haiku', 'sonnet', 'opus']
14export const DEFAULT_COSTS_TEXT = 'haiku=1,sonnet=20,opus=40'
15const DEFAULT_COSTS: Costs = { haiku: 1, sonnet: 20, opus: 40 }
16
17/** best = the most capable model; ok = predicted good enough; weak = likely to struggle. Review work is rated like deep work. */
18const RATING: Record<Exclude<Tier, 'review'>, Record<Family, Rating>> = {
19  light: { haiku: 'ok', sonnet: 'ok', opus: 'best', fable: 'best' },
20  standard: { haiku: 'weak', sonnet: 'ok', opus: 'best', fable: 'best' },
21  deep: { haiku: 'weak', sonnet: 'weak', opus: 'best', fable: 'best' },
22}
23
24export const ratingOf = (tier: Tier, family: Family): Rating => RATING[tier === 'review' ? 'deep' : tier][family]
25
26export const nameOf = (f: Family): string => f.charAt(0).toUpperCase() + f.slice(1)
27
28// ------------------------------------------------------------------------ cost config
29
30const FAMILIES: readonly Family[] = ['haiku', 'sonnet', 'opus', 'fable']
31
32/**
33 * Parses "haiku=1,sonnet=20,opus=40" (fable optional; commas, semicolons or spaces; "3x" is fine).
34 * Bad pieces are ignored, one by one, and reported in `problems`; a missing or bad model keeps its
35 * default (fable has none: its cost is simply unknown).
36 */
37export function parseCosts(raw: unknown): { costs: Costs; problems: string[] } {
38  const costs: Costs = { ...DEFAULT_COSTS }
39  const problems: string[] = []
40  if (typeof raw !== 'string' || raw.trim() === '') return { costs, problems }
41  for (const piece of raw.split(/[,;\s]+/).filter(Boolean)) {
42    const m = /^([a-z]+)=(\d+(?:\.\d+)?)x?$/i.exec(piece)
43    const family = FAMILIES.find(f => f === m?.[1]?.toLowerCase())
44    const value = Number(m?.[2])
45    if (m === null || family === undefined || !Number.isFinite(value) || value <= 0 || value > 10_000) {
46      problems.push(piece.slice(0, 30))
47      continue
48    }
49    costs[family] = value
50  }
51  return { costs, problems }
52}
53
54/** A model with no known cost (fable by default) counts as the most expensive. */
55const costOf = (costs: Costs, f: Family): number => costs[f] ?? Number.POSITIVE_INFINITY
56
57export function fmtRatio(n: number): string {
58  return `${Number.isInteger(n) ? n : n.toFixed(1).replace(/\.0$/, '')}x`
59}
60
61// ------------------------------------------------------------------ rating and choice
62
63export type Row = { family: Family; rating: Rating; cost: number | undefined }
64
65/** Haiku, Sonnet, Opus (and the current model if it is Fable), each with its predicted rating and cost. */
66export function ratingTable(tier: Tier, current: Family | undefined, costs: Costs): Row[] {
67  const families: Family[] = current === 'fable' ? [...CANDIDATES, 'fable'] : [...CANDIDATES]
68  return families.map(family => ({ family, rating: ratingOf(tier, family), cost: costs[family] }))
69}
70
71export function tableText(rows: readonly Row[]): string {
72  return rows.map(r => `${nameOf(r.family)}: ${r.rating}, ${r.cost === undefined ? 'cost not set' : fmtRatio(r.cost)}`).join('; ')
73}
74
75/** The cheapest candidate rated at least "ok" (ties go to the weaker model). Never fable. */
76export function recommend(tier: Tier, costs: Costs): Alias {
77  const fit = CANDIDATES.filter(a => ratingOf(tier, a) !== 'weak')
78  return [...fit].sort((a, b) => costOf(costs, a) - costOf(costs, b) || RANK[a] - RANK[b])[0] ?? 'opus'
79}
80
81/** "~40x cheaper", or '' when it cannot be said (unknown cost, or not actually cheaper). */
82export function cheaperBy(costs: Costs, from: Family, to: Family): string {
83  const a = costOf(costs, from)
84  const b = costOf(costs, to)
85  return Number.isFinite(a) && b > 0 && a > b ? `~${fmtRatio(Math.round((a / b) * 10) / 10)} cheaper` : ''
86}
87
88// ------------------------------------------------------------------------- the verdict
89
90export type Risk = 'under' | 'over'
91
92export type Fit =
93  | { kind: 'none'; why: string }
94  /** The current model suits the task. */
95  | { kind: 'fits' }
96  /** Worth a word on the status line, never a dialog. */
97  | { kind: 'hint'; recommended: Alias; why: 'borderline' | 'spare' }
98  /**
99   * A review-type task on a model that is strong enough: the point is that a DIFFERENT model reviews it,
100   * not capability. Only a Codex offer makes sense; the caller turns this into `fits` when Codex is absent.
101   */
102  | { kind: 'review' }
103  /** A mismatch: this is what may open the dialog. */
104  | { kind: 'mismatch'; risk: Risk; recommended: Alias }
105
106/** A standard task needs this much confidence before even a status-line hint is shown. */
107export const STANDARD_MIN = 0.7
108
109/**
110 * `strict` turns the two standard-tier hints into mismatches (borderline = 'under', spare capacity = 'over'),
111 * so ordinary tasks can block too. Off by default: a standard task is usually fine on Sonnet.
112 */
113export function judge(p: Prediction, current: Family | undefined, minConfidence: number, costs: Costs, strict = false): Fit {
114  if (current === undefined) return { kind: 'none', why: 'current model not recognised' }
115  const floor = p.tier === 'standard' ? Math.max(minConfidence, STANDARD_MIN) : minConfidence
116  if (p.confidence < floor) return { kind: 'none', why: `confidence ${p.confidence.toFixed(2)} below ${floor.toFixed(2)}` }
117  const recommended = recommend(p.tier, costs)
118  const rating = ratingOf(p.tier, current)
119  const isCheaper = costOf(costs, recommended) < costOf(costs, current)
120  if (p.tier === 'deep' || p.tier === 'review') {
121    if (rating === 'weak') return { kind: 'mismatch', risk: 'under', recommended }
122    return p.tier === 'review' ? { kind: 'review' } : { kind: 'fits' }
123  }
124  if (p.tier === 'light') return isCheaper ? { kind: 'mismatch', risk: 'over', recommended } : { kind: 'fits' }
125  if (rating === 'weak') return strict ? { kind: 'mismatch', risk: 'under', recommended } : { kind: 'hint', recommended, why: 'borderline' }
126  if (!isCheaper) return { kind: 'fits' }
127  return strict ? { kind: 'mismatch', risk: 'over', recommended } : { kind: 'hint', recommended, why: 'spare' }
128}
129
130// ------------------------------------------------------------------------- effort
131
132/**
133 * Effort-watch: a short note when the session's effort level does not match the task, or undefined. Advice only, on the status
134 * line, never a dialog. On Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.1 changing effort keeps the prompt cache, so /effort is
135 * a cheap move there; on older models it re-reads the conversation once. Anthropic's guidance is medium for most work, high or
136 * xhigh when it matters, and max rarely (it "may show diminishing returns and is prone to overthinking").
137 */
138export function effortNote(tier: Tier, effort: unknown): string | undefined {
139  if (typeof effort !== 'string') return undefined
140  const level = effort.toLowerCase()
141  if (level === 'max' && (tier === 'light' || tier === 'standard')) return 'effort max is more than this needs · /effort medium'
142  if (level === 'xhigh' && tier === 'light') return 'effort xhigh is more than this needs · /effort medium'
143  if (level === 'low' && (tier === 'deep' || tier === 'review')) return 'effort low may be too little for this · /effort high'
144  return undefined
145}
146
147/** The same check as a few words for the moddeck's model row ("medium would do", "try high"), or undefined when the effort fits. */
148export function effortAdvice(tier: Tier, effort: unknown): string | undefined {
149  const note = effortNote(tier, effort)
150  return note === undefined ? undefined : note.includes('/effort high') ? 'try high' : 'medium would do'
151}
152
153// ------------------------------------------------------------------------- status line
154
155/** The always-on one-liner. Undefined clears the line (nothing placed, or the model is not recognised). */
156export function statusText(fit: Fit, current: Family | undefined, costs: Costs): string | undefined {
157  if (current === undefined || fit.kind === 'none') return undefined
158  const me = nameOf(current)
159  if (fit.kind === 'fits') return `model-picker: ${me} fits`
160  if (fit.kind === 'review') return 'model-picker: review, Codex available'
161  const rec = nameOf(fit.recommended)
162  if (fit.kind === 'hint') {
163    if (fit.why === 'borderline') return `model-picker: ${me} is borderline for this, ${rec} is safer`
164    const by = cheaperBy(costs, current, fit.recommended)
165    return `model-picker: ${rec} would do${by ? ` (${by})` : ''}`
166  }
167  if (fit.risk === 'under') return `model-picker: weak for this, use ${rec}`
168  const by = cheaperBy(costs, current, fit.recommended)
169  return `model-picker: overkill, ${rec} would do${by ? ` (${by})` : ''}`
170}
171
hooks/gate.ts 50 lines
1// Gating: whether a prediction is worth interrupting the person for. Pure functions.
2import { namedFiles, refsPriorContext } from './predict'
3
4export type Alias = 'haiku' | 'sonnet' | 'opus'
5/** What a resolved model id can map to; `fable` is known but never a target. */
6export type Family = Alias | 'fable'
7
8export const RANK: Record<Family, number> = { haiku: 1, sonnet: 2, opus: 3, fable: 4 }
9
10/** Maps a resolved model id (`claude-sonnet-4-5`, `opus`) to its family; undefined when unknown. */
11export function familyOf(modelId: string): Family | undefined {
12  const id = modelId.toLowerCase()
13  return (['haiku', 'sonnet', 'opus', 'fable'] as const).find(f => id.includes(f))
14}
15
16/** Pushes an answer ('a' accepted, 'd' dismissed) onto the short history kept for the status report. */
17export const HISTORY_WINDOW = 10
18
19export const pushHistory = (history: readonly string[], item: 'a' | 'd'): string[] =>
20  [...history, item].slice(-HISTORY_WINDOW)
21
22// ------------------------------------------------------------------ subagent fitness
23
24export { refsPriorContext }
25
26/** A subagent starts with no conversation: fine for a self-contained prompt or one that names files. */
27export const canRunAlone = (text: string): boolean => !refsPriorContext(text) || namedFiles(text).length > 0
28
29// ----------------------------------------------------------------- display helpers
30
31export function fmtTokens(n: number): string {
32  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
33  if (n >= 1000) return `${Math.round(n / 1000)}k`
34  return String(Math.round(n))
35}
36
37export const BIG_CONTEXT_TOKENS = 20_000
38
39/** First few words of a prompt, for a subagent's task label. */
40export function describeTask(text: string): string {
41  const words = text.trim().split(/\s+/).slice(0, 6).join(' ')
42  return words.length > 40 ? `${words.slice(0, 37)}...` : words
43}
44
45/** A short single-line snippet for the status list. */
46export function snippet(text: string, max = 50): string {
47  const one = text.replace(/\s+/g, ' ').trim()
48  return one.length > max ? `${one.slice(0, max - 3)}...` : one
49}
50
hooks/input.ts 33 lines
1// What is NOT worth evaluating. Pure functions, no I/O.
2
3const CONFIRMATIONS = new Set([
4  'y', 'yes', 'yep', 'yeah', 'yup', 'ok', 'okay', 'k', 'sure', 'no', 'n', 'nope', 'thanks', 'thank you', 'thx', 'ty',
5  'go', 'go ahead', 'go on', 'continue', 'proceed', 'do it', 'please do', 'sounds good', 'looks good', 'lgtm', 'great',
6  'perfect', 'cool', 'nice', 'done', 'stop', 'retry', 'again', 'next',
7])
8
9/** A bare yes/ok/thanks style reply: it answers the model, it is not a new task. */
10export function isConfirmation(text: string): boolean {
11  const t = text.trim().toLowerCase().replace(/[.!\s]+$/g, '').replace(/\s+/g, ' ')
12  return t.length <= 24 && CONFIRMATIONS.has(t)
13}
14
15// Deliberately narrow: markdown bullets, numbered lists and quotes are what a real request looks like.
16const OUTPUT_LINE =
17  /^\s*(?:\$ |>>> |\d+\s*[:|]\s|-->\s|\|\s*$|\[\d+\]|at \S+ \(|at \S+:\d+|File ".*", line \d+|Traceback|Error:|error(?:\[E\d+\])?:|warning:|Warning message|Compiling \S+ v|Caused by:|[─┌└│]|\+\+\+ |--- |@@ |\|.*\||=+$|-{3,}$|\S+\.\w+:\d+(?::\d+)?:? )/
18
19/**
20 * Pasted terminal or tool output rather than a request: at least five lines and most of them look
21 * like prompts, stack frames, numbered listings, diffs or tables. Also the wrapper tags the app
22 * uses for local command output.
23 */
24export function looksLikeToolOutput(text: string): boolean {
25  if (/^\s*<(local-command-std(out|err)|bash-std(out|err)|command-(name|message))>/.test(text)) return true
26  const lines = text.split(/\r?\n/).filter(l => l.trim() !== '')
27  if (lines.length < 5) return false
28  return lines.filter(l => OUTPUT_LINE.test(l)).length / lines.length >= 0.6
29}
30
31/** True for the prompts that are never judged: slash commands, confirmations, pasted output. */
32export const isNotATask = (text: string): boolean => text.startsWith('/') || isConfirmation(text) || looksLikeToolOutput(text)
33
hooks/predict.ts 200 lines
1// The predictor: a pure function from prompt text to a tier. It is a HEURISTIC (keyword
2// weights plus a few size signals), not a measured router. No model call, no I/O.
3
4export type Tier = 'light' | 'standard' | 'deep' | 'review'
5
6export type Prediction = {
7  tier: Tier
8  /** Points behind the chosen tier (for review/light/deep the winning side, for standard its own). */
9  score: number
10  /** 0.3 to 0.97; how decisively the winner beat the other tiers. Not a probability. */
11  confidence: number
12  /** Short human labels of what matched, strongest first. */
13  reasons: string[]
14}
15
16type Side = 'light' | 'standard' | 'deep' | 'review'
17type Rule = {
18  re: RegExp
19  side: Side
20  pts: number
21  label: string
22  /** The rule is off when this also matches (for example `translate` before a code word). */
23  unless?: RegExp
24}
25
26const CODE_WORDS = String.raw`(?:code|function|script|package|module|class|[rR]|rust|python|julia|c\+\+|typescript|javascript)`
27
28const RULES: Rule[] = [
29  // ---- light: trivial edits and lookups
30  { re: /\b(typos?|misspell\w*|spelling)\b/i, side: 'light', pts: 3, label: 'typo or spelling' },
31  { re: /\brename\b/i, side: 'light', pts: 3, label: 'rename' },
32  { re: /\b(re-?format|prettify|reindent|fix (the )?(indent\w*|whitespace|formatting))\b/i, side: 'light', pts: 3, label: 'formatting' },
33  { re: /\b(add|write|insert) (a |an |some )?(short |brief )?(code )?(comments?|docstrings?|roxygen)\b/i, side: 'light', pts: 3, label: 'add a comment' },
34  { re: /\b(change|set|bump|update) (the )?(constant|default|version|port|timeout|threshold|seed|color|colour|label|title)\b/i, side: 'light', pts: 3, label: 'change a constant' },
35  { re: /\b(git (status|log|diff --stat|branch)|list (the |all )?(files|folders|directories)|which files|ls\b)/i, side: 'light', pts: 3, label: 'list or git status' },
36  { re: /\bcommit message\b/i, side: 'light', pts: 3, label: 'commit message' },
37  { re: /\b(explain|what does) (this|that|the) (one )?(line|regex|snippet|command|flag|error message|warning)\b/i, side: 'light', pts: 3, label: 'explain one line' },
38  { re: new RegExp(String.raw`\btranslat\w+\b(?!.{0,40}\b${CODE_WORDS}\b)`, 'i'), side: 'light', pts: 3, label: 'translate text' },
39  { re: /\b(boilerplate|scaffold(ing)?|skeleton|stub out|template for)\b/i, side: 'light', pts: 2, label: 'boilerplate' },
40  { re: /\b(remove|delete|drop) (the )?(unused|dead|commented[- ]out|duplicate) \w+/i, side: 'light', pts: 3, label: 'remove unused code' },
41  { re: /\b(quick(ly)?|tiny|trivial|tl;?dr|briefly|in one sentence)\b/i, side: 'light', pts: 1, label: 'asked to be quick' },
42  { re: /\bone-?liner\b/i, side: 'light', pts: 2, label: 'one-liner' },
43  { re: /\bjust\b/i, side: 'light', pts: 1, label: '"just"' },
44  { re: /\b(what|which) (flag|option|argument|function|package|command|version|key) (does|do|is|should|am i|are we)\b/i, side: 'light', pts: 2, label: 'simple lookup' },
45  { re: /\b(show|print|display|cat) (me )?(the )?(contents? of|first \d+ lines|head of)\b/i, side: 'light', pts: 2, label: 'show a file' },
46  { re: /\b(add|remove|fix) (a |an |the )?(missing )?(import|library\(\w+\)|use statement|trailing (comma|whitespace|newline))/i, side: 'light', pts: 3, label: 'tiny edit' },
47  { re: /\b(sort|alphabeti[sz]e|capitali[sz]e|lowercase|uppercase) (the |these |this )?\w+/i, side: 'light', pts: 1, label: 'simple edit' },
48
49  // ---- standard: ordinary implementation
50  { re: /\b(write|add|create|extend|update|fix) (a |an |the |some |more |new )?(unit |regression |integration )?(tests?|test cases?|testthat|pytest|specs?)\b/i, side: 'standard', pts: 3, label: 'write tests' },
51  { re: /\b(testthat|pytest|cargo test|devtools::test|jest|vitest|expect_\w+)\b/i, side: 'standard', pts: 2, label: 'test framework' },
52  { re: /\b(implement|write|add|create|build) (a |an |the |new )?(small |simple |helper |utility )?(function|method|endpoint|command|class|module|feature|option|flag|argument|parameter|wrapper|cli|script)\b/i, side: 'standard', pts: 3, label: 'implement a function' },
53  { re: /\b(fix|debug|patch|resolve)\b.{0,40}\b(bug|error|failure|failing test|crash|exception|warning|issue)\b/i, side: 'standard', pts: 2, label: 'fix a bug' },
54  { re: /\b(refactor|clean up|simplify|extract|deduplicate|inline)\b/i, side: 'standard', pts: 2, label: 'small refactor' },
55  { re: /\b(ggplot2?|dplyr|tidyverse|data\.?frame|tibble|pandas|cargo|clippy|tokio|serde|lme4|sommer|asreml|rrBLUP)\b/i, side: 'standard', pts: 1, label: 'library task' },
56  { re: /\b(plot|figure|table|summari[sz]e|parse|read|load|export|import) (the |a |this |these )?\w+/i, side: 'standard', pts: 1, label: 'data handling' },
57
58  // ---- deep: proofs, design, hard debugging, method choice
59  { re: /\b(derive|derivation|prove|proof|theorem|lemma|corollary|closed[- ]form|show that|analytic(al)? (solution|expression))\b/i, side: 'deep', pts: 4, label: 'proof or derivation' },
60  { re: /\b(design|architect(ure)?|re-?architect)\b.{0,50}\b(algorithm|system|architecture|schema|api|pipeline|framework|data model|module structure)\b/i, side: 'deep', pts: 4, label: 'design work' },
61  { re: /\b(system design|architecture|trade-?offs?|design decision)\b/i, side: 'deep', pts: 2, label: 'architecture or trade-offs' },
62  { re: /\b(refactor|migrate|port|rewrite|restructure)\b.{0,60}\b(across|throughout|entire|whole|all (the )?(modules|files|packages|crates)|codebase|repo(sitory)?)\b/i, side: 'deep', pts: 4, label: 'cross-file refactor' },
63  { re: /\b(across|spanning) (all|many|several|multiple|every) (the )?(files|modules|packages|crates|scripts)\b/i, side: 'deep', pts: 3, label: 'many files' },
64  { re: /\b(port|rewrite|translate|convert)\b.{0,40}\b(to|into|in) (rust|c\+\+|julia)\b/i, side: 'deep', pts: 3, label: 'port to another language' },
65  { re: /\b(race conditions?|deadlock\w*|data race|memory leak|segfault|use-after-free|heisenbug|non-?determinis\w*|flaky|borrow checker|lifetimes?)\b/i, side: 'deep', pts: 4, label: 'hard debugging' },
66  { re: /\b(only sometimes|intermittent\w*|occasionally|randomly|only (when|on|after)\b|can'?t (reproduce|repro)|works on my machine)\b/i, side: 'deep', pts: 3, label: 'intermittent behaviour' },
67  { re: /\bwhy (does|do|is|are|would)\b.{0,80}\bonly\b/i, side: 'deep', pts: 3, label: '"why ... only"' },
68  { re: /\b(numerical(ly)? (stab\w+|precision|instab\w+|error)|ill-?conditioned|singular (matrix|information)|catastrophic cancellation|cholesky|eigen(value|vector|decomposition)?s?|convergence)\b/i, side: 'deep', pts: 3, label: 'numerical method' },
69  { re: /\b(which|what) (statistical |estimation |modelling |modeling )?(method|test|model|estimator|approach|prior|link)\b.{0,60}\b(should|best|appropriate|use|choose)\b/i, side: 'deep', pts: 3, label: 'method choice' },
70  { re: /\b(REML|BLUPs?|BLUE|variance components?|heritability|genomic (prediction|selection|relationship)|GBLUP|mixed models?|likelihood|posterior|MCMC|gibbs|bayesian|identifiab\w+|asymptotic\w*|unbiased(ness)?|multiple testing|false discovery|bootstrap)\b/i, side: 'deep', pts: 2, label: 'statistical theory' },
71  { re: /\b(kinship|relationship matrix|GWAS|genome-?wide association|linkage disequilibrium|population structure|epistasis|QTL|breeding values?|selection index)\b/i, side: 'deep', pts: 2, label: 'quantitative genetics' },
72  { re: /\b(experimental design|alpha[- ]lattice|incomplete block|split[- ]plot|augmented design|power analysis|sample size calculation|spatial (model|correlation))\b/i, side: 'deep', pts: 2, label: 'experimental design' },
73  { re: /\b(simulat(e|es|ed|ion|ions|ing)|monte carlo|stochastic)\b/i, side: 'deep', pts: 2, label: 'simulation' },
74  { re: /\b(change|switch|replace|swap|modify|rewrite|redo)\b.{0,30}\b(the )?(estimator|likelihood|prior|variance (model|structure)|link function|statistical method|statistical model|mixed model|model specification|test statistic)\b/i, side: 'deep', pts: 3, label: 'statistical method change' },
75  { re: /\b(unsafe|send\s*\+\s*sync|interior mutability|trait objects?|proc[- ]macro|macro_rules|ffi)\b/i, side: 'deep', pts: 3, label: 'advanced Rust' },
76  { re: /\b(non-?standard evaluation|tidy ?eval|rlang|Rcpp|RcppArmadillo|S4 (class|method)|R6)\b/i, side: 'deep', pts: 2, label: 'advanced R' },
77  { re: /\b(security|threat model|vulnerabilit\w+|exploit|injection|xss|csrf|cve-\d+)\b/i, side: 'deep', pts: 3, label: 'security' },
78  { re: /\bsecurity (review|audit)\b/i, side: 'deep', pts: 2, label: 'security review' },
79  { re: /\b(time complexity|big-?o|O\([nN\w ^*+log]+\)|invariants?|correctness|formally|amortized)\b/i, side: 'deep', pts: 3, label: 'complexity or correctness' },
80  { re: /\bimplement\b.{0,40}\b(algorithm|solver|sampler|optimi[sz]er|scheduler|parser|compiler|allocator|estimator)\b/i, side: 'deep', pts: 3, label: 'implement an algorithm' },
81  { re: /\b(think (hard|carefully|deeply|step by step)|ultrathink|take your time|be (very )?(careful|thorough|rigorous))\b/i, side: 'deep', pts: 3, label: 'asked to think hard' },
82  { re: /\b(thoroughly|rigorous(ly)?|in depth|in-depth|exhaustive(ly)?|carefully)\b/i, side: 'deep', pts: 1, label: 'asked to be careful' },
83
84  // ---- review: review, second opinion, audit, falsify
85  { re: /\b(second opinion|falsif\w+|try to (break|refute|disprove)|devil'?s advocate|red[- ]team)\b/i, side: 'review', pts: 7, label: 'second opinion or falsify' },
86  { re: /\bcheck (my|our|the|this) (derivation|proof|math|algebra|calculations?|reasoning|argument|results?|statistics|analysis)\b/i, side: 'review', pts: 7, label: 'check a derivation' },
87  { re: /\breview (my|our|the|this|these) (diff|pr|pull request|code|changes|patch|commit|branch|implementation|derivation|proof|analysis|manuscript|draft)\b/i, side: 'review', pts: 4, label: 'review request' },
88  { re: /\b(review|check|verify|validate)\b.{0,40}\b(REML|BLUPs?|mixed model|likelihood|estimator|statistical method|GWAS|kinship|variance components?|simulation|power analysis)\b/i, side: 'review', pts: 4, label: 'review of method code' },
89  { re: /\b(code review|peer review|audit)\b/i, side: 'review', pts: 3, label: 'review or audit', unless: /\bsecurity (review|audit)\b/i },
90  { re: /\b(sanity[- ]check|double[- ]check|cross-?check|verify (that|my|the|this))\b/i, side: 'review', pts: 1, label: 'verify' },
91]
92
93const PATH_RE = /(?:^|[\s"'`(\[])@?((?:[\w.-]+[/\\])*[\w.-]+\.(?:R|r|Rmd|qmd|rs|py|ts|tsx|js|jsx|mjs|json|toml|md|csv|tsv|txt|yml|yaml|c|cc|cpp|h|hpp|go|java|sh|ps1|tex|bib|sql|ipynb|lock|cfg|ini))\b/g
94const MENTION_RE = /(?:^|\s)@([\w./\\-]+)/g
95
96/** Distinct file paths and @mentions named in the text. */
97export function namedFiles(text: string): string[] {
98  const found = new Set<string>()
99  for (const m of text.matchAll(PATH_RE)) if (m[1]) found.add(m[1].toLowerCase())
100  for (const m of text.matchAll(MENTION_RE)) if (m[1]) found.add(m[1].toLowerCase())
101  return [...found]
102}
103
104const PRIOR_CONTEXT =
105  /\b(it|this|that|these|those|above|below|earlier|previous(ly)?|before|same|again)\b|\bthe (file|code|function|error|bug|test|script|plot|result|output)s? we\b|\bas (we )?(discussed|said|mentioned)\b|\bwe (just |already )?(did|discussed|wrote|made|ran|saw)\b|\byou (just )?(said|wrote|made|ran|suggested)\b/i
106
107/** Pronouns and phrases that point back at the conversation (the prompt is not self-contained). */
108export const refsPriorContext = (text: string): boolean => PRIOR_CONTEXT.test(text)
109
110export const wordCount = (text: string): number => text.trim().split(/\s+/).filter(Boolean).length
111
112const clamp = (n: number, lo: number, hi: number): number => Math.round(Math.min(hi, Math.max(lo, n)) * 100) / 100
113
114type Tally = Record<Side, number>
115type Hit = { label: string; side: Side; pts: number }
116
117function runRules(text: string): { tally: Tally; hits: Hit[] } {
118  const tally: Tally = { light: 0, standard: 0, deep: 0, review: 0 }
119  const hits: Hit[] = []
120  for (const rule of RULES) {
121    if (!rule.re.test(text) || (rule.unless && rule.unless.test(text))) continue
122    tally[rule.side] += rule.pts
123    hits.push({ label: rule.label, side: rule.side, pts: rule.pts })
124  }
125  return { tally, hits }
126}
127
128/** Size and shape signals: file count, length, code blocks, question marks. */
129function runShape(text: string, words: number, tally: Tally, hits: Hit[]): void {
130  const add = (side: Side, pts: number, label: string) => {
131    tally[side] += pts
132    hits.push({ label, side, pts })
133  }
134  const files = namedFiles(text).length
135  if (files >= 5) add('deep', 4, `names ${files} files`)
136  else if (files >= 3) add('deep', 2, `names ${files} files`)
137  else if (files >= 1) add('standard', 1, files === 1 ? 'names one file' : 'names two files')
138
139  if (words >= 500) add('deep', 3, `very long prompt (${words} words)`)
140  else if (words >= 250) add('deep', 2, `long prompt (${words} words)`)
141  else if (words >= 120) add('deep', 1, `longish prompt (${words} words)`)
142
143  const blocks = Math.floor((text.match(/```/g) ?? []).length / 2)
144  if (blocks >= 3) add('deep', 1, `${blocks} code blocks`)
145  else if (blocks >= 1) add('standard', 1, 'has a code block')
146
147  const questions = (text.match(/\?/g) ?? []).length
148  if (questions >= 3) add('deep', 1, `${questions} questions`)
149  // A short, plain question with nothing technical attached is a lookup, unless it leans on the conversation.
150  if (questions === 1 && words <= 14 && files === 0 && blocks === 0 && tally.deep === 0 && tally.review === 0 && !refsPriorContext(text))
151    add('light', 2, 'short question')
152}
153
154/** Lowercase tier words for the dialog and the status line. */
155export const TIER_WORD: Record<Tier, string> = { light: 'LIGHT', standard: 'STANDARD', deep: 'DEEP', review: 'REVIEW' }
156
157/**
158 * Predicts the tier of a prompt. Rules in short:
159 *  - review wins when its points are at least 3 and at least the deep points;
160 *  - deep needs 3+ points and a lead of 1 over standard and light;
161 *  - light needs 2+ points and a lead of 1 over standard and deep;
162 *  - anything else is standard.
163 * Confidence is 0.5 + 0.1 x the lead (capped 0.97) for the three decided tiers, so a lead of 1 is 0.6,
164 * 2 is 0.7 and 3 is 0.8.
165 */
166export function predict(text: string): Prediction {
167  const words = wordCount(text)
168  const { tally, hits } = runRules(text)
169  runShape(text, words, tally, hits)
170  const std = Math.min(tally.standard, 5) // many weak standard words must not outvote one clear signal
171
172  const reasonsFor = (side: Side): string[] =>
173    [...new Set(hits.filter(h => h.side === side).sort((a, b) => b.pts - a.pts).map(h => h.label))]
174
175  if (tally.review >= 3 && tally.review >= tally.deep) {
176    return { tier: 'review', score: tally.review, confidence: clamp(0.4 + 0.1 * tally.review, 0.5, 0.97), reasons: reasonsFor('review') }
177  }
178  const deepLead = tally.deep - Math.max(tally.light, std)
179  if (tally.deep >= 3 && deepLead >= 1) {
180    return { tier: 'deep', score: tally.deep, confidence: clamp(0.5 + 0.1 * deepLead, 0.5, 0.97), reasons: reasonsFor('deep') }
181  }
182  const lightLead = tally.light - Math.max(tally.deep, std)
183  if (tally.light >= 2 && lightLead >= 1) {
184    return { tier: 'light', score: tally.light, confidence: clamp(0.5 + 0.1 * lightLead, 0.5, 0.97), reasons: reasonsFor('light') }
185  }
186  const strongest = Math.max(tally.light, tally.deep)
187  const reasons = reasonsFor('standard')
188  return {
189    tier: 'standard',
190    score: std,
191    confidence: clamp(0.55 + 0.08 * std - 0.12 * strongest, 0.3, 0.95),
192    reasons: reasons.length > 0 ? reasons : ['no strong signal'],
193  }
194}
195
196/** True when a prompt matches a rule strong enough to be judged even though it is short. */
197export function isStrong(p: Prediction): boolean {
198  return p.tier !== 'standard' && p.score >= 4
199}
200
hooks/route.ts 22 lines
1// Model routing without a switch: pure helpers. Per request, a mod can name the model (turn.step), so a conversation can
2// run on a cheaper model chosen from its FIRST prompt (nothing is cached yet, so it costs nothing), and a subagent started
3// without a model can get one that fits its task. Never above the model you chose. Idea from laya-router (MIT); no code copied.
4import { recommend } from './fit'
5import type { Costs } from './fit'
6import { RANK } from './gate'
7import type { Alias, Family } from './gate'
8import type { Prediction } from './predict'
9
10/** The ids a request names (the current models; an alias is not accepted by turn.step). */
11export const MODEL_ID: Record<Alias, string> = { haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
12
13/**
14 * The cheaper model a confident prediction says will do, or undefined to keep the chosen one. Only ever down: a task that
15 * needs more than you chose is left to the dialog, which asks.
16 */
17export function cheaperFit(p: Prediction, chosen: Family | undefined, costs: Costs, minConfidence: number): Alias | undefined {
18  if (chosen === undefined || p.tier === 'review' || p.confidence < minConfidence) return undefined
19  const fit = recommend(p.tier, costs)
20  return RANK[fit] < RANK[chosen] ? fit : undefined
21}
22
types/index.d.ts 33 lines
1/** One prediction as /model-picker status lists it. The prompt is cut to a short snippet and lives in session memory only. */
2export type SuggestModelEntry = {
3  snippet: string
4  tier: 'light' | 'standard' | 'deep' | 'review'
5  confidence: number
6  reasons: string[]
7  /** What happened: why nothing was shown, or the person's answer. */
8  outcome: string
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'model-picker': {
14      /** Prompts from the person checked this session. */
15      seen: number
16      /** "Keep ... and don't suggest again this session" was chosen. */
17      isMuted: boolean
18      /** The persisted on/off switch, mirrored here so the band's button redraws when it flips. */
19      isOff: boolean
20      /** Result of the one `codex --version` probe; null = not probed yet. */
21      codexOk: boolean | null
22      /** The session's effort level as the last main-thread request named it; empty = none seen. Read by effort-watch. */
23      effort: string
24      /** Short effort advice for the moddeck's model row (`medium would do`, `try high`); empty = the effort fits. */
25      effortAdvice: string
26      /** The model this conversation was routed to at its first prompt (`Haiku`); '' when not routed. */
27      routedTo: string
28      /** The last predictions, newest last (at most 5). */
29      recent: SuggestModelEntry[]
30    }
31  }
32}
33