Picks the model and effort for each prompt (you confirm), and upgrades substantial prompts through a short question panel before they run

A Claude Code mod that works on every prompt you send, in two parts you can switch on and off separately:
The two live in one mod because the upgrader must run first and hand the chooser the model it settled on.
The mod's plugin name is prompt-pilot.
CLAUDE.md as context. If the prompt is clear, it runs as typed, with « ✓ Your prompt is clear »./chooser reset makes it ask again.Put them at the start or the end of a prompt; they are removed before it is sent.
| Tag | Effect |
|---|---|
!! | no upgrader for this prompt |
?? | the upgrader even for a short prompt |
!opus !sonnet !haiku | force the model |
!medium !high !extra !max | force the effort |
claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5 (change them in the options). The upgrader's reads and rewrites run on your own account, like any other Claude Code request.pbcopy. Elsewhere that one fallback is skipped.In a terminal Claude Code session (the desktop app's Code tab does not offer this command), type:
/plugin install prompt-pilot --marketplace Fox1304/FT_Claude_PromptUpgrader
Answer y to add the marketplace, then pick the user scope so it loads in every project. The mod is active at once in that session, and in every session started afterwards, including the desktop app.
Or from a shell, with the same result:
claude plugin marketplace add Fox1304/FT_Claude_PromptUpgrader
claude plugin install prompt-pilot@claude-prompt-upgrader --scope user
To remove it: /plugin uninstall prompt-pilot, or claude plugin uninstall prompt-pilot@claude-prompt-upgrader.
git clone https://github.com/Fox1304/FT_Claude_PromptUpgrader.git
claude --plugin-dir ./FT_Claude_PromptUpgrader
To load it in every session without installing it, add the clone's absolute path to env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.
Type prompts as usual. Then:
/upgrader on, /upgrader off: switch the prompt upgrader on or off in every session./chooser on, /chooser off: the same for the model chooser; /chooser reset makes it ask for confirmation again.Set them in /config (or under pluginConfigs in ~/.claude/settings.json):
| Option | Default | What it does |
|---|---|---|
opusModel | claude-opus-5-5 | the model id used for Opus picks |
sonnetModel | claude-sonnet-5-5 | the model id used for Sonnet picks and rewrites |
haikuModel | claude-haiku-5-5 | the model id used for Haiku picks and for reading prompts |
cacheMinutes | 60 | how long a model's prompt cache counts as warm after its last answer |
usageWarnPercent | 80 | plan usage share that triggers a warning |
The mod keeps its switches and what it learned (kept recommendations, the average cost of a turn per model and effort) in Claude Code's own plugin store (~/.claude/plugins/store/). It sends nothing anywhere except to Claude, through your own Claude Code session.
claude plugin validate .
claude plugin test .
claude plugin test runs the tests/*.test.ts(x) files against Claude Code's own engine (it needs mods enabled). Claude Code lays the API typings in .claude-plugin/types/ the first time it loads the mod; tsc -p . type-checks it after that.
| Path | What it holds |
|---|---|
hooks/register.tsx | the hooks: prompt interception, the upgrader pane, the chooser's confirm and band |
hooks/decide.ts | pure logic: rules, tags, switch costs, trust and turn-cost learning |
hooks/prompts.ts | the instructions the small models read |
types/index.d.ts | the shapes of the mod's state |
tests/ | engine tests, with world.ts standing in for the store, the clock and the models |
MIT, see LICENSE.
hooks/register.tsx 1016 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, RenderSurface } from 'claude-code'
3
4import type { Effort, ModelName, Pick, SwitchCost, TrustEntry, TurnCost, Upgrade, UpgradeQuestion, UsageWindow } from '../types'
5import {
6 EFFORTS,
7 EFFORT_LABEL,
8 TRUST_AFTER,
9 MODELS,
10 MODEL_LABEL,
11 answersText,
12 costKey,
13 costText,
14 estimateTurn,
15 isContinuation,
16 isTrusted,
17 keepIfTooLong,
18 keepStronger,
19 kindKey,
20 kindOf,
21 label,
22 meter,
23 neighbours,
24 parseClassify,
25 parsePickWords,
26 parseRead,
27 parseRewrite,
28 parseTags,
29 recordAnswer,
30 recordTurnCost,
31 rulePick,
32 samePick,
33 switchCost,
34 tierOf,
35 wordCount,
36} from './decide'
37import type { Read } from './decide'
38import { CLASSIFY_SYSTEM, READ_SYSTEM, RECAP_PROMPT, REWRITE_SYSTEM } from './prompts'
39
40const PLUGIN = 'prompt-pilot'
41const PANE = 'prompt-upgrader'
42const PANE_TITLE = '✨ Prompt upgrader'
43const MAX_QUESTIONS = 3
44const READ_TIMEOUT_MS = 10_000
45const CLASSIFY_TIMEOUT_MS = 6_000
46const WRITE_TIMEOUT_MS = 60_000
47const HANDOFF_MS = 15 * 60_000
48const RULES_CHARS = 2_500
49
50const active = atom({ plugin: 'prompt-pilot', key: 'active' } as const, null)
51const upgrade = atom({ plugin: 'prompt-pilot', key: 'upgrade' } as const, null)
52const busy = atom({ plugin: 'prompt-pilot', key: 'busy' } as const, '')
53const usage = atom({ plugin: 'prompt-pilot', key: 'usage' } as const, [])
54const cache = atom({ plugin: 'prompt-pilot', key: 'cache' } as const, {})
55const handoff = atom({ plugin: 'prompt-pilot', key: 'handoff' } as const, null)
56const turnStart = atom({ plugin: 'prompt-pilot', key: 'turnStart' } as const, null)
57const chooserOn = atom({ plugin: 'prompt-pilot', key: 'chooserOn' } as const, true)
58const upgraderOn = atom({ plugin: 'prompt-pilot', key: 'upgraderOn' } as const, true)
59
60/** Model ids, cache lifetime and warning level, from the mod's settings. */
61const config = {
62 ids: { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' } as Record<ModelName, string>,
63 cacheMs: 60 * 60_000,
64 warnAt: 80,
65}
66
67const MODEL_COLOR: Record<ModelName, string> = { opus: 'claude', sonnet: 'suggestion', haiku: 'success' }
68
69/** Prompts that come from elsewhere than the person (notifications, peers, other plugins) run on what runs now. */
70const NOT_THE_PERSON = new Set<PromptOrigin['kind']>([
71 'task-notification',
72 'scheduled-trigger',
73 'peer',
74 'peer-send-message',
75 'projects-relay',
76 'channel',
77 'coordinator',
78 'observer',
79 'observer-activity',
80 'auto-continuation',
81 'plugin',
82 'slack-ping',
83])
84
85const cap = (text: string) => (text ? text[0]?.toUpperCase() + text.slice(1) : text)
86const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
87
88// ── Reading the session ────────────────────────────────────────────────────
89
90type Conversation = { lastUser: string; lastAssistant: string; hasHistory: boolean; contextTokens: number }
91
92const conversation = async ($: EngineInterface): Promise<Conversation> => {
93 const listed = await $.session.messages()
94 const messages = Array.isArray(listed) ? listed : []
95 const said = messages.filter(one => one.text.trim())
96 const lastAssistant = [...said].reverse().find(one => one.role === 'assistant')?.text ?? ''
97 const lastUser = [...said].reverse().find(one => one.role === 'user')?.text ?? ''
98 const used = await $.session.usage()
99
100 return { lastUser, lastAssistant, hasHistory: messages.some(one => one.role === 'assistant'), contextTokens: used.context.tokens ?? 0 }
101}
102
103/** What runs now: the last pick, else the session's own model (at High). */
104const currentPick = async ($: EngineInterface): Promise<Pick> => {
105 const pick = await read($, active)
106 if (pick) return pick
107
108 return { model: tierOf(await $.session.model()) ?? 'opus', effort: 'high', reason: 'session model', by: 'kept' }
109}
110
111const projectRules = async ($: EngineInterface) => {
112 const root = await $.session.root()
113 for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
114 try {
115 const text = await $.fs.read(`${root}/${name}`)
116 if (text.trim()) return clip(text.trim(), RULES_CHARS)
117 } catch {
118 // no rules file there
119 }
120 }
121
122 return ''
123}
124
125const setActive = async ($: EngineInterface, pick: Pick) => {
126 await update($, active, () => pick)
127 $.ui.status(`◆ ${label(pick)} · ${pick.reason}`)
128}
129
130const setBusy = async ($: EngineInterface, text: string) => {
131 await update($, busy, () => text)
132}
133
134// ── The models' reads ──────────────────────────────────────────────────────
135
136const contextBlock = (conv: Conversation) =>
137 [conv.lastUser ? `Previous request: ${clip(conv.lastUser, 500)}` : '', conv.lastAssistant ? `Claude's last answer: ${clip(conv.lastAssistant, 900)}` : '']
138 .filter(Boolean)
139 .join('\n')
140
141/** Haiku reads a substantial prompt: clear or not, the questions worth asking, and the pick. */
142const readPrompt = async ($: EngineInterface, text: string, conv: Conversation, fallback: Pick): Promise<Read | undefined> => {
143 const rules = await projectRules($)
144 const reply = await $.model.complete({
145 model: 'haiku',
146 system: READ_SYSTEM(MAX_QUESTIONS),
147 prompt: [
148 { text: rules ? `Project rules (start of CLAUDE.md):\n${rules}` : 'This project has no rules file.', cache: true },
149 { text: `${contextBlock(conv)}\n\nPrompt to triage:\n<<<\n${clip(text, 6000)}\n>>>` },
150 ],
151 maxTokens: 900,
152 effort: 'low',
153 timeoutMs: READ_TIMEOUT_MS,
154 })
155 if (!reply.isAnswered) {
156 $.ui.log(`prompt-pilot: the read failed (${reply.reason})`, { to: 'debug' })
157
158 return undefined
159 }
160
161 return parseRead(reply.text, fallback, MAX_QUESTIONS)
162}
163
164/** The chooser's pick: the rules when they are sure, else a quick Haiku read. */
165const choosePick = async ($: EngineInterface, text: string, hasImages: boolean, conv: Conversation): Promise<Pick> => {
166 const ruled = rulePick(text, hasImages)
167 if (ruled.isConfident || wordCount(text) < 5) return ruled.pick
168 await setBusy($, 'Choosing a model…')
169 try {
170 const reply = await $.model.complete({
171 model: 'haiku',
172 system: CLASSIFY_SYSTEM,
173 prompt: `${contextBlock(conv)}\n\nPrompt:\n<<<\n${clip(text, 4000)}\n>>>`,
174 maxTokens: 120,
175 effort: 'low',
176 timeoutMs: CLASSIFY_TIMEOUT_MS,
177 })
178
179 return (reply.isAnswered ? parseClassify(reply.text, ruled.pick) : undefined) ?? ruled.pick
180 } finally {
181 await setBusy($, '')
182 }
183}
184
185// ── Asking the person ──────────────────────────────────────────────────────
186
187/** One question in the engine's own dialog; the label chosen, the words typed under Other, or undefined when dismissed. */
188const askOne = async ($: EngineInterface, question: string, header: string, options: string[]) => {
189 try {
190 const answer = await $.ui.ask(question, { options, header })
191
192 return answer.trim() || undefined
193 } catch (error) {
194 $.ui.log(`prompt-pilot: the confirm dialog closed without an answer (${String(error)})`, { to: 'debug' })
195
196 return undefined
197 }
198}
199
200/** `agreed`: the plain confirm's answer kept the recommendation (true), chose otherwise (false), or said nothing (undefined). */
201type Decision = { kind: 'run'; pick: Pick; agreed?: boolean } | { kind: 'new' }
202
203/** A choice's label with what one turn on it usually costs: "Opus · High (recommended) · ≈3%". */
204const priced = (text: string, pick: { model: ModelName; effort: Effort }, costs: Record<string, TurnCost>) => {
205 const cost = costText(estimateTurn(costs, pick))
206
207 return cost ? `${text} · ${cost}` : text
208}
209
210/** The confirm step: a plain confirm, or, when a model switch would re-read the conversation, three choices. */
211const confirmPick = async (
212 $: EngineInterface,
213 pick: Pick,
214 current: Pick | null,
215 cost: SwitchCost,
216 isClear: boolean,
217 costs: Record<string, TurnCost>,
218): Promise<Decision> => {
219 const typed = (answer: string): Pick => {
220 const words = parsePickWords(answer, pick)
221 if (!words) $.ui.toast(`Didn't catch “${clip(answer, 30)}”: running on ${label(pick)}.`)
222
223 return words ? { ...words, reason: pick.reason, by: 'you' } : pick
224 }
225 if (cost.kind === 'costly' && current) {
226 const stay: Pick = { model: current.model, effort: pick.effort, reason: pick.reason, by: 'you' }
227 const from = MODEL_LABEL[current.model]
228 const question = `${label(pick)} fits this better (${pick.reason}), but switching from ${from} re-reads ${cost.tokens} tokens without the cache. Switch?`
229 const options = [priced(`Switch to ${label(pick)}`, pick, costs), priced(`Stay on ${label(stay)} (cheapest)`, stay, costs), 'New conversation (with recap)']
230 const answer = await askOne($, question, 'Switch model', options)
231 if (answer === undefined || answer === options[0]) return { kind: 'run', pick: answer ? { ...pick, by: 'you' } : pick }
232 if (answer === options[1]) return { kind: 'run', pick: stay }
233 if (answer === options[2]) return { kind: 'new' }
234
235 return { kind: 'run', pick: typed(answer) }
236 }
237 const { up, down } = neighbours(pick)
238 const choices: { pick: { model: ModelName; effort: Effort }; text: string }[] = [{ pick, text: priced(`${label(pick)} (recommended)`, pick, costs) }]
239 if (up) choices.push({ pick: up, text: priced(`${label(up)} (stronger)`, up, costs) })
240 if (down) choices.push({ pick: down, text: priced(`${label(down)} (lighter)`, down, costs) })
241 const notes = [isClear ? '✓ Your prompt is clear.' : '', cost.kind === 'cheap' ? `${cost.short}.` : ''].filter(Boolean).join(' ')
242 const answer = await askOne($, `${notes ? `${notes} ` : ''}${cap(pick.reason)}: run it on ${label(pick)}?`, 'Model', choices.map(one => one.text))
243 if (answer === undefined) return { kind: 'run', pick }
244 const chosen = choices.find(one => one.text === answer || label(one.pick) === answer)
245 if (chosen) {
246 const isKept = chosen.pick === pick
247
248 return {
249 kind: 'run',
250 pick: { model: chosen.pick.model, effort: chosen.pick.effort, reason: pick.reason, by: isKept ? pick.by : 'you', ...(pick.kind ? { kind: pick.kind } : {}) },
251 agreed: isKept,
252 }
253 }
254 const words = typed(answer)
255
256 return { kind: 'run', pick: words, agreed: samePick(words, pick) }
257}
258
259// ── Learning from the person ───────────────────────────────────────────────
260
261const loadTrust = async ($: EngineInterface) => ((await $.store.get('trust')) ?? {}) as Record<string, TrustEntry>
262const loadCosts = async ($: EngineInterface) => ((await $.store.get('turnCost')) ?? {}) as Record<string, TurnCost>
263
264/** Records whether the person kept the recommendation for this kind; says so the moment the chooser stops asking. */
265const learnAnswer = async ($: EngineInterface, pick: Pick, kept: boolean) => {
266 const kind = kindKey(pick)
267 const now = await $.clock.now()
268 const trust = recordAnswer(await loadTrust($), kind, kept, now)
269 await $.store.set('trust', trust)
270 if (kept && trust[kind]?.streak === TRUST_AFTER)
271 $.ui.toast(`You kept my pick ${TRUST_AFTER} times in a row for “${pick.reason}”: I'll stop asking for that kind of task (/chooser reset to undo).`)
272}
273
274/** A pick forced with a tag on a kind of task the chooser no longer asks about: the question comes back. */
275const forgetTrust = async ($: EngineInterface, kind: string) => {
276 const trust = await loadTrust($)
277 if (!trust[kind]) return
278 await $.store.set('trust', recordAnswer(trust, kind, false, await $.clock.now()))
279}
280
281// ── Clipboard, box, new conversation ──────────────────────────────────────
282
283const copyText = async ($: EngineInterface, text: string, surface?: RenderSurface) => {
284 try {
285 const copied = await $.ui.copy({ text, surface })
286 if (copied.isCopied) return true
287 } catch {
288 // no surface clipboard: the machine's below
289 }
290 try {
291 return (await $.process.run(['pbcopy'], { stdin: text, timeoutMs: 5000 })).exitCode === 0
292 } catch {
293 return false
294 }
295}
296
297/** Puts text in the prompt box, else on the clipboard: where it went. */
298const fillBox = async ($: EngineInterface, text: string, surface?: RenderSurface): Promise<'box' | 'clipboard' | 'nowhere'> => {
299 try {
300 if ((await $.prompt.fill({ text })).isFilled) return 'box'
301 } catch {
302 // no box: the clipboard below
303 }
304
305 return (await copyText($, text, surface)) ? 'clipboard' : 'nowhere'
306}
307
308/** A short recap of this conversation plus the prompt, on the clipboard for a fresh session. */
309const handOffToNewConversation = async ($: EngineInterface, text: string, surface?: RenderSurface) => {
310 await setBusy($, 'Writing a recap for the new conversation…')
311 try {
312 const fork = await $.model.fork({ prompt: RECAP_PROMPT })
313 const recap = fork.isAnswered ? fork.text.trim() : ''
314 const whole = recap ? `Context from an earlier session:\n${recap}\n\n---\n\n${text}` : text
315 const copied = await copyText($, whole, surface)
316
317 return copied
318 ? `Copied your prompt${recap ? ' with a short recap' : ''}: open a new conversation and paste it.`
319 : "Couldn't reach the clipboard: your prompt was not sent."
320 } finally {
321 await setBusy($, '')
322 }
323}
324
325// ── The upgrader's panel ───────────────────────────────────────────────────
326
327const openUpgrade = async ($: EngineInterface, original: string, triage: Read, pick0: Pick, isForced: boolean, chooser: boolean, conv: Conversation) => {
328 const now = await $.clock.now()
329 const from = conv.hasHistory ? await currentPick($) : null
330 const clock = await read($, cache)
331 const first = switchCost(pick0, from, conv.contextTokens, clock, now, config.cacheMs)
332 const pick = keepIfTooLong(isForced ? pick0 : keepStronger(pick0, from, first), from, conv.contextTokens)
333 const state: Upgrade = {
334 id: `u${now}`,
335 stage: 'questions',
336 original,
337 intent: triage.intent,
338 questions: triage.questions,
339 answers: Object.fromEntries(triage.questions.map(question => [question.id, { choice: question.recommended }])),
340 extra: '',
341 improved: '',
342 previous: [],
343 pick,
344 pickTouched: isForced,
345 chipsOpen: false,
346 chooser,
347 from: from && { model: from.model, effort: from.effort },
348 contextTokens: conv.contextTokens,
349 switch: switchCost(pick, from, conv.contextTokens, clock, now, config.cacheMs),
350 error: '',
351 notice: '',
352 }
353 await update($, upgrade, () => state)
354 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true, closeOnEscape: true })
355 if (opened.isPlaced) return true
356 await update($, upgrade, () => null)
357 await $.ui.close({ id: PANE })
358
359 return false
360}
361
362const changeUpgrade = async ($: EngineInterface, change: (up: Upgrade) => Upgrade) => {
363 await update($, upgrade, up => (up ? change(up) : up))
364}
365
366const choose = async ($: EngineInterface, id: string, choice: number) => {
367 await changeUpgrade($, up => ({ ...up, answers: { ...up.answers, [id]: { choice } } }))
368}
369
370const typeOwn = async ($: EngineInterface, id: string, text: string) => {
371 await changeUpgrade($, up => ({ ...up, answers: { ...up.answers, [id]: { ...up.answers[id], text } } }))
372}
373
374const typeExtra = async ($: EngineInterface, text: string) => {
375 await changeUpgrade($, up => ({ ...up, extra: text }))
376}
377
378const repick = async ($: EngineInterface, change: { model?: ModelName; effort?: Effort }) => {
379 const now = await $.clock.now()
380 const clock = await read($, cache)
381 await changeUpgrade($, up => {
382 const pick: Pick = { ...up.pick, ...change, by: 'you' }
383
384 return { ...up, pick, pickTouched: true, switch: switchCost(pick, up.from, up.contextTokens, clock, now, config.cacheMs) }
385 })
386}
387
388const toggleChips = async ($: EngineInterface) => {
389 await changeUpgrade($, up => ({ ...up, chipsOpen: !up.chipsOpen }))
390}
391
392/** Sonnet writes the improved prompt from the answers (or applies a requested change to it). */
393const writeUpgrade = async ($: EngineInterface, tweak?: string) => {
394 const start = await read($, upgrade)
395 if (!start || start.stage === 'writing') return
396 await update($, upgrade, () => ({ ...start, stage: 'writing' as const, error: '', notice: '' }))
397 const rules = await projectRules($)
398 const change = tweak?.trim()
399 const reply = await $.model.complete({
400 model: 'sonnet',
401 system: REWRITE_SYSTEM(start.pick.model),
402 prompt: [
403 { text: rules ? `Project rules (start of CLAUDE.md):\n${rules}` : 'This project has no rules file.', cache: true },
404 {
405 text: [
406 `Original prompt:\n<<<\n${start.original}\n>>>`,
407 `The developer's answers:\n${answersText(start.questions, start.answers, start.extra)}`,
408 change ? `Your current rewrite:\n<<<\n${start.improved}\n>>>\nApply this change to it: ${change}` : '',
409 ]
410 .filter(Boolean)
411 .join('\n\n'),
412 },
413 ],
414 maxTokens: 3000,
415 effort: 'low',
416 timeoutMs: WRITE_TIMEOUT_MS,
417 })
418 const parsed = reply.isAnswered ? parseRewrite(reply.text, start.pick) : undefined
419 const now = await $.clock.now()
420 const clock = await read($, cache)
421 await update($, upgrade, up => {
422 if (!up || up.id !== start.id) return up
423 if (!parsed) {
424 const why = reply.isAnswered
425 ? 'the answer came back in an unexpected shape'
426 : reply.reason === 'api-error'
427 ? `the API answered ${reply.status ?? 'with an error'}`
428 : reply.reason === 'aborted'
429 ? 'it took too long'
430 : 'the model sent back nothing'
431 return { ...up, stage: 'failed' as const, error: `Couldn't write the improved prompt: ${why}.` }
432 }
433 const fresh = up.pickTouched
434 ? up.pick
435 : keepIfTooLong(keepStronger(parsed.pick, up.from, switchCost(parsed.pick, up.from, up.contextTokens, clock, now, config.cacheMs)), up.from, up.contextTokens)
436
437 return {
438 ...up,
439 stage: 'ready' as const,
440 improved: parsed.prompt,
441 previous: change && up.improved ? [...up.previous, up.improved].slice(-5) : up.previous,
442 pick: fresh,
443 switch: switchCost(fresh, up.from, up.contextTokens, clock, now, config.cacheMs),
444 }
445 })
446}
447
448const undoTweak = async ($: EngineInterface) => {
449 await changeUpgrade($, up => {
450 const last = up.previous[up.previous.length - 1]
451
452 return last === undefined ? up : { ...up, improved: last, previous: up.previous.slice(0, -1), notice: '' }
453 })
454}
455
456const closePane = async ($: EngineInterface) => {
457 await update($, upgrade, () => null)
458 await $.ui.close({ id: PANE })
459}
460
461/** Runs the improved prompt (or the original) here, on the panel's pick. */
462const launch = async ($: EngineInterface, which: 'improved' | 'original') => {
463 const up = await read($, upgrade)
464 if (!up) return
465 const text = which === 'improved' && up.improved ? up.improved : up.original
466 if (up.chooser) await setActive($, up.pick)
467 await closePane($)
468 await $.prompt.submit({ text, asUser: true })
469}
470
471const copyImproved = async ($: EngineInterface, surface: RenderSurface) => {
472 const up = await read($, upgrade)
473 if (!up?.improved) return
474 const copied = await copyText($, up.improved, surface)
475 await changeUpgrade($, one => ({ ...one, notice: copied ? '✓ Copied: paste it into any session.' : "Couldn't reach the clipboard." }))
476}
477
478const editImproved = async ($: EngineInterface, surface: RenderSurface) => {
479 const up = await read($, upgrade)
480 if (!up?.improved) return
481 const now = await $.clock.now()
482 if (up.chooser) await update($, handoff, () => ({ pick: up.pick, until: now + HANDOFF_MS }))
483 await closePane($)
484 const where = await fillBox($, up.improved, surface)
485 $.ui.toast(
486 where === 'box'
487 ? 'The improved prompt is in the box: edit it, then press Enter.'
488 : where === 'clipboard'
489 ? 'Copied the improved prompt: paste it in the box, edit it, then press Enter.'
490 : "Couldn't reach the box or the clipboard.",
491 )
492}
493
494const newConversation = async ($: EngineInterface, surface: RenderSurface) => {
495 const up = await read($, upgrade)
496 if (!up) return
497 const said = await handOffToNewConversation($, up.improved || up.original, surface)
498 await changeUpgrade($, one => ({ ...one, notice: said }))
499}
500
501/** The person closed the panel without running anything: their prompt goes back where they can use it. */
502const cancelUpgrade = async ($: EngineInterface) => {
503 const up = await read($, upgrade)
504 if (!up) return
505 await update($, upgrade, () => null)
506 const where = await fillBox($, up.original)
507 $.ui.toast(
508 where === 'box'
509 ? 'Upgrade cancelled: your prompt is back in the box.'
510 : where === 'clipboard'
511 ? 'Upgrade cancelled: your prompt is on the clipboard.'
512 : 'Upgrade cancelled.',
513 )
514}
515
516const cancelFromPane = async ($: EngineInterface) => {
517 await cancelUpgrade($)
518 await $.ui.close({ id: PANE })
519}
520
521// ── Plan usage ─────────────────────────────────────────────────────────────
522
523const WINDOW_NAME: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
524
525const resetsIn = (resetsAt: string | undefined, now: number) => {
526 if (!resetsAt) return ''
527 const ms = Date.parse(resetsAt) - now
528 if (!Number.isFinite(ms) || ms <= 0) return ''
529 const hours = Math.floor(ms / 3_600_000)
530 const minutes = Math.round((ms % 3_600_000) / 60_000)
531 if (hours >= 24) return ` · resets in ${Math.round(hours / 24)} d`
532
533 return ` · resets in ${hours ? `${hours} h ` : ''}${minutes} min`
534}
535
536const noteUsage = async ($: EngineInterface, limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]) => {
537 const windows: UsageWindow[] = limits
538 .filter(one => WINDOW_NAME[one.kind])
539 .map(one => ({ kind: one.kind, percent: one.percentUsed, ...(one.resetsAt ? { resetsAt: one.resetsAt } : {}) }))
540 await update($, usage, () => windows)
541 const now = await $.clock.now()
542 for (const window of windows) {
543 const level = window.percent >= 95 ? 95 : window.percent >= config.warnAt ? config.warnAt : 0
544 if (!level) continue
545 const key = `${window.kind}:${window.resetsAt ?? ''}:${level}`
546 const warned = ((await $.store.get('warned')) ?? {}) as Record<string, number>
547 if (warned[key]) continue
548 const kept = Object.fromEntries(Object.entries(warned).filter(([, at]) => now - at < 8 * 24 * 3_600_000))
549 await $.store.set('warned', { ...kept, [key]: now })
550 $.ui.toast(`⚠ ${WINDOW_NAME[window.kind]} usage at ${Math.round(window.percent)}%${resetsIn(window.resetsAt, now)}`)
551 }
552}
553
554// ── Switches ───────────────────────────────────────────────────────────────
555
556const setToggle = async ($: EngineInterface, which: 'chooser' | 'upgrader', value: boolean) => {
557 await $.store.set(which === 'chooser' ? 'chooserOn' : 'upgraderOn', value)
558 if (which === 'chooser') {
559 await update($, chooserOn, () => value)
560 if (!value) {
561 await update($, active, () => null)
562 $.ui.status(undefined)
563 }
564 } else await update($, upgraderOn, () => value)
565}
566
567const toggleCommand = async ($: EngineInterface, which: 'chooser' | 'upgrader', args: string) => {
568 const word = args.trim().toLowerCase()
569 if (which === 'chooser' && word === 'reset') {
570 await $.store.set('trust', {})
571
572 return 'Model chooser: it will ask again for every kind of task.'
573 }
574 const now = which === 'chooser' ? await read($, chooserOn) : await read($, upgraderOn)
575 const next = word === 'on' ? true : word === 'off' ? false : word === '' ? !now : now
576 if (next !== now) await setToggle($, which, next)
577 const name = which === 'chooser' ? 'Model chooser' : 'Prompt upgrader'
578 const pick = await read($, active)
579 const extra = which === 'chooser' && next && pick ? ` Now running on ${label(pick)} (${pick.reason}).` : ''
580
581 return `${name}: ${next ? 'on' : 'off'} in every session.${extra}`
582}
583
584// ── The hooks ──────────────────────────────────────────────────────────────
585
586export const register: Register = (on, options) => {
587 for (const model of MODELS) {
588 const id = options[`${model}Model`]
589 if (typeof id === 'string' && id.trim()) config.ids[model] = id.trim()
590 }
591 if (typeof options.cacheMinutes === 'number' && options.cacheMinutes > 0) config.cacheMs = options.cacheMinutes * 60_000
592 if (typeof options.usageWarnPercent === 'number' && options.usageWarnPercent > 0) config.warnAt = options.usageWarnPercent
593
594 on('session.start', async ($, e, next) => {
595 await $.command.register({ name: 'chooser', description: 'Model chooser: picks the model and effort for each prompt (on, off; reset makes it ask again)', argumentHint: '[on|off|reset]', immediate: true })
596 await $.command.register({ name: 'upgrader', description: 'Prompt upgrader: improves substantial prompts with a short question panel (on, off)', argumentHint: '[on|off]', immediate: true })
597 const [chooser, upgrader] = [await $.store.get('chooserOn'), await $.store.get('upgraderOn')]
598 await update($, chooserOn, () => chooser !== false)
599 await update($, upgraderOn, () => upgrader !== false)
600
601 return next(e)
602 })
603
604 on('command.run', { command: 'chooser' }, async ($, e) => ({ text: await toggleCommand($, 'chooser', e.args) }))
605 on('command.run', { command: 'upgrader' }, async ($, e) => ({ text: await toggleCommand($, 'upgrader', e.args) }))
606
607 on('prompt.submit', async ($, e, next) => {
608 if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) return next(e)
609 if (NOT_THE_PERSON.has(e.origin.kind) || e.turnId !== undefined) return next(e)
610 if (e.text.trimStart().startsWith('/') || /^\s*<[a-z-]+>/.test(e.text)) return next(e)
611 const tags = parseTags(e.text)
612 const pass = () => next(tags.text === e.text ? e : { ...e, text: tags.text })
613 const chooser = await read($, chooserOn)
614 const upgrader = await read($, upgraderOn)
615 if (!chooser && !upgrader) return pass()
616 const text = tags.text
617 const conv = await conversation($)
618 const kind = kindOf(text, conv.lastAssistant)
619 const hasAttachments = (e.attachments?.length ?? 0) > 0
620 const now = await $.clock.now()
621 const current = conv.hasHistory || (await read($, active)) ? await currentPick($) : null
622
623 // An improved prompt the person edited in the box runs on the panel's pick, unasked.
624 const handed = await read($, handoff)
625 if (handed) await update($, handoff, () => null)
626 if (handed && handed.until > now) {
627 if (chooser) await setActive($, handed.pick)
628
629 return pass()
630 }
631
632 const forced: Pick | undefined =
633 tags.model || tags.effort
634 ? { model: tags.model ?? current?.model ?? 'opus', effort: tags.effort ?? (tags.model === 'haiku' ? 'medium' : 'high'), reason: 'your choice', by: 'you' }
635 : undefined
636
637 let triage: Read | undefined
638 if (upgrader && !tags.skipUpgrade && !hasAttachments && (tags.forceUpgrade || kind === 'substantial')) {
639 await setBusy($, 'Reading your prompt…')
640 try {
641 triage = await readPrompt($, text, conv, forced ?? rulePick(text, hasAttachments).pick)
642 } finally {
643 await setBusy($, '')
644 }
645 if (triage && (!triage.isClear || tags.forceUpgrade) && triage.questions.length > 0) {
646 const opened = await openUpgrade($, text, triage, forced ?? triage.pick, !!forced, chooser, conv)
647 if (opened) return { drop: '✨ Prompt upgrader: answer in its panel, then Launch (or Send as is).' }
648 }
649 }
650 if (!chooser) return pass()
651
652 // Go-aheads and status replies keep what runs (the upgrader keeps its own reading of `kind`).
653 const asked = kind === 'ack' || isContinuation(text, conv.lastAssistant) ? 'ack' : kind
654 let pick: Pick
655 if (forced) pick = forced
656 else if (asked === 'ack' && current) pick = { ...current, by: 'kept' }
657 else pick = triage?.pick ?? (await choosePick($, text, hasAttachments, conv))
658 if (forced) {
659 const usual = rulePick(text, hasAttachments).pick
660 if (!samePick(forced, usual)) await forgetTrust($, kindKey(usual))
661 }
662 const clock = await read($, cache)
663 const kept = keepIfTooLong(
664 forced ? pick : keepStronger(pick, current, switchCost(pick, current, conv.contextTokens, clock, now, config.cacheMs)),
665 current,
666 conv.contextTokens,
667 )
668 const cost = switchCost(kept, current, conv.contextTokens, clock, now, config.cacheMs)
669 const mustAsk = !forced && asked !== 'ack' && (asked === 'substantial' || !samePick(kept, current))
670 let final = kept
671 if (mustAsk && cost.kind !== 'costly' && isTrusted(await loadTrust($), kindKey(kept))) final = { ...kept, by: 'trusted' }
672 else if (mustAsk) {
673 const decision = await confirmPick($, kept, current, cost, triage?.isClear === true, await loadCosts($))
674 if (decision.kind === 'new') return { drop: await handOffToNewConversation($, text) }
675 final = decision.pick
676 if (decision.agreed !== undefined) await learnAnswer($, kept, decision.agreed)
677 }
678 await setActive($, final)
679
680 return pass()
681 }).catch(($, e, next) => next(e))
682
683 on('turn.step', async function* ($, e, next) {
684 if (e.agentId !== undefined) return yield* next(e)
685 const pick = await read($, active)
686 if (!pick || !(await read($, chooserOn))) return yield* next(e)
687 const model = tierOf(e.model) === pick.model ? e.model : config.ids[pick.model]
688
689 return yield* next({ ...e, model, effort: pick.effort })
690 })
691
692 on('turn.start', async ($, e, next) => {
693 const started = await next(e)
694 const percent = (await $.session.usage()).rateLimits.find(one => one.kind === 'five_hour')?.percentUsed
695 const pick = (await read($, active)) ?? (await currentPick($))
696 await update($, turnStart, () => (percent === undefined ? null : { key: costKey(pick), percent }))
697
698 return started
699 })
700
701 on('turn.complete', async ($, e, next) => {
702 const ended = await next(e)
703 if (e.agentId === undefined && e.reason === 'answer') {
704 const pick = await read($, active)
705 const tier = pick?.model ?? tierOf(await $.session.model())
706 const now = await $.clock.now()
707 if (tier) await update($, cache, clock => ({ ...clock, [tier]: now }))
708 }
709 if (e.agentId === undefined) {
710 const mark = await read($, turnStart)
711 await update($, turnStart, () => null)
712 const percent = (await $.session.usage()).rateLimits.find(one => one.kind === 'five_hour')?.percentUsed
713 if (mark && percent !== undefined && e.reason === 'answer') await $.store.set('turnCost', recordTurnCost(await loadCosts($), mark.key, percent - mark.percent))
714 }
715
716 return ended
717 })
718
719 on('session.measure', async ($, e, next) => {
720 if (e.changed.includes('rateLimits')) await noteUsage($, e.rateLimits)
721
722 return next(e)
723 })
724
725 on('session.end', async ($, e, next) => {
726 if (e.reason === 'clear') {
727 await update($, active, () => null)
728 await update($, cache, () => ({}))
729 $.ui.status(undefined)
730 }
731
732 return next(e)
733 })
734
735 on('ui.close', { id: 'prompt-upgrader' }, async ($, e, next) => {
736 const closed = await next(e)
737 if (e.origin.kind === 'person') await cancelUpgrade($)
738
739 return closed
740 })
741
742 // ── The band above the prompt ────────────────────────────────────────────
743
744 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
745 if (e.props.hasSurvey) return next(e)
746 const [chooser, upgrader] = [await read($, chooserOn), await read($, upgraderOn)]
747 if (!chooser && !upgrader) return next(e)
748 const [pick, doing, windows, up] = [await read($, active), await read($, busy), await read($, usage), await read($, upgrade)]
749 const { Box, Text } = $.ui.resolve(e)
750 const shown = windows.filter(one => one.percent >= 1)
751 const usageText = shown.map(one => `${one.kind === 'five_hour' ? '5h' : 'week'} ${Math.round(one.percent)}%`).join(' · ')
752 const usageColor = shown.some(one => one.percent >= 95) ? 'error' : shown.some(one => one.percent >= config.warnAt) ? 'warning' : undefined
753
754 return (
755 <Box key="pilot-band" flexDirection="row" justifyContent="space-between" width={e.props.bodyColumns}>
756 <Box flexShrink={1}>
757 {doing ? (
758 <Text color="claude" wrap="truncate-end">
759 ✨ {doing}
760 </Text>
761 ) : up ? (
762 <Text color="claude" wrap="truncate-end">
763 ✨ Upgrading your prompt: answer in the panel
764 </Text>
765 ) : chooser && pick ? (
766 <Text wrap="truncate-end">
767 <Text color={MODEL_COLOR[pick.model]} bold>
768 ◆ {label(pick)}
769 </Text>
770 <Text color={MODEL_COLOR[pick.model]}> {meter(pick.effort)}</Text>
771 <Text dimColor> {pick.reason}</Text>
772 {pick.by === 'trusted' ? <Text dimColor> · auto</Text> : null}
773 </Text>
774 ) : (
775 <Text dimColor wrap="truncate-end">
776 {[chooser ? '◆ Auto model' : '', upgrader ? '✨ Prompt upgrader' : ''].filter(Boolean).join(' · ')}
777 </Text>
778 )}
779 </Box>
780 {usageText ? (
781 <Text color={usageColor} dimColor={!usageColor}>
782 {' '}
783 {usageText}
784 </Text>
785 ) : null}
786 </Box>
787 )
788 })
789
790 // ── The upgrader's panel ─────────────────────────────────────────────────
791
792 on('ui.render', { component: 'Pane', requestId: 'prompt-upgrader' }, async ($, e) => {
793 const up = await read($, upgrade)
794 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
795 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
796 const width = Math.max(30, e.props.bodyColumns)
797
798 if (!up)
799 return (
800 <Box flexDirection="column" paddingX={1}>
801 <Text dimColor wrap="wrap">
802 No prompt is being upgraded. Long new prompts open this panel; start one with ?? to ask for it, or with !! to skip it.
803 </Text>
804 </Box>
805 )
806
807 const step = up.stage === 'questions' ? 'Step 1 of 2 · A few questions' : up.stage === 'writing' ? 'Step 2 of 2 · Writing' : up.stage === 'ready' ? 'Step 2 of 2 · Ready' : 'Step 2 of 2'
808 const header = (
809 <Box key="head" flexDirection="row" justifyContent="space-between">
810 <Text bold color="claude">
811 ✨ PROMPT UPGRADER
812 </Text>
813 <Text dimColor>{step}</Text>
814 </Box>
815 )
816 const original = (
817 <Box key="original" flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
818 <Text dimColor bold>
819 Your prompt{up.intent ? ` · ${up.intent}` : ''}
820 </Text>
821 <Text dimColor wrap="wrap">
822 {clip(up.original.replace(/\s+/g, ' '), up.stage === 'questions' ? 360 : 160)}
823 </Text>
824 </Box>
825 )
826
827 const chip = (key: string, isOn: boolean, text: string, onPress: () => void) => (
828 <Button key={key} plain onPress={onPress}>
829 <Text color={isOn ? 'claude' : 'subtle'}>{isOn ? '●' : '○'}</Text> <Text bold={isOn}>{text}</Text>
830 </Button>
831 )
832
833 const modelRows = up.chooser ? (
834 <Box key="model" flexDirection="column" marginTop={1}>
835 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
836 <Text bold>Model </Text>
837 {MODELS.map(model => chip(`model:${model}`, up.pick.model === model, MODEL_LABEL[model], () => void repick($, { model })))}
838 </Box>
839 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
840 <Text bold>Effort</Text>
841 {EFFORTS.map(effort => chip(`effort:${effort}`, up.pick.effort === effort, EFFORT_LABEL[effort], () => void repick($, { effort })))}
842 </Box>
843 <Text dimColor wrap="wrap">
844 {meter(up.pick.effort)} {up.pick.reason}
845 {up.pick.by === 'you' ? ' · your choice' : ''}
846 </Text>
847 {up.switch.kind !== 'none' ? (
848 <Text color={up.switch.kind === 'costly' ? 'warning' : undefined} dimColor={up.switch.kind !== 'costly'} wrap="wrap">
849 {up.switch.kind === 'costly' ? '⚠ ' : 'ⓘ '}
850 {up.switch.note}
851 </Text>
852 ) : null}
853 </Box>
854 ) : null
855
856 const pickLine = up.chooser ? (
857 <Box key="pick-line" flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
858 <Text dimColor>Runs on</Text>
859 <Text color={MODEL_COLOR[up.pick.model]} bold>
860 ◆ {label(up.pick)}
861 </Text>
862 <Text dimColor>{up.pick.reason}</Text>
863 <Button key="chips" plain onPress={() => void toggleChips($)}>
864 <Text color="suggestion">{up.chipsOpen ? '▾ hide' : '▸ change'}</Text>
865 </Button>
866 </Box>
867 ) : null
868
869 if (up.stage === 'questions')
870 return (
871 <Box flexDirection="column" paddingX={1} width={width}>
872 {header}
873 {original}
874 {up.questions.map((question: UpgradeQuestion, index: number) => {
875 const answer = up.answers[question.id] ?? {}
876 const ownWords = answer.text?.trim() ?? ''
877
878 return (
879 <Box key={`q:${question.id}`} flexDirection="column" marginTop={1}>
880 <Text>
881 <Text color="claude" bold>
882 {index + 1}
883 </Text>
884 <Text bold> {question.topic.toUpperCase()}</Text>
885 </Text>
886 <Text wrap="wrap">{question.question}</Text>
887 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
888 {question.options.map((option, choice) =>
889 chip(`opt:${question.id}:${choice}`, !ownWords && answer.choice === choice, option, () => void choose($, question.id, choice)),
890 )}
891 </Box>
892 {Input ? (
893 <Input
894 key={`own:${question.id}`}
895 placeholder="Or type your own answer…"
896 value={answer.text ?? ''}
897 onInput={value => void typeOwn($, question.id, value)}
898 onSubmit={value => void typeOwn($, question.id, value)}
899 />
900 ) : null}
901 </Box>
902 )
903 })}
904 {Input ? (
905 <Box key="extra" flexDirection="column" marginTop={1}>
906 <Text bold>Anything to add?</Text>
907 <Input key="extra-input" placeholder="Optional: a detail, a constraint…" value={up.extra} onInput={value => void typeExtra($, value)} onSubmit={value => void typeExtra($, value)} />
908 </Box>
909 ) : null}
910 {pickLine}
911 {up.chipsOpen ? modelRows : null}
912 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
913 <Button key="improve" label="✨ Improve prompt" variant="primary" hotkey="i" autoFocus onPress={() => void writeUpgrade($)} />
914 <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
915 <Button key="cancel" label="Cancel" hotkey="x" onPress={() => void cancelFromPane($)} />
916 </Box>
917 <Text dimColor>Cancel or Esc puts your prompt back in the box.</Text>
918 </Box>
919 )
920
921 if (up.stage === 'writing')
922 return (
923 <Box flexDirection="column" paddingX={1} width={width}>
924 {header}
925 {original}
926 <Text key="writing" color="claude" bold>
927 ◌ Writing your improved prompt…
928 </Text>
929 <Box key="answers" flexDirection="column" marginTop={1}>
930 {up.questions.map(question => {
931 const answer = up.answers[question.id]
932 const words = answer?.text?.trim() || (answer?.choice !== undefined ? question.options[answer.choice] : '')
933
934 return (
935 <Text key={`a:${question.id}`} dimColor wrap="wrap">
936 {question.topic}: {words || '—'}
937 </Text>
938 )
939 })}
940 {up.extra.trim() ? (
941 <Text key="a:extra" dimColor wrap="wrap">
942 Also: {up.extra.trim()}
943 </Text>
944 ) : null}
945 </Box>
946 <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
947 <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
948 </Box>
949 </Box>
950 )
951
952 if (up.stage === 'failed')
953 return (
954 <Box flexDirection="column" paddingX={1} width={width}>
955 {header}
956 {original}
957 <Text key="error" color="warning" wrap="wrap">
958 ⚠ {up.error}
959 </Text>
960 <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
961 <Button key="retry" label="Try again" variant="primary" hotkey="r" autoFocus onPress={() => void writeUpgrade($)} />
962 <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
963 <Button key="cancel" label="Cancel" hotkey="x" onPress={() => void cancelFromPane($)} />
964 </Box>
965 </Box>
966 )
967
968 return (
969 <Box flexDirection="column" paddingX={1} width={width}>
970 {header}
971 <Box key="improved-head" flexDirection="row" justifyContent="space-between" marginTop={1}>
972 <Text bold color="success">
973 ✓ Improved prompt
974 </Text>
975 <Text dimColor>
976 {wordCount(up.original)} → {wordCount(up.improved)} words
977 </Text>
978 </Box>
979 <Box key="improved" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
980 <Markdown key="improved-text" text={up.improved} />
981 </Box>
982 {up.previous.length > 0 ? (
983 <Button key="undo" plain onPress={() => void undoTweak($)}>
984 <Text color="suggestion">↶ Undo the last change</Text>
985 </Button>
986 ) : null}
987 {modelRows}
988 {Input ? (
989 <Box key="tweak" flexDirection="column" marginTop={1}>
990 <Input key="tweak-input" placeholder="Ask for a change: shorter, add tests…, then Enter" onSubmit={value => void (value.trim() ? writeUpgrade($, value) : undefined)} />
991 </Box>
992 ) : null}
993 {up.notice ? (
994 <Text key="notice" color={up.notice.startsWith('✓') || up.notice.startsWith('Copied') ? 'success' : 'warning'} wrap="wrap">
995 {up.notice}
996 </Text>
997 ) : null}
998 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
999 <Button
1000 key="launch"
1001 label={up.chooser ? `▶ Launch on ${label(up.pick)}` : '▶ Launch'}
1002 variant="primary"
1003 hotkey="l"
1004 autoFocus
1005 onPress={() => void launch($, 'improved')}
1006 />
1007 <Button key="copy" label="Copy" hotkey="c" onPress={press => void copyImproved($, press.surface)} />
1008 <Button key="edit" label="Edit in box" hotkey="e" onPress={press => void editImproved($, press.surface)} />
1009 <Button key="original" label="Send original" hotkey="o" onPress={() => void launch($, 'original')} />
1010 {up.switch.kind === 'costly' ? <Button key="new" label="New conversation" hotkey="n" onPress={press => void newConversation($, press.surface)} /> : null}
1011 </Box>
1012 </Box>
1013 )
1014 })
1015}
1016hooks/decide.ts 533 lines1import type { Effort, ModelName, Pick, SwitchCost, TrustEntry, TurnCost, UpgradeQuestion } from '../types'
2
3// ── The ladder ────────────────────────────────────────────────────────────
4
5export const MODEL_LABEL: Record<ModelName, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' }
6export const EFFORT_LABEL: Record<Effort, string> = { medium: 'Medium', high: 'High', xhigh: 'Extra', max: 'Max' }
7export const MODELS: ModelName[] = ['haiku', 'sonnet', 'opus']
8export const EFFORTS: Effort[] = ['medium', 'high', 'xhigh', 'max']
9export const MODEL_RANK: Record<ModelName, number> = { haiku: 0, sonnet: 1, opus: 2 }
10
11/** Weakest to strongest; the confirm dialog offers a pick's neighbours. */
12export const LADDER: [ModelName, Effort][] = [
13 ['haiku', 'medium'],
14 ['haiku', 'high'],
15 ['sonnet', 'medium'],
16 ['sonnet', 'high'],
17 ['sonnet', 'xhigh'],
18 ['opus', 'high'],
19 ['opus', 'xhigh'],
20 ['opus', 'max'],
21]
22
23export const label = (pick: { model: ModelName; effort: Effort }) => `${MODEL_LABEL[pick.model]} · ${EFFORT_LABEL[pick.effort]}`
24
25/** A four-step meter for the effort: ▰▰▱▱ is High. */
26export const meter = (effort: Effort) => {
27 const filled = EFFORTS.indexOf(effort) + 1
28
29 return '▰'.repeat(filled) + '▱'.repeat(EFFORTS.length - filled)
30}
31
32const rankOf = (pick: { model: ModelName; effort: Effort }) => {
33 const exact = LADDER.findIndex(([model, effort]) => model === pick.model && effort === pick.effort)
34 if (exact >= 0) return exact
35 // Off the ladder (Opus · Medium, Sonnet · Max): the nearest rung of the same model.
36 const same = LADDER.map((rung, index) => ({ rung, index })).filter(one => one.rung[0] === pick.model)
37 const wanted = EFFORTS.indexOf(pick.effort)
38
39 return [...same].sort((a, b) => Math.abs(EFFORTS.indexOf(a.rung[1]) - wanted) - Math.abs(EFFORTS.indexOf(b.rung[1]) - wanted))[0]?.index ?? 5
40}
41
42/** The rungs just above and below a pick, for the confirm dialog. */
43export const neighbours = (pick: { model: ModelName; effort: Effort }) => {
44 const rank = rankOf(pick)
45 const at = (index: number) => {
46 const rung = LADDER[index]
47
48 return rung && !(rung[0] === pick.model && rung[1] === pick.effort) ? { model: rung[0], effort: rung[1] } : undefined
49 }
50
51 return { up: at(rank + 1), down: at(rank - 1) }
52}
53
54export const samePick = (a: { model: ModelName; effort: Effort } | null | undefined, b: { model: ModelName; effort: Effort } | null | undefined) =>
55 !!a && !!b && a.model === b.model && a.effort === b.effort
56
57/** The tier a model id belongs to: "claude-opus-5-5[1m]" is opus. */
58export const tierOf = (model: string): ModelName | undefined =>
59 /opus/i.test(model) ? 'opus' : /sonnet/i.test(model) ? 'sonnet' : /haiku/i.test(model) ? 'haiku' : undefined
60
61// ── Words typed by the person: tags, and a pick in their own words ─────────
62
63export type Tags = {
64 text: string
65 model?: ModelName
66 effort?: Effort
67 /** `!!`: no upgrader for this prompt. */
68 skipUpgrade: boolean
69 /** `??`: the upgrader even for a short prompt. */
70 forceUpgrade: boolean
71}
72
73const MODEL_WORDS: Record<string, ModelName> = { opus: 'opus', sonnet: 'sonnet', haiku: 'haiku' }
74const EFFORT_WORDS: Record<string, Effort> = { medium: 'medium', med: 'medium', high: 'high', extra: 'xhigh', xhigh: 'xhigh', max: 'max' }
75const TAG = /^(!!|\?\?|!(opus|sonnet|haiku|medium|med|high|extra|xhigh|max))$/i
76
77/** Reads `!!`, `??` and `!opus`/`!max`… at the start or the end of a prompt, and strips them. */
78export const parseTags = (raw: string): Tags => {
79 const words = raw.trim().split(/\s+/)
80 const tags: string[] = []
81 while (words.length > 1 && TAG.test(words[0] ?? '')) tags.push(words.shift() as string)
82 while (words.length > 1 && TAG.test(words[words.length - 1] ?? '')) tags.push(words.pop() as string)
83 if (tags.length === 0) return { text: raw, skipUpgrade: false, forceUpgrade: false }
84 const found: Tags = { text: words.join(' '), skipUpgrade: false, forceUpgrade: false }
85 for (const tag of tags.map(one => one.toLowerCase())) {
86 if (tag === '!!') found.skipUpgrade = true
87 else if (tag === '??') found.forceUpgrade = true
88 else {
89 const word = tag.slice(1)
90 if (MODEL_WORDS[word]) found.model = MODEL_WORDS[word]
91 if (EFFORT_WORDS[word]) found.effort = EFFORT_WORDS[word]
92 }
93 }
94 // Keep the person's own line breaks when only the edges carried tags.
95 const first = raw.indexOf(words[0] ?? '')
96 const last = raw.lastIndexOf(words[words.length - 1] ?? '')
97 if (first >= 0 && last >= first) found.text = raw.slice(first, last + (words[words.length - 1] ?? '').length)
98
99 return found
100}
101
102/** "sonnet max", "Opus · Extra", "max" typed under Other: a pick, or undefined. */
103export const parsePickWords = (words: string, base: { model: ModelName; effort: Effort }) => {
104 const lower = words.toLowerCase()
105 const model = MODELS.find(one => lower.includes(one))
106 const effortWord = Object.keys(EFFORT_WORDS).find(one => new RegExp(`\\b${one}\\b`).test(lower))
107 const effort = effortWord ? EFFORT_WORDS[effortWord] : undefined
108 if (!model && !effort) return undefined
109
110 return { model: model ?? base.model, effort: effort ?? (model && model !== base.model ? 'high' : base.effort) }
111}
112
113// ── What kind of prompt it is ──────────────────────────────────────────────
114
115const ACK =
116 /^(y|yes|yeah|yep|yup|ok|okay|k|sure|go|go ahead|go for it|do it|continue|carry on|proceed|next|done|thanks|thank you|thx|merci|oui|non|no|nope|vas-y|vas y|go on|c'est bon|parfait|perfect|great|nice|cool|lgtm|ship it|approved?|agreed|d'accord|ok merci|top|super|[1-9]|[a-d]|option [a-d1-9])[\s.!?]*$/i
117
118export const wordCount = (text: string) => text.trim().split(/\s+/).filter(Boolean).length
119
120export type PromptKind = 'ack' | 'short' | 'substantial'
121
122/**
123 * `ack`: "yes", "continue" — keeps what runs. `short`: a brief instruction or an
124 * answer to Claude's question — confirmed only when the pick changes.
125 * `substantial`: a new task in its own words — the upgrader's, and always confirmed.
126 */
127export const kindOf = (text: string, lastAssistant: string): PromptKind => {
128 const trimmed = text.trim()
129 if (ACK.test(trimmed)) return 'ack'
130 const words = wordCount(trimmed)
131 const tail = lastAssistant.trim().slice(-400)
132 const claudeAsked = /\?\s*$/.test(tail) || /\?\s*\n/.test(tail.slice(-200))
133 if (words < 12) return 'short'
134 if (claudeAsked && words < 30) return 'short'
135
136 return 'substantial'
137}
138
139// ── Go-aheads: replies that continue the work at hand (calibrated on 200 real prompts) ──
140
141const GO_AHEAD =
142 /^(y|yes|yeah|yep|ok(ay)?|sure|go|go ahead|go for (it|both|all)|do it|let'?s (go|do it)|work on|move forward|you can (move forward|do|go|proceed|continue|start)|proceed|continue|carry on|please (continue|proceed|resume)|agreed?|approved?|confirmed?|both|all of them|keep|fine|perfect|great|parfait|d'accord|oui|vas[- ]?y|go on|c'est bon|run it( yourself)?|deploy it)\b/i
143const SELECTION = /^(ok(ay)?( with| for)? |work on |do |go with )?((option )?[a-e1-9]\b[\s,&+]*(and |et |through |to |à )?)+(are |is )?(a go|yes|ok|oui|go|done)?\b/i
144const STATUS =
145 /^(i'?ve|i have|i'?m now|i am now|j'ai|c'est|it'?s|app has|the app has|everything|all (these|of them)|done|chrome is|digest|that'?s|it was|ajouté|fait)\b.*\b(done|filled|logged|added|created|set|put|published|reconnected|arrived|configured|available|approved|mis|ajouté|fait|relancé|installé|connecté|ended|views?)\b/i
146const RESUME = /\b(resume|reprends|reprend|continue|carry on)\b/i
147
148/**
149 * True for a reply that continues the work at hand: a go-ahead ("Work on 1, 3, 4"),
150 * a status from the person ("App has been published"), a resume, or a short answer
151 * to Claude's own question. The chooser keeps what runs for these, unasked.
152 */
153export const isContinuation = (text: string, lastAssistant: string) => {
154 const trimmed = text.trim()
155 const words = wordCount(trimmed)
156 if (words === 0) return false
157 if (ACK.test(trimmed)) return true
158 const tail = lastAssistant.trim().slice(-400)
159 if ((/\?\s*$/.test(tail) || /\?\s*\n/.test(tail.slice(-200))) && words <= 25) return true
160 if (words <= 12 && RESUME.test(trimmed)) return true
161 if (words > 25 || trimmed.includes('?') || MULTI_STEP_RULES[0]?.test(trimmed)) return false
162
163 return GO_AHEAD.test(trimmed) || SELECTION.test(trimmed) || (words <= 15 && STATUS.test(trimmed))
164}
165
166// ── The rules: instant picks for the obvious prompts ──────────────────────
167
168const any = (rules: RegExp[], text: string) => rules.filter(rule => rule.test(text)).length
169
170const MAX_RULES = [
171 /\barchitect/i,
172 /\bfrom scratch\b/i,
173 /\bmigrate\b/i,
174 /\bmigration (plan|strategy|to|from|of)\b/i,
175 /\bmigrer\b/i,
176 /\brace condition/i,
177 /\bdeadlock/i,
178 /\bmemory leak/i,
179 /\broot cause\b/i,
180 /\bstill (fails?|failing|broken|crash|crashing|doesn'?t work|not working)/i,
181 /\btried everything\b/i,
182 /\bultrathink\b/i,
183 /\bthink (very |really )?hard\b/i,
184 /\bsecurity (audit|review)\b/i,
185 /\bredesign\b/i,
186 /\boverhaul\b/i,
187 /\brewrite (the|all|whole|entire)\b/i,
188 /\bde z[ée]ro\b/i,
189 /\brefonte\b/i,
190 /\bfuite m[ée]moire\b/i,
191 /\bcause racine\b/i,
192 /\btoujours (cass[ée]|pas|en [ée]chec|le m[êe]me bug|un bug)/i,
193 /\br[ée]fl[ée]chis (bien|à fond|longuement)\b/i,
194 /\b(strategy|strat[ée]gie)\b/i,
195]
196
197const EXTRA_RULES = [
198 /\bdebug/i,
199 /\bbugs?\b/i,
200 /\bcrash/i,
201 /\bfail(s|ing|ed|ure)?\b/i,
202 /\berrors?\b/i,
203 /\brefactor/i,
204 /\bimplement/i,
205 /\bfeatures?\b/i,
206 /\breview\b/i,
207 /\binvestigat/i,
208 /\banaly[sz]/i,
209 /\boptimi[sz]/i,
210 /\bperformance\b/i,
211 /\bwhy (does|is|do|did|doesn'?t|isn'?t)\b/i,
212 /\bplan\b/i,
213 /\bintegrat/i,
214 /\baudit\b/i,
215 /\bplante\b/i,
216 /\berreurs?\b/i,
217 /\bcorrige/i,
218 /\bd[ée]bug/i,
219 /\bimpl[ée]mente/i,
220 /\bfonctionnalit[ée]/i,
221 /\brevue\b/i,
222 /\banalyse/i,
223 /\bpourquoi\b/i,
224 /\boptimise/i,
225 /\bcon[çc]ois\b/i,
226 /\bimprove/i,
227 /\bam[ée]liore/i,
228 /\bwhat'?s next\b/i,
229 /\bparit[yé]/i,
230]
231
232/** Several actions chained, or shipping: release work is never a one-liner. */
233const MULTI_STEP_RULES = [/\b(release|testflight|production|publish|ship it|mise en prod)\b/i, /\b(and then|then|ensuite|puis|once it'?s)\b/i]
234
235/** A yes/no question about the state of things: a quick check. */
236const CHECK_QUESTION = /^(did you|have you|has it|is (it|everything|that|this)|are (they|you|we done)|was it|isn'?t it|do you have|est-ce que (c'est|tout|tu as))\b[^?]{0,200}\?\s*$/i
237const STATUS_PING = /\b(still running|toujours en cours|is it done|c'est fini)\b/i
238const RUN_IT = /\b(apply|run|deploy|applique|lance)\b.{0,30}\b(migrations?|yourself|toi-m[êe]me)\b/i
239
240const ROUTINE_RULES = [
241 /\brename\b/i,
242 /\bformat(ting)?\b/i,
243 /\btranslate\b/i,
244 /\btypos?\b/i,
245 /\bcommit\b/i,
246 /\bpush\b/i,
247 /\bgit (status|log|diff|pull)\b/i,
248 /\brun (the )?(tests?|build|lint|linter)\b/i,
249 /\bbump\b/i,
250 /\bsummari[sz]e\b/i,
251 /\bexplain\b/i,
252 /\bshow me\b/i,
253 /\blist (the|all)\b/i,
254 /\bwhat('?s| is| are| does)\b/i,
255 /\bhow (do|can|to)\b/i,
256 /\btradui[st]\b/i,
257 /\brenomme\b/i,
258 /\bexplique\b/i,
259 /\br[ée]sume\b/i,
260 /\bc'est quoi\b/i,
261 /\bcomment (je|on|faire)\b/i,
262 /\blance (les )?tests\b/i,
263]
264
265const TRIVIAL = /^(hi|hello|hey|salut|bonjour|coucou|thanks|thank you|merci|ok|cool|test|ping)\b[\s.!?]*$/i
266
267export type RulePick = { pick: Pick; isConfident: boolean }
268
269/** Instant local pick; `isConfident` false means a quick Haiku read should settle it. */
270export const rulePick = (text: string, hasImages: boolean): RulePick => {
271 const words = wordCount(text)
272 const rules = (model: ModelName, effort: Effort, reason: string, isConfident: boolean): RulePick => ({
273 pick: { model, effort, reason, by: 'rules', kind: reason },
274 isConfident,
275 })
276 if (TRIVIAL.test(text.trim())) return rules('haiku', 'medium', 'trivial message', true)
277 if (STATUS_PING.test(text) && words <= 8) return rules('haiku', 'medium', 'status check', true)
278 const max = any(MAX_RULES, text)
279 const extra = any(EXTRA_RULES, text)
280 const routine = any(ROUTINE_RULES, text)
281 if (RUN_IT.test(text) && max === 0 && words <= 20) return rules('sonnet', 'high', 'run a step', true)
282 if (CHECK_QUESTION.test(text.trim()) && max === 0 && words <= 25) return rules('sonnet', 'medium', 'quick check', extra === 0 && words <= 12)
283 const isShipping = MULTI_STEP_RULES[0]?.test(text) === true
284 const isChained = MULTI_STEP_RULES[1]?.test(text) === true && routine >= 1
285 if (max >= 1 && (words >= 20 || extra >= 1 || max >= 2)) return rules('opus', 'max', 'hard, deep problem', true)
286 if (max >= 1) return rules('opus', 'xhigh', 'demanding task', true)
287 if (extra >= 2) return rules('opus', 'xhigh', words > 40 ? 'multi-part engineering task' : 'debugging or design work', true)
288 if (extra === 1 && words >= 50) return rules('opus', 'xhigh', 'detailed engineering task', true)
289 if (extra === 1) return rules('opus', 'high', 'engineering task', words >= 6)
290 if ((isShipping || isChained) && max === 0) return rules('opus', 'high', 'multi-step chore', isShipping)
291 if (hasImages) return rules('opus', 'high', 'task with images', false)
292 if (routine >= 1 && words <= 30) return rules('sonnet', words <= 10 ? 'medium' : 'high', 'routine request', words <= 20)
293 if (words > 150) return rules('opus', 'high', 'long, detailed task', false)
294
295 return rules('opus', 'high', 'general task', false)
296}
297
298/** Keeps a pick sensible: no Low, efforts the model takes, Opus never below Medium. */
299export const normalise = (model: string | undefined, effort: string | undefined, fallback: Pick): { model: ModelName; effort: Effort } => {
300 const tier = (MODELS as string[]).includes(model ?? '') ? (model as ModelName) : fallback.model
301 const level = effort === 'extra' ? 'xhigh' : effort === 'low' ? 'medium' : effort
302 const chosen = (EFFORTS as string[]).includes(level ?? '') ? (level as Effort) : fallback.effort
303
304 return { model: tier, effort: chosen }
305}
306
307// ── Switching models mid-conversation ──────────────────────────────────────
308
309/** Below this, re-reading a conversation on another model costs next to nothing. */
310export const SMALL_CONTEXT = 20_000
311
312export const kTokens = (tokens: number) => (tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`)
313
314export const ago = (ms: number) => {
315 const minutes = Math.round(ms / 60_000)
316 if (minutes < 1) return 'just now'
317 if (minutes < 60) return `${minutes} min ago`
318
319 return `${Math.round(minutes / 60)} h ago`
320}
321
322/** `none`: same model; `cheap`: a plain confirm says so; `costly`: the three-choice switch dialog. */
323export type Switch = SwitchCost
324
325/**
326 * What moving from the model that runs now to the pick's costs. The cache is
327 * per model: a model that answered within the cache's lifetime still holds the
328 * conversation, so going back to it is cheap; one that never did re-reads it all.
329 */
330export const switchCost = (
331 pick: { model: ModelName },
332 current: { model: ModelName } | null,
333 contextTokens: number,
334 lastAnswered: Partial<Record<ModelName, number>>,
335 now: number,
336 cacheMs: number,
337): Switch => {
338 if (!current || current.model === pick.model) return { kind: 'none', note: '', short: '', tokens: '' }
339 const from = MODEL_LABEL[current.model]
340 const to = MODEL_LABEL[pick.model]
341 if (contextTokens < SMALL_CONTEXT)
342 return {
343 kind: 'cheap',
344 note: `The conversation is still short (${kTokens(contextTokens)} tokens): switching from ${from} costs little.`,
345 short: 'Short conversation: switching costs little',
346 tokens: kTokens(contextTokens),
347 }
348 const targetAt = lastAnswered[pick.model]
349 if (targetAt !== undefined && now - targetAt < cacheMs)
350 return {
351 kind: 'cheap',
352 note: `${to} still holds this conversation in its cache (answered ${ago(now - targetAt)}): switching back costs little.`,
353 short: `${to}'s cache is still warm: switching costs little`,
354 tokens: kTokens(contextTokens),
355 }
356 const currentAt = lastAnswered[current.model]
357 if (currentAt !== undefined && now - currentAt >= cacheMs)
358 return {
359 kind: 'cheap',
360 note: `${from}'s cache has gone cold (last answer ${ago(now - currentAt)}): switching costs nothing extra.`,
361 short: `${from}'s cache is cold: switching costs nothing extra`,
362 tokens: kTokens(contextTokens),
363 }
364
365 return {
366 kind: 'costly',
367 note: `Switching from ${from} re-reads ${kTokens(contextTokens)} tokens on ${to} without the cache; staying on ${from} reads them from its cache.`,
368 short: `Re-reads ${kTokens(contextTokens)} tokens on ${to}, without the cache`,
369 tokens: kTokens(contextTokens),
370 }
371}
372
373/**
374 * Mid-conversation, a step DOWN in model is rarely worth a full re-read: when
375 * the switch would be costly, the stronger model that runs now stays, at Medium.
376 */
377export const keepStronger = (pick: Pick, current: { model: ModelName; effort: Effort } | null, cost: Switch): Pick => {
378 if (!current || cost.kind !== 'costly' || MODEL_RANK[pick.model] >= MODEL_RANK[current.model]) return pick
379
380 return { model: current.model, effort: 'medium', reason: `${pick.reason}, kept on ${MODEL_LABEL[current.model]} (cache warm)`, by: 'kept' }
381}
382
383/** Past this, a conversation may not fit another model's window: it stays where it is. */
384export const MOVABLE_CONTEXT = 150_000
385
386/** A conversation too long to move keeps its model (the effort still follows the pick). */
387export const keepIfTooLong = (pick: Pick, current: { model: ModelName } | null, contextTokens: number): Pick =>
388 current && pick.model !== current.model && contextTokens > MOVABLE_CONTEXT
389 ? { ...pick, model: current.model, reason: `${pick.reason}, kept on ${MODEL_LABEL[current.model]} (too long to move)`, by: 'kept' }
390 : pick
391
392// ── Learning from the person: ask less over time ──────────────────────────
393
394/** Kept the recommendation this many times in a row for a kind of task: stop asking for it. */
395export const TRUST_AFTER = 5
396
397/** The kind a pick's trust is kept under: the rules' kind, else its level ("opus:high"). */
398export const kindKey = (pick: Pick) => pick.kind ?? `${pick.model}:${pick.effort}`
399
400export const isTrusted = (trust: Record<string, TrustEntry>, kind: string) => (trust[kind]?.streak ?? 0) >= TRUST_AFTER
401
402/** One answer to a plain confirm: kept the recommendation (streak + 1) or chose otherwise (back to 0). */
403export const recordAnswer = (trust: Record<string, TrustEntry>, kind: string, kept: boolean, now: number): Record<string, TrustEntry> => ({
404 ...trust,
405 [kind]: { streak: kept ? (trust[kind]?.streak ?? 0) + 1 : 0, at: now },
406})
407
408// ── What a turn costs ──────────────────────────────────────────────────────
409
410export const costKey = (pick: { model: ModelName; effort: Effort }) => `${pick.model}:${pick.effort}`
411
412/** Relative weight of a turn per model (input price ratio) and per effort (thinking spent). */
413const MODEL_WEIGHT: Record<ModelName, number> = { haiku: 1, sonnet: 3, opus: 5 }
414const EFFORT_WEIGHT: Record<Effort, number> = { medium: 0.7, high: 1, xhigh: 1.4, max: 2 }
415const weight = (pick: { model: ModelName; effort: Effort }) => MODEL_WEIGHT[pick.model] * EFFORT_WEIGHT[pick.effort]
416
417/** Readings past this are a window reset or another session's burst, not one turn. */
418const MAX_TURN_POINTS = 40
419
420/** Adds one turn's cost (points of the 5-hour window) to the moving average of its level. */
421export const recordTurnCost = (costs: Record<string, TurnCost>, key: string, points: number): Record<string, TurnCost> => {
422 if (!Number.isFinite(points) || points < 0 || points > MAX_TURN_POINTS) return costs
423 const was = costs[key]
424 const avg = was ? was.avg * 0.8 + points * 0.2 : points
425
426 return { ...costs, [key]: { avg: Math.round(avg * 100) / 100, n: (was?.n ?? 0) + 1 } }
427}
428
429/**
430 * A turn's expected cost on a level: its own average once two turns ran on it,
431 * else the other levels' averages scaled by weight; undefined with no history.
432 */
433export const estimateTurn = (costs: Record<string, TurnCost>, pick: { model: ModelName; effort: Effort }): number | undefined => {
434 const own = costs[costKey(pick)]
435 if (own && own.n >= 2) return own.avg
436 const known = Object.entries(costs).filter(([key, one]) => one.n >= 2 && key.includes(':'))
437 if (known.length === 0) return undefined
438 const perWeight =
439 known.reduce((sum, [key, one]) => {
440 const [model, effort] = key.split(':') as [ModelName, Effort]
441 return MODEL_WEIGHT[model] && EFFORT_WEIGHT[effort] ? sum + one.avg / weight({ model, effort }) : sum
442 }, 0) / known.length
443
444 return perWeight * weight(pick)
445}
446
447/** "≈3%", "<1%", or '' with no history. */
448export const costText = (points: number | undefined) =>
449 points === undefined ? '' : points < 1 ? '<1%' : `≈${Math.round(points)}%`
450
451// ── Reading the models' answers ────────────────────────────────────────────
452
453const firstJson = (text: string): unknown => {
454 const start = text.indexOf('{')
455 const end = text.lastIndexOf('}')
456 if (start < 0 || end <= start) return undefined
457 try {
458 return JSON.parse(text.slice(start, end + 1))
459 } catch {
460 return undefined
461 }
462}
463
464const str = (value: unknown, max: number) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
465
466export type Read = {
467 isClear: boolean
468 intent: string
469 pick: Pick
470 questions: UpgradeQuestion[]
471}
472
473/** The Haiku read: whether the prompt is clear, the questions worth asking, and the pick. */
474export const parseRead = (text: string, fallback: Pick, maxQuestions: number): Read | undefined => {
475 const json = firstJson(text) as Record<string, unknown> | undefined
476 if (!json || typeof json !== 'object') return undefined
477 const { model, effort } = normalise(str(json.model, 10), str(json.effort, 10), fallback)
478 const raw = Array.isArray(json.questions) ? json.questions : []
479 const questions: UpgradeQuestion[] = []
480 for (const [index, one] of raw.entries()) {
481 if (questions.length >= maxQuestions || !one || typeof one !== 'object') continue
482 const item = one as Record<string, unknown>
483 const options = (Array.isArray(item.options) ? item.options : [])
484 .map(option => str(option, 60))
485 .filter(Boolean)
486 .slice(0, 4)
487 const question = str(item.question, 200)
488 if (!question || options.length < 2) continue
489 const recommended = typeof item.recommended === 'number' && item.recommended >= 0 && item.recommended < options.length ? item.recommended : 0
490 questions.push({ id: `q${index + 1}`, topic: str(item.topic, 14) || `Question ${index + 1}`, question, options, recommended })
491 }
492
493 return {
494 isClear: json.clear === true || questions.length === 0,
495 intent: str(json.intent, 60),
496 pick: { model, effort, reason: str(json.reason, 40) || fallback.reason, by: 'haiku' },
497 questions,
498 }
499}
500
501/** The classify-only read. */
502export const parseClassify = (text: string, fallback: Pick): Pick | undefined => {
503 const json = firstJson(text) as Record<string, unknown> | undefined
504 if (!json || typeof json !== 'object') return undefined
505 const { model, effort } = normalise(str(json.model, 10), str(json.effort, 10), fallback)
506
507 return { model, effort, reason: str(json.reason, 40) || fallback.reason, by: 'haiku' }
508}
509
510/** The rewrite: the prompt between <prompt> tags, then `PICK: opus high | reason`. */
511export const parseRewrite = (text: string, fallback: Pick): { prompt: string; pick: Pick } | undefined => {
512 const match = /<prompt>\s*([\s\S]*?)\s*<\/prompt>/i.exec(text)
513 const prompt = (match?.[1] ?? '').trim()
514 if (!prompt) return undefined
515 const line = /PICK:\s*([a-z]+)\s+([a-z]+)\s*(?:\|\s*(.+))?/i.exec(text)
516 const { model, effort } = normalise(line?.[1]?.toLowerCase(), line?.[2]?.toLowerCase(), fallback)
517
518 return { prompt, pick: { model, effort, reason: (line?.[3] ?? '').trim().slice(0, 40) || fallback.reason, by: 'upgrader' } }
519}
520
521/** The answers as the rewrite reads them. */
522export const answersText = (questions: UpgradeQuestion[], answers: Record<string, { choice?: number; text?: string }>, extra: string) => {
523 const lines = questions.map(question => {
524 const answer = answers[question.id]
525 const words = answer?.text?.trim() || (answer?.choice !== undefined ? question.options[answer.choice] : undefined)
526
527 return `- ${question.question} → ${words ?? '(no preference)'}`
528 })
529 if (extra.trim()) lines.push(`- Also: ${extra.trim()}`)
530
531 return lines.join('\n')
532}
533hooks/prompts.ts 45 lines1import type { ModelName } from '../types'
2
3/** How the chooser ranks work; shared by every read so the picks agree. */
4export const POSTURE = `Pick the model and effort that will run the prompt. Aim for the strongest model the task deserves, without waste.
5- opus: the default for real work — features, bug fixes, refactors, reviews, plans, anything multi-step or touching several files. effort "high"; "xhigh" for hard debugging, multi-file features, code reviews; "max" only for architecture, migrations, stubborn or subtle bugs (race conditions, leaks), research synthesis, or when the person asks for deep thought.
6- sonnet: clearly routine work — a small single-file edit, a quick factual or how-to question, formatting, renaming, translating, git chores, running a command. effort "medium" or "high".
7- haiku: trivial only — a greeting, thanks, a one-line lookup. effort "medium".
8Never "low". reason: 2 to 5 plain words naming the task's kind ("multi-file feature", "quick question").`
9
10export const CLASSIFY_SYSTEM = `You route a developer's prompt to Claude Code (an agentic coding assistant). ${POSTURE}
11Answer with JSON only: {"model":"haiku|sonnet|opus","effort":"medium|high|xhigh|max","reason":"..."}`
12
13export const READ_SYSTEM = (maxQuestions: number) => `You triage a developer's prompt to Claude Code (an agentic coding assistant that reads and edits the code itself) before it runs. Answer with JSON only.
14
151. clear: true only when a competent developer could start right away with no decision left open: a precise bug report, a fully specified change, a plain question. A new feature, a redesign, or any request with several reasonable outcomes is NOT clear: ask about the decisions that would change the result. Never ask what Claude can find by reading the code or the project rules.
162. When not clear, up to ${maxQuestions} questions whose answers would most change the result (scope, expected outcome, constraints, approach, priorities), most important first. Each: "topic" (1-2 words, at most 12 characters), "question" (one short sentence ending in "?"), "options" (2 to 4 distinct answers of at most 48 characters, the likeliest first), "recommended" (index of the likeliest). Write them in the prompt's language.
173. ${POSTURE}
184. intent: what the prompt asks, in at most 6 words, in the prompt's language.
19
20{"clear":false,"intent":"...","model":"opus","effort":"high","reason":"...","questions":[{"topic":"Scope","question":"...?","options":["...","..."],"recommended":0}]}`
21
22const MODEL_HINT: Record<ModelName, string> = {
23 opus: 'Opus plans well on its own: give it the goal, the decisions already made, the constraints and how to verify; do not dictate steps it can work out.',
24 sonnet: 'Sonnet does best with a bounded task: name the area or files to touch and the exact expected result.',
25 haiku: 'Haiku needs one bounded task, explicit steps and the exact expected output.',
26}
27
28export const REWRITE_SYSTEM = (target: ModelName) => `You rewrite a developer's prompt to Claude Code (an agentic coding assistant that reads and edits the code itself) so it gets an excellent result on the first try. Fold in the answers the developer just gave.
29
30Rules:
31- Keep the developer's intent, facts, names, paths, code and language: write in the language of their prompt. Add no requirement they did not state or choose.
32- Claude already reads the project's rules (CLAUDE.md) and the code: never restate them. Mention a rule only when an answer decides something about it.
33- Every sentence must change the outcome: no role-play ("You are an expert"), no politeness, no filler, no restating the obvious. Shorter is better when nothing is lost.
34- Open with the goal as one imperative sentence. Then, only where they add something: the context Claude cannot find in the code; scope and constraints (what to touch, what to leave alone); the decisions made in the answers; how to tell it is done (behaviour to see, tests to pass). Use short bullets for several points; no headings for a small task.
35- ${MODEL_HINT[target]}
36- Then pick the model and effort for the rewritten prompt. ${'Opus is the default for real work; Sonnet for clearly routine work; Haiku for trivial work only; effort medium, high, xhigh or max, never low.'}
37
38Answer exactly in this shape, nothing before or after:
39<prompt>
40the rewritten prompt
41</prompt>
42PICK: <haiku|sonnet|opus> <medium|high|xhigh|max> | <2 to 5 words naming the task's kind>`
43
44export const RECAP_PROMPT = `Write a handoff note so a fresh Claude Code session can take over this work. Plain text, at most 12 lines: the goal, what is done (with file paths), decisions made and why, what is left, and anything that must not be redone or broken. No preamble.`
45types/index.d.ts 112 lines1export type ModelName = 'haiku' | 'sonnet' | 'opus'
2
3/** The four levels the chooser uses; `xhigh` is shown as "Extra". Low is never used. */
4export type Effort = 'medium' | 'high' | 'xhigh' | 'max'
5
6/** Who settled a pick: the local rules, a Haiku read, the upgrader's rewrite, the person, or the chooser keeping what runs. */
7export type PickSource = 'rules' | 'haiku' | 'upgrader' | 'you' | 'kept' | 'trusted'
8
9export type Pick = {
10 model: ModelName
11 effort: Effort
12 /** Two to five plain words: "multi-file feature". */
13 reason: string
14 by: PickSource
15 /** The kind of task the rules saw ("engineering", "routine"…); trust is kept per kind. */
16 kind?: string
17}
18
19/** How many times in a row the person kept the recommended pick for one kind of task. */
20export type TrustEntry = { streak: number; at: number }
21
22/** What one turn on a model and effort costs, in points of the 5-hour window (a moving average). */
23export type TurnCost = { avg: number; n: number }
24
25/** The 5-hour window's reading when a main turn started, and what it ran on. */
26export type TurnStartMark = { key: string; percent: number }
27
28export type UpgradeQuestion = {
29 id: string
30 /** A short chip: "Scope", "Done when". */
31 topic: string
32 question: string
33 options: string[]
34 /** Index of the option preselected. */
35 recommended: number
36}
37
38/** A question's answer: an option, or the person's own words (which win). */
39export type UpgradeAnswer = { choice?: number; text?: string }
40
41export type UpgradeStage = 'questions' | 'writing' | 'ready' | 'failed'
42
43/** What moving to another model mid-conversation costs: nothing to say, little, or a full re-read. */
44export type SwitchCost = {
45 kind: 'none' | 'cheap' | 'costly'
46 /** A sentence for the panel. */
47 note: string
48 /** A clause for the dialog. */
49 short: string
50 /** The conversation's size, "90k". */
51 tokens: string
52}
53
54/** One run of the prompt upgrader, drawn by its panel. */
55export type Upgrade = {
56 id: string
57 stage: UpgradeStage
58 original: string
59 /** What the prompt asks, in six words or fewer. */
60 intent: string
61 questions: UpgradeQuestion[]
62 answers: Record<string, UpgradeAnswer>
63 /** "Anything to add?" */
64 extra: string
65 improved: string
66 /** Earlier rewrites, newest last, so a tweak can be taken back. */
67 previous: string[]
68 pick: Pick
69 /** True once the person changed the model or effort in the panel: the rewrite then keeps it. */
70 pickTouched: boolean
71 /** The model/effort chips are open in the questions step. */
72 chipsOpen: boolean
73 /** Whether the model chooser is on: the panel then shows and applies the pick. */
74 chooser: boolean
75 /** What runs now in this conversation (null in a fresh one), and how much it holds. */
76 from: { model: ModelName; effort: Effort } | null
77 contextTokens: number
78 switch: SwitchCost
79 /** What went wrong while writing. */
80 error: string
81 /** A one-line confirmation after Copy. */
82 notice: string
83}
84
85/** A usage window as the band shows it. */
86export type UsageWindow = { kind: string; percent: number; resetsAt?: string }
87
88/** The prompt cache's state per model: when each last answered in this conversation. */
89export type CacheClock = Partial<Record<ModelName, number>>
90
91/** An improved prompt handed to the box to edit: the next prompt runs on its pick, with no questions. */
92export type Handoff = { pick: Pick; until: number }
93
94declare module 'claude-code' {
95 interface PluginState {
96 'prompt-pilot': {
97 /** What runs now: every main request of the session goes to it. */
98 active: Pick | null
99 /** The upgrader's panel, while one runs. */
100 upgrade: Upgrade | null
101 /** What the band says while a prompt is being read. */
102 busy: string
103 usage: UsageWindow[]
104 cache: CacheClock
105 handoff: Handoff | null
106 turnStart: TurnStartMark | null
107 chooserOn: boolean
108 upgraderOn: boolean
109 }
110 }
111}
112