SLOPSHOPPER

arne

Routes each prompt and subagent run to the cheapest model and effort that can handle it, and manages the cache, compaction and model switches around it.

newpanebandcommandtoaststatus
★ 1v0.4.1MITupdated 2026-10-22Tu2525/arne
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arne
│ ┃ arne-settings ✕ › fix the failing auth test and add an audit log call │ ┃ arne's settings and stats are in the band │ ┃ above the prompt: open it, then Settings. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /arne │ ⎿ arne: arne on, backend haiku. Subcommands: on, off, pin, unpin, │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ band band-cache │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ band band-cache │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · arne-settings
arne's settings and stats are in the band above the prompt: open it, then Settings.
Pane · arne-stats
arne's settings and stats are in the band above the prompt: open it, then Settings.
Pane · arne-log
No decisions yet.
README

arne

auto route and effort

Your typo fix doesn't need Opus. arne is a Claude Code plugin that sends each prompt to the cheapest model and effort that can handle it (Haiku 5.5, Sonnet 5.5 or Opus 5.5), and does the cache math before you switch or hand off a big chat.

In its bench, arne cost 53% less per task than running everything on Opus, and 35% less than effortless.

https://github.com/user-attachments/assets/20300835-8253-4130-91c0-946e7c5370c5

What it does

  • Routes every prompt. A small judge (Haiku by default) reads the prompt and picks a tier and effort. A rename goes to Haiku · low, and a design question goes to Opus · high.
  • Shows its work. The band above the prompt shows the model that's running and why it was picked. Every decision is logged with its cost to ~/.claude/arne/decisions.jsonl, which never leaves your machine.
  • Knows when switching costs more than staying. On a warm cache, moving a big chat to a cheaper model can cost more than it saves. arne prices staying, switching and a handoff side by side.
  • Hands off cleanly. Haiku summarizes the chat to your clipboard so you can continue in a fresh chat on a cheaper tier.
  • Stays out of the way. Pin a model whenever you want, and the pin lasts for that chat. /arne off turns routing off.

Screenshots

The band above the prompt shows the model that's running and why it was picked:

The arne band above the prompt

Open the band to see the cost of staying, switching or handing off, and to pin a model or effort:

arne panel comparing stay, switch and handoff costs

Settings, where you choose the judge and the cheapest and strongest model and effort arne may use:

arne settings

Stats, which estimates what arne saved (shown here with example data):

arne stats

Install

/plugin marketplace add tu2525/arne
/plugin install arne@arne

Restart Claude Code afterwards. If you use effortless, turn it off first, because arne replaces it.

Use

The band above the prompt is the control:

ControlWhat it does
● AutoTurns routing on or off, or resumes it after you pick a model yourself
model / effortPins a choice for this chat (Auto unpins)
whyShows the last decision, with 👍/👎 to rate it
cacheShows what a cold cache will cost
⚙Settings (also in /config)

/arne does the same from the prompt: on, off, pin haiku|sonnet|opus [low|medium|high], unpin, handoff, subagents auto|haiku|sonnet|opus|off, why, log, stats, backend haiku|heuristic|laya|jev, settings.

Handoff

Open it from the band's panel, from the cold-cache and heavy-chat alerts, or with /arne handoff [haiku|sonnet|opus]. Haiku summarizes the chat into arne-handoff.md and copies it to the clipboard. The Handoff setting decides what happens next:

  • New chat (default): you open a new chat on the tier you picked and paste the summary. The old chat stays as it is. Plugins can't open a chat or paste into one for you.
  • Clear: arne runs /clear, switches tier and sends the summary as the first message. The old chat is saved to arne-archive/ first.

Once a chat passes 30k tokens, the panel shows one line per cheaper tier: what a handoff costs now, and how many turns until it pays off.

Stats

Open it from the band's panel (Settings → Stats) or with /arne stats. It shows an estimate of what arne saved today, over the last 7 days and in total. To get it, each turn's real tokens are priced again as if the turn had run on Opus at medium effort. The figure can't be exact, because a different model would have written a different answer. Model switches and summaries are counted against arne.

Subagents (experimental)

This is off by default. When it's on, a subagent gets its model when it starts. The model is at medium effort or more, and never dearer than the chat that sent it or the Subagents ceiling in Settings. If the call asks for a model under the ceiling, that model runs. A dearer one is capped, and the Agent call says so (for example, "arne: Sonnet (capped from Opus)"). Forks, teammates and workflow agents are always left alone.

Judges

BackendWhere it runs
haiku (default)Your Claude session
heuristicLocally, with no model call
layaA local laya-serve
jevTypeSafe's hosted judge. Your prompts leave your machine. You need a key from console.typesafe.ai/keys, set in /config (kept in secure storage) or in TYPESAFE_API_KEY

Known limit

Plugins can't move the desktop app's model menu. arne switches with the real /model, but the menu can keep showing your last pick. The band always shows the model that's actually running.

Bench

Latest results: bench/agentic/FINDINGS.md. It covers 5 reps per task, a 5-prompt chat test, pinned and effortless setups, the method, every test prompt and how it's checked, and how to send us your own numbers. Short version: arne cost $0.047 per task against $0.228 on Opus, and $0.021 per chat against $0.599, at about the same pass rate. The first run, below, is kept for history.

These are effortless's own bench/agentic tasks (MIT): four real coding tasks on effortless's source. They find a threshold, add JSDoc without touching code, write and run a test-counting script, and explain an update flow. Every run started on Opus · medium. There were three setups, with 2 runs per task each, through claude -p --setting-sources project, which keeps user plugins out.

SetupRunsCorrectCorrect, harness denials excludedAPI-equivalent costPer taskAvg requestsAvg time
Opus medium, no mods888$1.71$0.2147.430 s
effortless868$1.23$0.1537.128 s
arne867$0.80$0.1007.929 s

arne bench: 53% cheaper per task

  • Cost is total_cost_usd from each run, which is the list-price equivalent. It isn't what a Claude plan bills.
  • Harness denials: in 3 runs (effortless 2, arne 1), the session refused node tools/count-tests.mjs even though it was allowed. The agent wrote the script but couldn't run it.
  • Where arne missed: arne sent the JSDoc task to Haiku · low, and once Haiku skipped an export. Raising the floor for multi-edit tasks should fix it.
  • Sample size: 2 runs per setup is small. To run it yourself: bash bench/agentic/run.sh 2, then node bench/agentic/score.cjs.

Credits

arne replaces effortless by HeyCubit (MIT), and reuses its cost formulas and bench tasks.

License

MIT, see LICENSE.

Source 7 files
hooks/register.tsx 1829 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { JEV_MODEL, JEV_URL, LAYA_URL, parseVerdict, SYSTEM, SYSTEM_ONE_TIMEOUT_MS, systemOneBody, systemOneVerdict } from './backends'
5import { bandText, CACHE_TTL, clampEffort, clampTier, coldAdvice, contextOf, costCompact, costContinue, dollars, EFFORT_LABEL, EFFORT_LABEL_SHORT, EFFORT_RANK, fillOf, fitRest, fitTier, midTask, mmss, nextTtl, overTurns, paysOffAfter, plainReason, planSwitch, shortModel, summaryPrompt, tierOf, PRICE, OUT, READ } from './econ'
6import type { BandState, Row as CostRow } from './econ'
7import { MODEL, failedAttempt, route } from './route'
8import { VERSION } from './version'
9import type { Backend, Decision, Tier } from './route'
10import { parseUsage, periodStart, PERIODS, savedText, summarize, usageRow } from './stats'
11import type { Period, UsageRow } from './stats'
12import type { Cache, Row } from '../types'
13
14const LOG_FILE = '.claude/arne/decisions.jsonl' // under the home directory; local only, never shared
15
16// A path under the home directory, read at runtime; an absolute path (the bench sets one) stays as it is.
17async function homePath($: any, p: string): Promise<string> {
18  if (/^([A-Za-z]:)?\//.test(p)) return p
19  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
20  return home ? `${home.replace(/\\/g, '/').replace(/\/$/, '')}/${p}` : p
21}
22
23const PANE = 'arne-log'
24const SETTINGS = 'arne-settings'
25const STATS = 'arne-stats'
26
27const TIER_OF: Record<string, Tier> = { haiku: 'S', sonnet: 'M', opus: 'L' }
28
29const last = atom({ plugin: 'arne', key: 'last' } as const, null)
30const log = atom({ plugin: 'arne', key: 'log' } as const, [])
31const cache = atom({ plugin: 'arne', key: 'cache' } as const, null)
32const clockNow = atom({ plugin: 'arne', key: 'now' } as const, 0)
33const coldDismissed = atom({ plugin: 'arne', key: 'coldDismissed' } as const, 0)
34const routing = atom({ plugin: 'arne', key: 'routing' } as const, false)
35const isOff = atom({ plugin: 'arne', key: 'isOff' } as const, false)
36// True after the person picked a model by hand (/model or the picker). One of the two inputs to mode().
37const isPaused = atom({ plugin: 'arne', key: 'isPaused' } as const, false)
38// The effort the app sends when arne leaves it alone: what the band shows while paused or off.
39const appEffort = atom({ plugin: 'arne', key: 'appEffort' } as const, null)
40// The session's real model, as state so the band redraws when it changes ($.session.model() alone would not).
41const sessionModel = atom({ plugin: 'arne', key: 'sessionModel' } as const, null)
42const held = atom({ plugin: 'arne', key: 'held' } as const, null)
43// True while effortless is enabled: it writes effort, so arne routes models only.
44const effortless = atom({ plugin: 'arne', key: 'effortless' } as const, false)
45const EFFORTLESS = 'effortless@effortless'
46const SETTINGS_FILE = '.claude/settings.json' // under the home directory, same as LOG_FILE
47const shown = atom({ plugin: 'arne', key: 'shown' } as const, null)
48// True while the band's panel is open (model, effort, why, settings).
49const whyOpen = atom({ plugin: 'arne', key: 'whyOpen' } as const, false)
50// The decision key the person just rated; the panel then says it logged the rating.
51const thanked = atom({ plugin: 'arne', key: 'thanked' } as const, null as string | null)
52// True while Laya is the judge but laya-serve is not answering; the why line then says Haiku judged.
53const layaDown = atom({ plugin: 'arne', key: 'layaDown' } as const, false)
54// True while Jev is the judge but no TypeSafe key is found (17s); the why line then says Haiku judged.
55const jevNoKey = atom({ plugin: 'arne', key: 'jevNoKey' } as const, false)
56// The typesafe_key option (sensitive: secure storage, never settings.json). Kept here only, never in an atom,
57// a log row or on screen.
58let optionKey = ''
59// The TypeSafe key: TYPESAFE_API_KEY first, then the option.
60const typesafeKey = async ($: any): Promise<string | undefined> => (await Promise.resolve($.env.get('TYPESAFE_API_KEY')).catch(() => undefined)) || optionKey || undefined
61const ADD_KEY_HINT = 'Set it in /config → arne → TypeSafe API key, or the TYPESAFE_API_KEY environment variable.'
62// What the desktop app's model picker shows: the session's first model, then each pick made in it
63// (PostModelSwitch source "sdk"). Plugins cannot move that picker, so arne's /model can differ from it.
64const appPicker = atom({ plugin: 'arne', key: 'appPicker' } as const, null)
65// The Stats pane's period (17q).
66const statsPeriod = atom({ plugin: 'arne', key: 'statsPeriod' } as const, 'week')
67// 17u. Every own config write reloads the plugin and wipes module state, so what a press must show at
68// once, or what has to outlive that reload, lives here.
69// Bumped on every option change: the Settings pane reads it, since `live` alone draws nothing again.
70const liveRev = atom({ plugin: 'arne', key: 'liveRev' } as const, 0)
71// Bumped after a pin's store write: the band reads it, since a store read does not subscribe.
72const pinRev = atom({ plugin: 'arne', key: 'pinRev' } as const, 0)
73// The cold spell (its warmUntil) whose heavy alert the person dismissed, as coldDismissed.
74const heavyDismissed = atom({ plugin: 'arne', key: 'heavyDismissed' } as const, 0)
75// A compact arne started is running: its buttons give way to "summarizing…", and a second press does nothing.
76const compacting = atom({ plugin: 'arne', key: 'compacting' } as const, false)
77// `claude plugin disable effortless` is running (up to 60 s): its button is hidden meanwhile.
78const disabling = atom({ plugin: 'arne', key: 'disabling' } as const, false)
79// The main turn's decision, for its steps.
80const currentAtom = atom({ plugin: 'arne', key: 'current' } as const, null)
81// The session model arne expects: the one it last switched to, or last saw. A switch to another tier is the person's.
82const expectedAtom = atom({ plugin: 'arne', key: 'expected' } as const, null)
83// A Summarize, then send/continue is running: its compaction must not also send "Continue".
84const compactSendingAtom = atom({ plugin: 'arne', key: 'compactSending' } as const, false)
85// The tier the last handoff went to, for the panel's note; the "Handoff…" item's tier list is open (17w).
86const handedOff = atom({ plugin: 'arne', key: 'handedOff' } as const, null)
87const handoffMenu = atom({ plugin: 'arne', key: 'handoffMenu' } as const, false)
88// This chat's subagents (17x): how many arne moved to each tier, kept on their model, ran as asked, or capped (17y).
89const subCount = atom({ plugin: 'arne', key: 'subCount' } as const, null)
90type SubCount = Partial<Record<Tier | 'kept' | 'asked' | 'capped', number>>
91const countSpawn = ($: any, k: keyof SubCount) => update($, subCount, (c: SubCount | null) => ({ ...c, [k]: ((c ?? {})[k] ?? 0) + 1 }))
92// The Stats pane's three periods, parsed from the log once per open rather than on every press.
93const statsData = atom({ plugin: 'arne', key: 'stats' } as const, null)
94// What the band's panel shows: its main rows, Settings or Stats. In the app a Pane lost the first press
95// of every button, so Settings and Stats moved into the band, whose buttons answer at once.
96type View = 'main' | 'settings' | 'stats'
97const panelView = atom({ plugin: 'arne', key: 'panelView' } as const, 'main')
98const TIER_NAME_OF: Record<Tier, string> = { S: 'haiku', M: 'sonnet', L: 'opus' }
99const STATES: BandState[] = ['routing', 'off', 'paused', 'cold', 'heavy', 'normal']
100
101// /compact runs as the app's own command, so the idle wait and the app's own summary path apply.
102async function effortlessOn($: any): Promise<boolean> {
103  const text: string = await $.fs.read(await homePath($, SETTINGS_FILE)).catch(() => '')
104  try {
105    return JSON.parse(text).enabledPlugins?.[EFFORTLESS] === true
106  } catch {
107    return false
108  }
109}
110
111// The state flips only at the next session start: effortless's hooks stay loaded until then.
112async function disableEffortless($: any) {
113  if (await read($, disabling)) return
114  await update($, disabling, () => true)
115  const r = await $.process.run(['claude', 'plugin', 'disable', EFFORTLESS], { timeoutMs: 60_000 }).catch(() => undefined)
116  await update($, disabling, () => false)
117  $.ui.toast(r?.exitCode === 0 ? 'Effortless disabled. Restart to hand effort to arne.' : 'Could not disable effortless; disable it from the plugin list.')
118}
119
120// One compact at a time: the alerts show "summarizing…" while it runs, and a second press returns false.
121async function compactNow($: any, note: string): Promise<boolean> {
122  if (await read($, compacting)) return false
123  await update($, compacting, () => true)
124  try {
125    await $.command.run({ command: 'compact', args: note.trim() })
126    return true
127  } catch {
128    $.ui.toast('Compact failed; try again when the session is idle')
129    return false
130  } finally {
131    await update($, compacting, () => false)
132  }
133}
134
135// Card colours. On a dark theme the prompt box's outline colour, so arne's cards match it. No fill: it paints a square box behind the rounded border; any other theme keeps its keys.
136let CARD: { border: string } = { border: 'promptBorder' }
137let cardSynced = false
138async function syncCard($: any) {
139  if (cardSynced) return
140  const row = (await $.config.list().catch(() => [])).find((r: any) => r.key === 'theme')
141  if (!row) return
142  cardSynced = true
143  if (String(row.value).startsWith('dark')) CARD = { border: '#4d4d4c' }
144}
145
146// --- Handoff (17o, 17w) ---
147// Haiku summarizes the chat into arne-handoff.md and the clipboard. Setting Handoff = newchat (default): a
148// toast asks the person to open a new chat on `tier` and paste; no plugin call opens a chat or pastes into
149// one. Handoff = clear: /clear starts a fresh chat in place, pinned to `tier`, whose first message is the
150// summary. One at a time (the compacting atom).
151const HANDOFF_FILE = 'arne-handoff.md'
152const ARCHIVE_DIR = 'arne-archive'
153const SUMMARY_SYSTEM = 'Summarize this coding conversation for its next turn: goal, decisions and why, files changed (paths), exact errors, open questions, next step. Plain text, under 1500 words.'
154
155// Set before the first await, so two presses in the same tick still run one handoff; the atom draws "summarizing…".
156let handingOff = false
157async function handoff($: any, tier: Tier, surface?: string) {
158  if (handingOff) return
159  handingOff = true
160  if (await read($, compacting)) return void (handingOff = false)
161  await update($, compacting, () => true)
162  let step = 'the summary'
163  try {
164    const msgs: any = await $.session.messages()
165    const said = (Array.isArray(msgs) ? msgs : []).filter((m: any) => m.text?.trim())
166    if (said.length === 0) throw new Error('empty chat')
167    const r = await $.model.complete(
168      { model: live.compactModel, prompt: summaryPrompt(said), system: SUMMARY_SYSTEM, effort: 'low' },
169      { timeoutMs: 120_000 },
170    )
171    if (!r.isAnswered || !r.text) throw new Error('no summary')
172    const sum = usageRow('summary', await $.clock.now(), r.usage, MODEL[TIER_OF[live.compactModel] ?? 'S'])
173    if (sum) await appendLog($, sum)
174    const text = `# Handoff
175
176Summary of an earlier chat (written by ${shortModel(live.compactModel)} for arne):
177
178${r.text}
179`
180    await $.fs.write(HANDOFF_FILE, text).catch(() => undefined)
181    step = 'the clipboard'
182    const copied = await $.ui.copy({ text, surface }).catch(() => undefined)
183    if (copied?.isCopied === false) throw new Error('copy')
184    if (live.handoff !== 'clear') {
185      // No plugin call opens a new chat or pastes into one, so the old chat is left alone.
186      await update($, handedOff, () => tier)
187      $.ui.toast(`Handoff copied. Open a new chat on ${shortModel(MODEL[tier])} and paste. This chat is untouched.`)
188      return
189    }
190    // The whole old chat, kept as a file before /clear: the sidebar history alone is not a copy.
191    const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
192    const transcript = said.map((m: any) => `## ${m.role ?? 'message'}\n\n${m.text}`).join('\n\n')
193    await $.fs.write(`${ARCHIVE_DIR}/handoff-${stamp}.md`, `# Chat before handoff\n\n${transcript}\n`).catch(() => undefined)
194    step = '/clear'
195    await $.command.run({ command: 'clear', args: '' })
196    await update($, cache, () => null) // the new chat has no cache yet
197    await update($, subCount, () => null)
198    step = '/model'
199    // Pinned after the clear: a pin belongs to the chat it was set in.
200    await setPinPart($, { tier }, false)
201    if (tierOf(await $.session.model()) !== tier) await moveModel($, MODEL[tier])
202    if (tierOf(await $.session.model()) !== tier) throw new Error('model')
203    step = 'sending the summary'
204    await $.prompt.submit({ text, asUser: true })
205    await update($, handedOff, () => tier)
206    $.ui.toast(`Handed off to ${shortModel(MODEL[tier])}. Old chat saved in history; archive it from the sidebar when you are done.`)
207  } catch {
208    $.ui.toast(`Handoff stopped at ${step}.${step === 'the summary' ? '' : ` The summary is on your clipboard and in ${HANDOFF_FILE}.`}`)
209  } finally {
210    handingOff = false
211    await update($, compacting, () => false)
212    await update($, handoffMenu, () => false)
213  }
214}
215
216// --- The held prompt (step 17a) ---
217// A prompt typed on a cold, big chat waits here until the person picks Compact & send or Send anyway.
218const CONTINUE_TEXT = 'Continue where you left off.'
219// Messages a compact keeps word for word after the summary, and the judge's card reads (17v).
220const KEEP_AFTER_COMPACT = 10
221let lastReason: string | undefined // how the last main turn ended
222
223// Sends a prompt as the person's own words. A plugin's own $.prompt.submit skips that plugin's
224// prompt.submit hooks (seen in the test kit), so it is never held again and never routed there.
225async function sendAsUser($: any, text: string) {
226  try {
227    await $.prompt.submit({ text, asUser: true })
228  } catch {
229    $.ui.toast('Could not send your message; it is back in the prompt box')
230    await $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined)
231  }
232}
233
234async function compactAndSend($: any) {
235  const text = await read($, held)
236  if (!text || (await read($, compactSendingAtom)) || (await read($, compacting))) return
237  await update($, held, () => null) // cleared first: a second click finds nothing to send
238  await update($, compactSendingAtom, () => true)
239  const ok = await compactNow($, '').finally(() => update($, compactSendingAtom, () => false))
240  if (ok) await routeAndSend($, text)
241  else await update($, held, () => text)
242}
243
244// Heavy banner: nothing is held (the prompt was never blocked), so after compacting arne sends "Continue".
245async function compactThenContinue($: any) {
246  if ((await read($, compactSendingAtom)) || (await read($, compacting))) return
247  await update($, compactSendingAtom, () => true)
248  const ok = await compactNow($, '').finally(() => update($, compactSendingAtom, () => false))
249  if (ok) await routeAndSend($, CONTINUE_TEXT)
250}
251
252async function sendAnyway($: any) {
253  const text = await read($, held)
254  if (!text) return
255  await update($, held, () => null)
256  await routeAndSend($, text)
257}
258
259
260// Module state. Reset on a reload, like every module variable (what must survive one is in atoms, 17u).
261let prev: Decision | undefined
262// After an escalation, the next HOLD_TURNS prompts stay at least on its tier, so a judge that read the retry
263// as small does not drop straight back to the model that failed (claude-router holds 2 turns too).
264const HOLD_TURNS = 2
265let hold: { tier: Tier; left: number } | undefined
266let failures = 0 // failed attempts reported at the current tier; the person's retry is not a new tier until live.attempts of them
267const RANK: Record<Tier, number> = { S: 0, M: 1, L: 2 }
268let switching = false // arne's own /model is queued or running: the session model may still be the old one
269let lastResponseAt: number | undefined
270let arneSwitched = false // arne's own /model ran: the next main turn's cache writes are the switch's (17q)
271// Decisions made at prompt.submit, by text, for the turn.start of the same prompt.
272const planned = new Map<string, Decision>()
273// Subagent decisions, by agentId, so each step of a run uses the effort picked at its spawn (17x).
274const agents = new Map<string, Decision | undefined>()
275// Picks whose spawn has not resolved yet: the subagent's first request can arrive before its agentId does.
276const pendingPicks: Decision[] = []
277// A step of an agent with no pick yet claims the oldest pending pick on its model (effortless's helperEffortOf).
278function agentPick(agentId: string, model: string): Decision | undefined {
279  if (agents.has(agentId)) return agents.get(agentId)
280  const i = pendingPicks.findIndex(d => tierOf(model) === d.tier)
281  if (i < 0) return undefined
282  const [d] = pendingPicks.splice(i, 1)
283  agents.set(agentId, d)
284  return d
285}
286
287// The band's pickers pin a tier, an effort, or both; the part left out is arne's to decide.
288type Pin = { tier?: Tier; effort?: Decision['effort'] }
289type Settings = { enabled: boolean; pin: Pin | null; backend: string }
290
291// The userConfig values, as the engine holds them. arne's own writes (the band, Settings, /arne) go
292// through setOption, which updates this copy itself: the app never shows a plugin its own config.set.
293// A /config write lands in the config.set hook below. Either write reloads arne, and register reads them again.
294type Live = {
295  auto: boolean
296  backend: string
297  modelFloor: string
298  modelCeiling: string
299  effortFloor: string
300  effortCeiling: string
301  compactModel: string
302  coldTokens: number
303  attempts: number // failed attempts at a tier before a retry goes one up
304  heavyTokens: number
305  continueAfterCompact: boolean
306  subagents: string // 17y: the dearest model a subagent may run on (haiku, sonnet, opus), or off
307  handoff: string // newchat: copy the summary and ask for a new chat; clear: /clear this chat and send it
308}
309const DEFAULTS: Live = {
310  auto: true,
311  backend: 'haiku',
312  modelFloor: 'haiku',
313  modelCeiling: 'sonnet', // the judge stops here; only a failed attempt goes on to Opus
314  effortFloor: 'low',
315  effortCeiling: 'high',
316  compactModel: 'haiku',
317  coldTokens: 30_000,
318  attempts: 1, // measured: 3 retries in a row talked a passing Sonnet answer into failing (0/3 vs 3/3)
319  heavyTokens: 150_000,
320  continueAfterCompact: true,
321  subagents: 'sonnet', // a subagent runs on Sonnet at most
322  handoff: 'newchat',
323}
324// 17y: Subagents was On/Off before it became a ceiling; On reads as Sonnet, Off as off. Auto is no ceiling (Opus).
325const subCeiling = (v: unknown) => (v === false || v === 'off' ? 'off' : v === 'auto' || TIER_OF[String(v)] ? String(v) : v === true ? 'sonnet' : 'auto')
326let live: Live = { ...DEFAULTS }
327const FIELDS = Object.keys(DEFAULTS) as (keyof Live)[]
328
329// The cold offer applies: the chat model's cache is cold, the chat is past coldTokens, and this cold
330// spell was not dismissed. A compact clears the cache atom, so there is no offer until the next response.
331async function coldOffer($: any, now: number) {
332  const c: Cache | null = await read($, cache)
333  if (!c) return undefined
334  const until = c.warmUntil[c.model] ?? 0
335  if (until > now || c.C < live.coldTokens) return undefined
336  if ((await read($, coldDismissed)) === until) return undefined
337  // Only when summarizing first is cheaper than the plain resend (17p): otherwise there is nothing to offer.
338  const advice = coldAdvice(c.C, c.model, c.warmUntil, now)
339  const resend = advice.rows.find(r => r.action === 'continue')!.first
340  const summary = advice.rows.find(r => r.action === 'compact')!.first
341  return summary < resend ? { c, ...advice } : undefined
342}
343
344// Where a handoff goes when no tier is named: the tier the judge last wanted, else the chat's own.
345async function handoffTier($: any): Promise<Tier> {
346  const l = await read($, last)
347  return tierOf(l?.waitFor ?? l?.model ?? '') ?? tierOf(await $.session.model()) ?? 'M'
348}
349
350// The one on/off answer every surface reads: the /arne reply, the band, the status line and the pane.
351type Mode = 'off' | 'paused' | 'on'
352// Reads atoms, not `live`, so every surface that asks redraws when the answer changes.
353async function modeOf($: any): Promise<Mode> {
354  return (await read($, isOff)) ? 'off' : (await read($, isPaused)) ? 'paused' : 'on'
355}
356
357// Clears a pause: the session's model is now the one arne expects.
358async function resume($: any) {
359  await update($, isPaused, () => false)
360  const model: string = await $.session.model()
361  await update($, expectedAtom, () => model)
362}
363
364// arne's own config writes while they run, by key. The app never shows them to arne's config.set hook
365// (index.d.ts, $.config.set); the test kit does, so the hook skips these keys to match the app.
366const ownWrites = new Set<string>()
367async function setOnce($: any, key: string, value: unknown): Promise<{ value: unknown } | { deny: string }> {
368  ownWrites.add(key)
369  try {
370    return await $.config.set({ key, value })
371  } catch (err) {
372    return { deny: String((err as Error)?.message ?? err) }
373  } finally {
374    ownWrites.delete(key)
375  }
376}
377// With two arne plugins enabled (a stale cached copy and a dev one), the engine keys rows
378// `arne@<marketplace>.<field>`, and `arne.<field>` names no row: find arne's own row and write that.
379async function ownSet($: any, key: string, value: unknown): Promise<{ value: unknown } | { deny: string }> {
380  const r = await setOnce($, key, value)
381  if (!('deny' in r) || !r.deny.includes('no /config row')) return r
382  const field = key.slice(key.indexOf('.') + 1)
383  const rows: { key: string; provider?: unknown }[] = await $.config.list().catch(() => [])
384  const named = rows.filter(row => row.key.endsWith(`.${field}`) || row.key === field)
385  // The provider's shape isn't documented: any arne in the key or the provider object marks arne's row.
386  const mine = named.filter(row => JSON.stringify([row.key, row.provider ?? null]).includes('arne'))
387  if (mine.length > 1) return { deny: 'two copies of arne are enabled; turn one off in /plugin' }
388  if (mine.length) return mine[0]!.key !== key ? setOnce($, mine[0]!.key, value) : r
389  // The engine lists no row for this option at all: nothing can persist it, but the surfaces already hold
390  // the value, so it applies for this session instead of rolling back with an error.
391  if (!named.length) return { value }
392  // Name the rows it saw, so a toast says what the engine calls this option.
393  return { deny: `${r.deny.replace(/\)$/, '')}; rows ending .${field}: ${named.map(row => row.key).join(', ') || 'none'})` }
394}
395
396// Puts one option into `live` and onto every surface: the band (isOff), the Settings pane (liveRev), the status line.
397async function applyLive($: any, field: keyof Live, value: unknown) {
398  ;(live as Record<string, unknown>)[field] = field === 'subagents' ? subCeiling(value) : value
399  await update($, liveRev, n => n + 1)
400  if (field === 'auto') {
401    await update($, isOff, () => !value)
402    // Turning it on, from /config, the band, Settings or /arne on, is the person's go-ahead: clear any pause.
403    if (value) await resume($)
404    await showStatus($)
405  }
406}
407
408// The one writer for arne's options (17u): the surfaces change first, then the config write. A refused
409// write puts the old value back and says why.
410async function setOption($: any, field: keyof Live, value: unknown) {
411  const was = live[field]
412  await applyLive($, field, value)
413  const r = await ownSet($, `arne.${field}`, value)
414  if ('deny' in r) {
415    await applyLive($, field, was)
416    // The engine's own "<plugin>: $.config.set: " prefix: the toast already shows arne's name.
417    $.ui.toast(`Could not change ${field}: ${r.deny.replace(/^[^:]*: \$\.config\.set: /, '')}`)
418  }
419}
420
421const setAuto = ($: any, on: boolean) => setOption($, 'auto', on)
422
423// The band's Auto button: on turns off, off turns on. A pause resumes without a config write.
424async function toggleAuto($: any) {
425  const mode = await modeOf($)
426  if (mode !== 'paused') return setAuto($, mode === 'off')
427  await resume($)
428  await showStatus($)
429}
430
431// Sets one part of the pin and keeps the other; a pin with neither part left is removed. A pin ends a pause.
432// The band redraws first; a model pin then moves the real /model on the next tick, unless the pick
433// already moved it (the app's own picker). The tick also lets /arne pin run it: a /model run from inside
434// a command's own hook is refused.
435async function setPinPart($: any, part: Pin, move = true) {
436  const next: Pin = { ...((await settings($)).pin ?? {}), ...part }
437  if (next.tier === undefined) delete next.tier
438  if (next.effort === undefined) delete next.effort
439  if (next.tier || next.effort) await $.store.set(await pinKey($), next)
440  else await $.store.delete(await pinKey($))
441  await update($, pinRev, n => n + 1)
442  await resume($)
443  await showStatus($)
444  if (move && part.tier && tierOf(await $.session.model()) !== part.tier) {
445    const target = MODEL[part.tier]
446    $.clock.after(0, () => moveModel($, target))
447  }
448}
449
450// The real /model moves now, marked as arne's own so PostModelSwitch does not pause arne.
451async function moveModel($: any, target: string) {
452  const was = await read($, expectedAtom)
453  await update($, expectedAtom, () => target)
454  switching = true
455  try {
456    await $.command.run({ command: 'model', args: target })
457    arneSwitched = true
458  } catch {
459    await update($, expectedAtom, () => was)
460    $.ui.toast(`Could not switch /model to ${shortModel(target)}`)
461  } finally {
462    switching = false
463  }
464}
465
466// Good call / Wrong call on the last decision: a correction label for the classifier, written beside the log rows.
467async function feedback($: any, verdict: 'up' | 'down') {
468  const l = await read($, last)
469  if (!l) return
470  // Thanked first, so the buttons give way on the press; a failed write brings them back.
471  const was = await read($, thanked)
472  await update($, thanked, () => l.key)
473  try {
474    const old: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
475    await $.fs.write(await homePath($, LOG_FILE), old + JSON.stringify({ type: 'feedback', key: l.key, verdict, model: l.model, effort: l.effort, reason: l.reason, source: l.source }) + '\n')
476  } catch {
477    await update($, thanked, () => was)
478    $.ui.toast('Could not log that feedback')
479  }
480}
481
482async function showStatus($: any) {
483  $.ui.status(statusOf(await modeOf($), await $.session.model()))
484}
485
486// The status line: the footer already names the plugin, so no "arne" here.
487const statusOf = (mode: Mode, model: string) =>
488  mode === 'on' ? undefined : bandText({ off: mode === 'off', paused: mode === 'paused', routing: false, model, warmLeftMs: 0, C: 0 }).short
489
490// $.store is one JSON file for every session, so a pin is keyed by its chat: a new chat starts on Auto (17p).
491// ponytail: one key per pinned chat is never cleaned up; prune old keys if the store ever nears its 4 MiB cap.
492async function pinKey($: any): Promise<string> {
493  return `pin:${await $.session.id().catch(() => '')}`
494}
495
496async function settings($: any): Promise<Settings> {
497  return {
498    enabled: live.auto,
499    pin: ((await $.store.get(await pinKey($))) ?? null) as Pin | null,
500    backend: live.backend,
501  }
502}
503
504// laya-serve is started once per session when it is not already answering.
505// Sessions share the one server on 127.0.0.1:8000; a second start just fails to bind.
506let layaUp = false
507let layaStarting = false
508
509async function layaHealthy($: any): Promise<boolean> {
510  try {
511    const r = await $.http.fetch(`${LAYA_URL}/health`, { method: 'GET' })
512    return r.ok
513  } catch {
514    return false
515  }
516}
517
518// Not awaited: a spawn streams for as long as the server runs. Without laya-serve
519// on PATH the spawn errors and the chain falls back to haiku.
520async function startLaya($: any) {
521  if (layaStarting) return
522  layaStarting = true
523  try {
524    for await (const _ of $.process.spawn({ argv: ['laya-serve'] })) {
525      // output is not read; the server logs go to its own console
526    }
527  } catch {
528    // laya not installed, or the server exited: the why line says Haiku judged until it answers
529    $.ui.toast('laya-serve did not start (not on PATH?). Haiku judges until it does.')
530  } finally {
531    layaStarting = false
532  }
533}
534
535// laya -> haiku -> prev -> default. The chain falls through silently.
536// What the project is, so Haiku judges "how hard is this prompt" for this codebase, not in a vacuum.
537// Read once per load from CLAUDE.md, else README.md, in the project folder. ponytail: stale until reload.
538let projectCtx: Promise<string> | undefined
539function projectContext($: any): Promise<string> {
540  projectCtx ??= (async () => {
541    for (const f of ['CLAUDE.md', 'README.md']) {
542      const t: string = await $.fs.read(f).catch(() => '')
543      if (t.trim()) return `\n\nProject context (${f}, for judging difficulty; do not answer it):\n${t.slice(0, 2500)}`
544    }
545    return ''
546  })()
547  return projectCtx
548}
549
550// The judge's view of the task so far (17p), so "continue", "do it" or "B" are read against the work in hand.
551// Built from the transcript each time: the first prompt, the last 10 messages from both sides (17v), the
552// last verdict, files touched, the context size. At most CARD_MAX characters (~2k tokens). '' when the
553// transcript cannot be read.
554const CARD_MAX = 8000
555async function taskCard($: any, c: Cache | null): Promise<string> {
556  const msgs: any = await $.session.messages().catch(() => [])
557  if (!Array.isArray(msgs) || msgs.length === 0) return ''
558  // Rows with words only: a user row that carries tool results is the engine's, not the person's.
559  const said = msgs.filter((m: any) => m.text?.trim() && !m.toolResults?.length)
560  const prompts = said.filter((m: any) => m.role === 'user').map((m: any) => m.text.trim())
561  const recent = said.slice(-KEEP_AFTER_COMPACT)
562  const files = new Set<string>()
563  for (const m of msgs) for (const u of m.toolUses ?? []) {
564    const f = u.input?.file_path ?? u.input?.notebook_path ?? u.input?.path
565    if (typeof f === 'string') files.add(f)
566  }
567  const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
568  const lines = [
569    prompts[0] && `First prompt: ${clip(prompts[0], 1200)}`,
570    recent.length > 1 && `Last messages:\n${recent.map((m: any) => `- ${m.role}: ${clip(m.text.trim(), 500)}`).join('\n')}`,
571    // No previous verdict here: the judge anchored on it and kept repeating Opus ("aight go on" → Opus).
572    files.size && `Files touched: ${[...files].slice(-15).join(', ')}`,
573    c && `Context: ${Math.round(c.C / 1000)}k tokens`,
574  ].filter(Boolean)
575  return lines.length ? clip(`Conversation so far:\n${lines.join('\n')}`, CARD_MAX) : ''
576}
577
578function backendsOf($: any, backend: string, card = ''): Backend[] {
579  const haiku: Backend = {
580    name: 'haiku',
581    ask: async text => {
582      const prompt = card ? `${card}\n\nNew prompt to judge:\n${text.slice(0, 4000)}` : text.slice(0, 4000)
583      const r = await $.model.complete(
584        { model: 'haiku', prompt, system: SYSTEM + (await projectContext($)), effort: 'low' },
585        { timeoutMs: 3000 },
586      )
587      return r.isAnswered ? parseVerdict(r.text) : undefined
588    },
589  }
590  // System One (17s): laya-serve and Jev take the same request with the task card in its state.
591  // No answer within 1.5 s falls through to Haiku.
592  const systemOne = async (url: string, headers: Record<string, string>, text: string, model?: string) => {
593    const r = await Promise.race([
594      $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: systemOneBody(text, card, model) }),
595      $.clock.sleep(SYSTEM_ONE_TIMEOUT_MS).then(() => undefined),
596    ])
597    return r?.ok ? JSON.parse(r.text) : undefined
598  }
599  const laya: Backend = {
600    name: 'laya',
601    ask: async text => systemOneVerdict((await systemOne(`${LAYA_URL}/v1/systemone`, {}, text))?.answers, 'laya'),
602  }
603  const jev: Backend = {
604    name: 'jev',
605    ask: async text => {
606      // Never stored, logged or shown. No key, no request.
607      const key = await typesafeKey($)
608      if (!key) return undefined
609      const j = await systemOne(JEV_URL, { authorization: `Bearer ${key}` }, text, JEV_MODEL)
610      // Jev's prices are not published, so Stats counts its tokens on a row of their own.
611      const u = j?.usage
612      if (u) await appendLog($, { type: 'usage', at: await $.clock.now(), kind: 'jev', model: JEV_MODEL, input: u.input_tokens ?? 0, read: 0, write: 0, out: u.output_tokens ?? 0 })
613      return systemOneVerdict(j?.answers, 'jev')
614    },
615  }
616  return backend === 'laya' ? [laya, haiku] : backend === 'jev' ? [jev, haiku] : backend === 'haiku' ? [haiku] : []
617}
618
619// The main loop's decision for one prompt, inside the floor and ceiling (pins included).
620async function decideMain($: any, text: string): Promise<Decision | undefined> {
621  const s = await settings($)
622  if (!s.enabled) return undefined
623  if (s.backend === 'laya' && !layaUp) {
624    layaUp = await layaHealthy($)
625    if (!layaUp) void startLaya($)
626  }
627  // The band says so when Laya was picked but is not answering: the chain judges with Haiku instead.
628  await update($, layaDown, () => s.backend === 'laya' && !layaUp)
629  const jevKeyless = s.backend === 'jev' && !(await typesafeKey($))
630  await update($, jevNoKey, () => jevKeyless)
631  // Any pin pins both parts and skips routing (17p): the part it leaves out stays what the session has now.
632  // A pin is the person's own pick, so it skips the floor and ceiling (how xhigh, max or a capped tier are reached).
633  const pin = s.pin ?? undefined
634  const c: Cache | null = await read($, cache)
635  let d: Decision
636  if (pin?.tier || pin?.effort) {
637    const effort = pin.effort ?? (((await read($, appEffort)) ?? 'medium') as Decision['effort'])
638    d = { tier: pin.tier ?? tierOf(await $.session.model()) ?? 'M', effort, reason: 'pinned', source: 'pin', keepEffort: !pin.effort }
639  } else {
640    const card = await taskCard($, c)
641    const api: any = await $.session.messages({ as: 'api' }).catch(() => [])
642    const failed = failedAttempt(Array.isArray(api) ? api : [], text)
643    failures = failed ? failures + 1 : 0
644    // A failure under the limit retries on the same model; the one that reaches it goes up.
645    const retry = failed && prev && failures < live.attempts
646    let r = retry
647      ? ({ ...prev, reason: `attempt ${failures + 1} of ${live.attempts} at this tier`, source: 'prev' } as Decision)
648      : await route(text, { prev, failed, paused: await read($, isPaused), backends: backendsOf($, s.backend, card) })
649    if (!r) return undefined
650    if (r.source === 'escalate') {
651      hold = { tier: r.tier, left: HOLD_TURNS }
652      failures = 0
653    }
654    else if (hold && hold.left-- > 0 && RANK[r.tier] < RANK[hold.tier]) r = { ...r, tier: hold.tier, reason: `${r.reason}; held after a failure` }
655    d = { ...r, tier: clampTier(r.tier, live.modelFloor, r.source === 'escalate' ? 'L' : live.modelCeiling), effort: clampEffort(r.effort, live.effortFloor, live.effortCeiling) }
656  }
657  // Never route to a model whose window is smaller than the chat: a bigger tier takes the prompt instead.
658  // This is the one thing that moves a pin, and the band then says why ("the chat needs a bigger window").
659  const tier = c ? fitTier(d.tier, c.C) : d.tier
660  const sized = tier === d.tier ? d : { ...d, tier, reason: 'window' }
661  prev = sized
662  return sized
663}
664
665// The model the real /model should move to before this prompt, or undefined to stay.
666// A pin always moves; otherwise only where planSwitch says moving pays (a cache boundary, or worth it).
667async function switchTarget($: any, d: Decision): Promise<string | undefined> {
668  const chat = tierOf(await $.session.model())
669  if (!chat || d.tier === chat) return undefined
670  // An escalation moves even on a warm cache: a cold write costs less than failing again.
671  if (d.source === 'pin' || d.source === 'escalate') return MODEL[d.tier]
672  const c: Cache | null = await read($, cache)
673  const plan = planSwitch({ decided: d.tier, chat, C: c?.C ?? 0, warmUntil: c?.warmUntil ?? {}, now: await $.clock.now() })
674  return plan.why === 'sticky' ? undefined : MODEL[d.tier]
675}
676
677// The effort the native /effort should move to before this prompt, or undefined to stay. Haiku takes
678// none, and while effortless is on the effort is its to set.
679// Effort is never typed as /effort any more: turn.step rewrites it silently on every request, so the
680// prompt is not dropped and re-sent for it. ponytail: the app's effort control keeps showing its old value.
681async function effortTarget(_$: any, _d: Decision, _model: string): Promise<Decision['effort'] | undefined> {
682  return undefined
683}
684
685// Runs the real /model and /effort, then sends the dropped prompt once, whether or not they worked.
686async function switchThenSend($: any, target: string | undefined, effort: Decision['effort'] | undefined, text: string) {
687  // The person may have picked a model while this waited: theirs stands.
688  if ((await modeOf($)) === 'on' && effort) {
689    try {
690      await $.command.run({ command: 'effort', args: effort })
691      await update($, appEffort, () => effort)
692    } catch {
693      $.ui.toast(`Could not set /effort to ${effort}`)
694    }
695  }
696  if ((await modeOf($)) === 'on' && target) {
697    const was = await read($, expectedAtom)
698    await update($, expectedAtom, () => target)
699    switching = true
700    try {
701      await $.command.run({ command: 'model', args: target })
702      arneSwitched = true
703    } catch {
704      await update($, expectedAtom, () => was)
705      planned.delete(text) // decided for a model it is not on: turn.start decides again
706      $.ui.toast(`Could not switch /model to ${shortModel(target)}; sending on the current model`)
707    } finally {
708      switching = false
709    }
710  }
711  await sendAsUser($, text)
712}
713
714// A prompt arne sends for the person (a released hold, a continue) gets the typed prompt's treatment
715// here, since prompt.submit will not see it: decided, and the real /model moved first when it should.
716async function routeAndSend($: any, text: string) {
717  const d = await decideMain($, text)
718  if (d) planned.set(text, d)
719  const target = d && (await switchTarget($, d))
720  const effort = d && (await effortTarget($, d, target ?? (await $.session.model())))
721  if (target || effort) await switchThenSend($, target, effort, text)
722  else await sendAsUser($, text)
723}
724
725// $.fs has no append: read, then write the whole file. ponytail: O(n) per turn, fine below ~10k rows.
726async function appendLog($: any, row: (Row & { text: string; agent?: string; decided?: string; from?: string; to?: string; at?: number }) | UsageRow) {
727  try {
728    const old: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
729    await $.fs.write(await homePath($, LOG_FILE), old + JSON.stringify(row) + '\n')
730  } catch {
731    // no log this time
732  }
733}
734
735// Stats for one period, from the log's usage rows (17q). ponytail: reads the whole log per call; fine
736// below ~10k rows, keep running totals in the store if it ever gets slow.
737async function statsFor($: any, period: Period) {
738  const text: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
739  return summarize(parseUsage(text), periodStart(period, await $.clock.now()))
740}
741
742// Opens the band's panel on a view. Stats reads all three periods from one parse here, so a period
743// press only redraws (17u).
744async function openView($: any, view: View) {
745  if (view === 'stats') {
746    const text: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
747    const rows = parseUsage(text)
748    const now: number = await $.clock.now()
749    await update($, statsData, () => Object.fromEntries(PERIODS.map(([p]) => [p, summarize(rows, periodStart(p, now))])))
750  }
751  await update($, panelView, () => view)
752  await update($, whyOpen, () => true)
753}
754
755// A view's header: "‹ Back" to the main panel, then the view's title.
756function viewHead($: any, { Box, Button, Text }: any, title: string) {
757  return (
758    <Box key="view-head" flexDirection="row" alignItems="center" gap={1}>
759      <Button key="back" plain label="‹ Back" onPress={() => update($, panelView, () => 'main')} />
760      <Text weight="semibold">{title}</Text>
761    </Box>
762  )
763}
764
765// A picked segment: the raised fill alone is faint (1.35:1 on the dark card), so a ✓ marks it too.
766const picked = (label: string, on: boolean) => (on ? `✓ ${label}` : label)
767const SETTINGS_LABEL_W = 16 // "Strongest model" is the longest label
768
769// The panel's Settings view: a label and a segmented control per row. A press shows the new value at
770// once, then writes it through $.config.set (setOption), so the panel and /config never disagree.
771function settingsView($: any, { Box, Button, Text }: any, gui: boolean, cols: number, needsKey = false) {
772  // Every row is the same: the label in a fixed column, the options beside it. A row with many options
773  // wraps under its own first option, never to the far edge.
774  const choice = (label: unknown, key: keyof Live, options: { value: unknown; label: string }[]) => {
775    const set = (value: unknown) => setOption($, key, value)
776    return (
777      <Box key={key} flexDirection="row" alignItems="flex-start" columnGap={2}>
778        <Box width={SETTINGS_LABEL_W} flexShrink={0}>
779          <Text>{label}</Text>
780        </Box>
781        <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1} columnGap={1} rowGap={1}>
782          {options.map(o =>
783            o.value === live[key] ? (
784              <Button key={`${key}-${String(o.value)}`} variant="secondary" label={picked(o.label, true)} onPress={() => set(o.value)} />
785            ) : (
786              <Button key={`${key}-${String(o.value)}`} plain label={o.label} onPress={() => set(o.value)} />
787            ),
788          )}
789        </Box>
790      </Box>
791    )
792  }
793  // Each group: a caption over a bordered card of rows, as in the design's inset lists.
794  const card = (title: string, rows: unknown[], note?: unknown) => (
795    <Box key={`card-${title}`} flexDirection="column" flexGrow={1}>
796      <Text size="footnote" dimColor>{title}</Text>
797      <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={CARD.border} paddingX={1} rowGap={gui ? 1 : 0}>
798        {rows as any}
799      </Box>
800      {note as any}
801    </Box>
802  )
803  const MODELS = [{ value: 'haiku', label: 'Haiku' }, { value: 'sonnet', label: 'Sonnet' }, { value: 'opus', label: 'Opus' }]
804  // The same names as the band: full from 72 columns, short below.
805  const effortNames = cols < 72 ? EFFORT_LABEL_SHORT : EFFORT_LABEL
806  const EFFORTS = (['low', 'medium', 'high', 'xhigh', 'max'] as const).map(v => ({ value: v, label: effortNames[v] ?? v }))
807  return (
808    <Box key="settings-view" flexDirection="column" gap={1}>
809      {viewHead($, { Box, Button, Text }, 'Settings')}
810      {/* The band is at most half the window and scrolls past that: from 90 columns the cards sit side by side. */}
811      <Box key="settings-cards" flexDirection={cols >= 90 ? 'row' : 'column'} gap={cols >= 90 ? 2 : 1}>
812        {card('Routing', [
813          choice('Auto routing', 'auto', [{ value: true, label: 'On' }, { value: false, label: 'Off' }]),
814          choice('Judge', 'backend', [
815            { value: 'heuristic', label: 'Rules' },
816            { value: 'haiku', label: 'Haiku' },
817            { value: 'laya', label: 'Laya' },
818            { value: 'jev', label: 'Jev' },
819          ]),
820          choice('Subagents', 'subagents', [
821            { value: 'auto', label: 'Auto' },
822            { value: 'haiku', label: 'Haiku' },
823            { value: 'sonnet', label: 'Sonnet' },
824            { value: 'opus', label: 'Opus' },
825            { value: 'off', label: 'Off' },
826          ]),
827        ], (
828          // The judge's note sits under the card it describes. Jev's while it is the judge.
829          <Box key="routing-notes" flexDirection="column">
830            <Text size="footnote" dimColor>
831              {live.backend === 'jev' ? 'Jev is hosted: your prompts leave this machine.' : 'Laya runs on this machine and learns from your ratings.'}
832            </Text>
833            {needsKey && (
834              // No masked Input exists, so the key is never typed here: the button points to /config's secure field.
835              <Box key="jev-key" flexDirection="row" flexWrap="wrap" columnGap={2}>
836                <Text size="footnote" dimColor>Jev needs a TypeSafe key.</Text>
837                <Button key="add-key" plain label="Add key…" onPress={() => $.ui.toast(ADD_KEY_HINT)} />
838              </Box>
839            )}
840          </Box>
841        ))}
842        {card('Limits', [
843          choice('Cheapest model', 'modelFloor', MODELS),
844          choice('Strongest model', 'modelCeiling', MODELS),
845          choice('Lowest effort', 'effortFloor', EFFORTS),
846          choice('Highest effort', 'effortCeiling', EFFORTS),
847          choice('Handoff', 'handoff', [{ value: 'newchat', label: 'New chat' }, { value: 'clear', label: 'Clear' }]),
848        ])}
849      </Box>
850      <Box key="settings-notes" flexDirection="column">
851        <Text size="footnote" dimColor>{`arne ${VERSION}. Subagents are experimental. Also in /config.`}</Text>
852      </Box>
853    </Box>
854  )
855}
856
857const STATS_EMPTY: Record<Period, string> = { today: 'No turns today yet.', week: 'No turns in the last 7 days yet.', all: 'No turns yet.' }
858
859// The panel's Stats view (17q): what arne saved against running every turn on Opus at medium.
860// An estimate, said so. Back returns to the main panel.
861async function statsView($: any, { Box, Button, Text }: any, cols: number, gui: boolean) {
862  const period = (await read($, statsPeriod)) as Period
863  // Parsed once at open (openView); a panel drawn without that open (a reload) parses here.
864  const st = (await read($, statsData))?.[period] ?? (await statsFor($, period))
865  const usd = (v: number) => `$${v.toFixed(2)}`
866  // Bars as text, so they follow the theme: one accent for arne, dim for the baselines.
867  const room = Math.max(10, Math.min(40, cols - 24))
868  const max = Math.max(st.spent, st.opus, 1e-9)
869  // ▆ not █: a full block touches the label's descenders above it.
870  const bar = (v: number) => '▆'.repeat(Math.max(v > 0 ? 1 : 0, Math.round((v / max) * room)))
871  const tile = (name: string, base: number) => {
872    const t = savedText(st.spent, base)
873    return (
874      <Box key={`tile-${name}`} flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1} flexGrow={1}>
875        <Text size="footnote" dimColor>{`Against ${name}`}</Text>
876        <Text size="title" weight="semibold" tabular>{t.headline}</Text>
877        <Text size="footnote" dimColor tabular>{t.sub}</Text>
878      </Box>
879    )
880  }
881  return (
882    <Box key="stats-view" flexDirection="column" gap={1}>
883      <Box flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" columnGap={2} rowGap={1}>
884        {viewHead($, { Box, Button, Text }, 'Stats')}
885        <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
886          {PERIODS.map(([p, label]) =>
887            p === period ? (
888              <Button key={`period-${p}`} variant="secondary" label={picked(label, true)} onPress={() => update($, statsPeriod, () => p)} />
889            ) : (
890              <Button key={`period-${p}`} plain label={label} onPress={() => update($, statsPeriod, () => p)} />
891            ),
892          )}
893        </Box>
894      </Box>
895      {st.turns === 0 && st.lines.length === 0 ? (
896        <Box flexDirection="column">
897          <Text>{STATS_EMPTY[period]}</Text>
898          <Text size="footnote" dimColor>Once there are, this shows what arne spent next to the same turns on Opus at medium effort. It is an estimate.</Text>
899        </Box>
900      ) : (
901        <Box flexDirection="column" gap={1}>
902          <Box flexDirection={cols < 60 ? 'column' : 'row'} gap={1}>
903            {tile('Opus · medium', st.opus)}
904          </Box>
905          <Box flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1} rowGap={gui ? 1 : 0}>
906            {([['With arne', st.spent], ['Opus · medium', st.opus]] as const).map(([label, v]) => (
907              <Box key={`bar-${label}`} flexDirection="column">
908                <Box flexDirection="row" justifyContent="space-between">
909                  <Text dimColor={label !== 'With arne'}>{label}</Text>
910                  <Text tabular>{usd(v)}</Text>
911                </Box>
912                {label === 'With arne' ? <Text color="claude">{bar(v)}</Text> : <Text dimColor>{bar(v)}</Text>}
913              </Box>
914            ))}
915          </Box>
916          <Box flexDirection="column">
917            <Text size="footnote" dimColor>Where it went</Text>
918            <Box flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1}>
919              {st.lines.map(l => (
920                <Box key={`line-${l.label}`} flexDirection="row" columnGap={2}>
921                  <Box flexGrow={1}>
922                    <Text>{l.label}</Text>
923                  </Box>
924                  <Text dimColor tabular>{l.label === 'Switches and summaries' ? `${l.count} ${l.count === 1 ? 'time' : 'times'}` : `${l.count} ${l.tokens === undefined ? 'turn' : 'call'}${l.count === 1 ? '' : 's'}`}</Text>
925                  <Text tabular>{l.tokens === undefined ? usd(l.cost) : `${l.tokens.toLocaleString('en-US')} tokens`}</Text>
926                </Box>
927              ))}
928            </Box>
929          </Box>
930          <Text size="footnote" dimColor>An estimate: each turn's real tokens, priced again as if every turn ran on Opus at medium effort. Model switches and summaries count against arne.</Text>
931        </Box>
932      )}
933    </Box>
934  )
935}
936
937// Drives the band's countdown while the cache is warm. A reload cancels every timer and session.start does
938// not run again, so a response landing and a render of a warm band arm it too; `tick` is module state, so a
939// reload arms it once more. A cold cache stays cold, so the tick stops there: no write every second while
940// the chat sits idle. ponytail: a 1s state write while warm.
941let tick: { cancel: () => void } | undefined
942function armTick($: any) {
943  if (tick) return
944  try {
945    tick = $.clock.every(1000, async () => {
946      const c = await read($, cache)
947      const now: number = await $.clock.now()
948      await update($, clockNow, () => now) // the last one redraws the band as the cache turns cold
949      if (c === null || (c.warmUntil[c.model] ?? 0) <= now) {
950        tick?.cancel()
951        tick = undefined
952      }
953    })
954  } catch {
955    tick = undefined
956  }
957}
958
959// A main-loop response landed: the answering model's cache is warm for one more TTL.
960async function cacheTouched($: any, model: string, usage: unknown) {
961  const tier = tierOf(model)
962  // No usage means no response landed (an interrupted or failed step): the cache and its size are unchanged.
963  if (!tier || !usage) return
964  const now: number = await $.clock.now()
965  const since = lastResponseAt === undefined ? undefined : now - lastResponseAt
966  lastResponseAt = now
967  await update($, cache, (c: Cache | null): Cache => {
968    const ttl = nextTtl(c?.ttl ?? '5m', usage, since)
969    return { ttl, C: contextOf(usage), model: tier, warmUntil: { ...c?.warmUntil, [tier]: now + CACHE_TTL[ttl] } }
970  })
971  await update($, clockNow, () => now)
972  armTick($)
973}
974
975export const register: Register = (on, options) => {
976  live = { ...DEFAULTS, ...(options as Partial<Live>) }
977  live.subagents = subCeiling(live.subagents)
978  optionKey = String((options as Record<string, unknown>).typesafe_key ?? '')
979
980  // A write to an arne.* row from /config or another plugin lands here. arne's own writes never do: the
981  // app skips a plugin's own hooks, and ownWrites makes the test kit skip them the same way (17u).
982  on('config.set', async ($, e, next) => {
983    if (ownWrites.has(e.key)) return next(e)
984    const r = await next(e)
985    // `arne.<field>`, or `arne@<marketplace>.<field>` while another arne is enabled too.
986    const field = /^arne(@[^.]+)?\./.test(e.key) ? (e.key.slice(e.key.indexOf('.') + 1) as keyof Live) : undefined
987    if (field && FIELDS.includes(field) && !('deny' in r)) await applyLive($, field, r.value)
988    if ((field as string) === 'typesafe_key' && !('deny' in r)) {
989      optionKey = String(r.value ?? '')
990      await update($, liveRev, n => n + 1)
991    }
992    return r
993  }).catch(($, e, next) => next(e))
994
995  on('session.start', async ($, e, next) => {
996    // Busy flags are atoms, so they outlive a reload or a quit that cut their `finally` short: a new session clears them.
997    await update($, compacting, () => false)
998    await update($, compactSendingAtom, () => false)
999    await update($, disabling, () => false)
1000    await update($, subCount, () => null)
1001    await $.command.register({
1002      name: 'arne',
1003      description: 'Auto model and effort routing: on, off, pin, unpin, handoff, subagents, why, log, stats, backend, debug',
1004    })
1005    const eff = await effortlessOn($)
1006    await update($, effortless, () => eff)
1007    await update($, isOff, () => !live.auto)
1008    const first = await $.session.model()
1009    // The app's picker shows the session's model at start. Re-read each start, so a stale pick does not outlive it.
1010    await update($, appPicker, () => first)
1011    armTick($)
1012    return next(e)
1013  })
1014
1015  // arne's own switch lands here as source "command" too, so it is told apart by its tier: arne sets
1016  // `expected` before it runs /model, and a repeat event for the same switch still matches.
1017  on('classic.PostModelSwitch', async ($, e, next) => {
1018    const r = await next(e)
1019    // ponytail: a manual pick of the tier arne already expects reads as arne's own and does not pause;
1020    // tell them apart by origin if command.run ever carries one for /model.
1021    const expected = await read($, expectedAtom)
1022    const isOwn = expected !== null && tierOf(e.to_model) === tierOf(expected)
1023    // A pick in the desktop app's picker is an input: it pins that tier, not a pause. Auto in the band's panel unpins.
1024    if (e.source === 'sdk' && !switching) {
1025      await update($, appPicker, () => e.to_model)
1026      const tier = tierOf(e.to_model)
1027      if (tier) await setPinPart($, { tier }, false)
1028    } else {
1029      const byHand = e.source === 'command' || e.source === 'picker'
1030      if (!isOwn && byHand && !(await read($, isOff))) await update($, isPaused, () => true)
1031    }
1032    await update($, expectedAtom, () => e.to_model)
1033    await update($, sessionModel, () => e.to_model)
1034    await showStatus($)
1035    return r
1036  }).catch(($, e, next) => next(e))
1037
1038  on('turn.complete', async ($, e, next) => {
1039    const r = await next(e)
1040    const isMain = e.agentId === undefined
1041    if (isMain) lastReason = e.reason
1042    // Each turn's real usage goes to the log, for Stats across reloads (17q). A turn with none is not logged.
1043    const row = usageRow(isMain ? 'turn' : 'agent', await $.clock.now(), r.usage ?? e.usage, undefined, isMain && arneSwitched)
1044    if (isMain) arneSwitched = false
1045    if (row) await appendLog($, row)
1046    return r
1047  })
1048
1049  // Haiku writes the summary for every compaction. If Haiku fails, the engine's own compaction stands.
1050  on('session.compact', async ($, e, next) => {
1051    if (e.trigger === 'precompute' || e.messages.length < 4) return next(e)
1052    try {
1053      const r = await $.model.complete(
1054        {
1055          model: live.compactModel,
1056          prompt: summaryPrompt(e.messages),
1057          system: SUMMARY_SYSTEM + (e.instructions ? ` Keep: ${e.instructions}` : ''),
1058          effort: 'low',
1059        },
1060        { timeoutMs: 120_000 },
1061      )
1062      if (!r.isAnswered || !r.text) return next(e)
1063      const sum = usageRow('summary', await $.clock.now(), r.usage, MODEL[TIER_OF[live.compactModel] ?? 'S'])
1064      if (sum) await appendLog($, sum)
1065      const kept = e.messages.slice(-KEEP_AFTER_COMPACT) // word for word after the summary (17v)
1066      return {
1067        messages: [
1068          { role: 'user', text: `Summary of the conversation so far (written by Haiku for arne):\n\n${r.text}`, toolUses: [] },
1069          ...kept,
1070        ],
1071      }
1072    } catch {
1073      return next(e)
1074    }
1075  }).catch(($, e, next) => next(e))
1076
1077  // A plain /compact (or Compact with nothing held) picks the work back up, once, if it stopped mid-task.
1078  // Compact & send skips this: its own held prompt is the one message that goes.
1079  on('classic.PostCompact', async ($, e, next) => {
1080    // The cache numbers are the old chat's, and the atom outlives a reload: a stale big, expired cache
1081    // made the first prompt after a compact read as a cold 100k chat and get held (17v). No cache until
1082    // the next response lands (cacheTouched); planSwitch then reads the chat as a boundary.
1083    await update($, cache, () => null)
1084    const r = await next(e)
1085    if (e.trigger === 'manual' && !(await read($, compactSendingAtom)) && live.continueAfterCompact && !(await read($, held)) && midTask(lastReason, e.compact_summary)) {
1086      $.clock.after(0, () => routeAndSend($, CONTINUE_TEXT))
1087    }
1088    return r
1089  }).catch(($, e, next) => next(e))
1090
1091  // A typed prompt is decided here, before its turn exists. On a cold, big chat it is held (17a). When
1092  // the decision moves the model, the prompt is dropped, the real /model runs, and the prompt is sent
1093  // again (17c): Claude's own picker then always shows the model the request runs on.
1094  on('prompt.submit', async ($, e, next) => {
1095    const typed = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
1096    // Left alone: commands, prompts typed into a running turn, and attachments (a resubmit would lose them).
1097    if (!typed || e.command || e.isMeta || e.turnId !== undefined || e.attachments?.length) return next(e)
1098    if ((await modeOf($)) !== 'on') return next(e)
1099    if (!(await read($, held)) && (await coldOffer($, await $.clock.now()))) {
1100      await update($, held, () => e.text)
1101      return { drop: 'arne is holding your message: the cache is cold. Pick Summarize, then send or Send as is above the prompt.' }
1102    }
1103    await update($, routing, () => true)
1104    const d = await decideMain($, e.text).finally(() => update($, routing, () => false))
1105    if (d) planned.set(e.text, d)
1106    const target = d && (await switchTarget($, d))
1107    const effort = d && (await effortTarget($, d, target ?? (await $.session.model())))
1108    if (!target && !effort) return next(e)
1109    $.clock.after(0, () => switchThenSend($, target, effort, e.text))
1110    const to = [target && shortModel(target), effort].filter(Boolean).join(' · ')
1111    return { drop: `arne: switching to ${to} first; your message follows.` }
1112  }).catch(($, e, next) => next(e))
1113
1114  on('command.run', { command: 'arne' }, async ($, e) => {
1115    const [sub, ...rest] = e.args.trim().split(/\s+/)
1116    const s = await settings($)
1117
1118    switch (sub) {
1119      case 'on':
1120        await setAuto($, true)
1121        return { text: live.auto ? 'arne on.' : 'arne stays off: the setting was refused.' }
1122      case 'off':
1123        await setAuto($, false)
1124        return { text: live.auto ? 'arne stays on: the setting was refused.' : 'arne off. Requests use the session model.' }
1125      case 'settings':
1126        await openView($, 'settings')
1127        return { text: 'arne settings are open in the band above the prompt. Changes there match /config.' }
1128      case 'pin': {
1129        const tier = TIER_OF[rest[0]]
1130        if (!tier) return { text: 'Usage: /arne pin haiku|sonnet|opus [low|medium|high|xhigh|max]' }
1131        if (rest[1] && !(rest[1] in EFFORT_RANK)) return { text: 'Efforts: low, medium, high, xhigh, max.' }
1132        const effort = (rest[1] ?? 'medium') as Decision['effort']
1133        // A pin is the person's pick made through arne: it ends a pause and moves /model (on the next tick).
1134        await setPinPart($, { tier, effort })
1135        return { text: `arne pinned to ${rest[0]} · ${effort}.` }
1136      }
1137      case 'unpin':
1138        await setPinPart($, { tier: undefined, effort: undefined })
1139        return { text: 'arne unpinned.' }
1140      case 'backend': {
1141        const b = rest[0]
1142        if (b !== 'haiku' && b !== 'heuristic' && b !== 'laya' && b !== 'jev') return { text: 'Backends: haiku, heuristic, laya, jev.' }
1143        await setOption($, 'backend', b)
1144        if (live.backend !== b) return { text: `arne backend stays ${live.backend}: the setting was refused.` }
1145        if (b === 'laya') {
1146          return {
1147            text: 'arne backend: laya. It needs laya-serve on PATH (Python 3.10+, the laya package, torch; git clone https://github.com/NandhaKishorM/laya and pip install -e .). Until it answers, turns fall back to haiku.',
1148          }
1149        }
1150        if (b === 'jev') {
1151          return {
1152            text: 'arne backend: jev. Jev is TypeSafe’s hosted judge: your prompts and the task card leave this machine. Set the key in /config (arne → TypeSafe API key) or TYPESAFE_API_KEY; keys are at console.typesafe.ai/keys. Until then, turns fall back to haiku.',
1153          }
1154        }
1155        return { text: `arne backend: ${b}.` }
1156      }
1157      case 'stats': {
1158        const lines = await Promise.all(
1159          PERIODS.map(async ([p, label]) => {
1160            const st = await statsFor($, p)
1161            const opus = savedText(st.spent, st.opus)
1162            return `${label}: $${st.spent.toFixed(2)} spent over ${st.turns} turns. Against Opus · medium: ${opus.headline.toLowerCase()} (${opus.sub}).`
1163          }),
1164        )
1165        await openView($, 'stats')
1166        return { text: `arne stats (an estimate: each turn's real tokens, priced again on Opus):\n${lines.join('\n')}` }
1167      }
1168      case 'subagents': {
1169        // `on` was the old switch: it reads as the default ceiling.
1170        const v = rest[0] === 'on' ? 'auto' : rest[0]
1171        if (v !== 'off' && v !== 'auto' && !TIER_OF[v ?? '']) return { text: 'Usage: /arne subagents auto|haiku|sonnet|opus|off' }
1172        await setOption($, 'subagents', v)
1173        return {
1174          text: live.subagents === 'off' ? 'arne leaves subagents on the model they ask for.' : live.subagents === 'auto' ? "arne picks each subagent's model, no ceiling." : `arne routes subagents, ${shortModel(MODEL[TIER_OF[live.subagents] ?? 'M'])} at most.`,
1175        }
1176      }
1177      case 'handoff': {
1178        const named: Tier | undefined = TIER_OF[rest[0] ?? '']
1179        if (rest[0] && !named) return { text: 'Usage: /arne handoff [haiku|sonnet|opus]' }
1180        const tier = named ?? (await handoffTier($))
1181        // Next tick: /clear and /model are refused from inside a command's own hook.
1182        $.clock.after(0, () => handoff($, tier))
1183        return { text: `arne: handing off to ${shortModel(MODEL[tier])}…` }
1184      }
1185      case 'why': {
1186        const l = await read($, last)
1187        return { text: l ? `${l.model} · ${l.effort}: ${l.reason} (${l.source})` : 'No decision yet.' }
1188      }
1189      case 'log':
1190        await $.ui.open({ id: PANE, title: 'arne log' })
1191        return { text: 'arne log opened.' }
1192      case 'show': {
1193        // Draws the band in one state for a few seconds, to check each look without a real trigger.
1194        const state = rest[0] as BandState
1195        if (rest[0] === 'live') {
1196          await update($, shown, () => null)
1197          return { text: 'Band shows the live state.' }
1198        }
1199        if (!STATES.includes(state)) return { text: `Usage: /arne show live|${STATES.join('|')}` }
1200        await update($, shown, () => ({ state, until: 0 }))
hooks/backends.ts 85 lines
1import type { Tier, Effort, Verdict } from './route'
2
3const TIERS: Tier[] = ['S', 'M', 'L']
4// ponytail: the judge never picks above high. xhigh and max are reached only by a pin or a floor, so a
5// judge's answer cannot raise the cost of a turn past what the person allowed.
6const EFFORTS: Effort[] = ['low', 'medium', 'high']
7
8export const SYSTEM = `You route a coding-assistant prompt to a model tier. Reply with JSON only:
9{"tier":"S|M|L","effort":"low|medium|high","reason":"<6 words>"}
10S = trivial lookup or edit, or a search, read or list (a subagent's brief to find or map code is S).
11M = normal coding, including multi-step or multi-file chores: edits, re-renders, commits, running tools.
12L = only when the hard part is the thinking: architecture, a race or subtle bug, a security review.
13Many steps is not L: the model hands searches and grunt work to cheap subagents. When unsure, pick M.
14Effort is high when the prompt asks to think hard or be careful, or touches security.`
15
16// Pure: a model reply to a verdict, or undefined when it is not one.
17export function parseVerdict(text: string): Verdict | undefined {
18  const m = text.match(/\{[\s\S]*\}/)
19  if (!m) return undefined
20  try {
21    const v = JSON.parse(m[0])
22    if (!TIERS.includes(v.tier) || !EFFORTS.includes(v.effort)) return undefined
23    return { tier: v.tier, effort: v.effort, reason: String(v.reason ?? '').slice(0, 80) }
24  } catch {
25    return undefined
26  }
27}
28
29// System One (17s): laya-serve here and Jev at TypeSafe speak the same POST /v1/systemone.
30export const LAYA_URL = 'http://127.0.0.1:8000'
31export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
32export const JEV_MODEL = 'jev-latest'
33export const SYSTEM_ONE_TIMEOUT_MS = 1500
34// Below this an answer falls through to the next judge. ponytail: untuned; step 8 measures it.
35export const MIN_CONFIDENCE = 0.5
36
37// Request body: two typed questions in one call, read against the task card and the prompt.
38export function systemOneBody(text: string, task = '', model?: string): string {
39  return JSON.stringify({
40    state: { ...(task ? { task } : {}), prompt: text.slice(0, 4000) },
41    ...(model ? { model } : {}),
42    min_confidence: MIN_CONFIDENCE,
43    questions: {
44      tier: {
45        type: 'choice',
46        instructions: 'How hard is this coding-assistant request?',
47        criteria: {
48          S: 'trivial lookup, typo, rename, format, one-line answer, a search or read of the code',
49          M: 'normal coding, including multi-step or multi-file chores: edits, re-renders, commits, running tools',
50          L: 'only when the hard part is the thinking: architecture, a race or subtle bug, a security review',
51        },
52      },
53      effort: {
54        type: 'score',
55        instructions: 'How much thinking does it need?',
56        criteria: ['low', 'medium', 'high'],
57      },
58    },
59  })
60}
61
62// The most likely key of an answer's probabilities: an option name for a choice, a level index for a score.
63function argmax(p: unknown): string | undefined {
64  if (!p || typeof p !== 'object') return undefined
65  let best: [string, number] | undefined
66  for (const [k, v] of Object.entries(p)) if (typeof v === 'number' && (!best || v > best[1])) best = [k, v]
67  return best?.[0]
68}
69
70// Pure: System One's answers to a verdict, by argmax over each answer's probabilities. A score is an
71// expectation (0.67 between two levels), never an index. An abstained, unsure or missing answer is
72// undefined, so the chain falls through to the next judge.
73export function systemOneVerdict(answers: any, reason = 'system one'): Verdict | undefined {
74  const t = answers?.tier
75  const e = answers?.effort
76  if (!t || !e || t.abstention || e.abstention) return undefined
77  if ((typeof t.confidence === 'number' && t.confidence < MIN_CONFIDENCE) || (typeof e.confidence === 'number' && e.confidence < MIN_CONFIDENCE)) return undefined
78  const tier = argmax(t.probabilities) ?? t.choice
79  const level = argmax(e.probabilities)
80  // No probabilities (an older laya-serve): a whole-number score is still a level.
81  const effort = level !== undefined ? EFFORTS[Number(level)] : Number.isInteger(e.score) ? EFFORTS[e.score] : undefined
82  if (!TIERS.includes(tier) || !effort) return undefined
83  return { tier, effort, reason }
84}
85
hooks/econ.ts 253 lines
1// Pure cost model, in units of one Sonnet uncached input token.
2// Ratios from effortless 1.71.0 (MIT, HeyCubit): MODEL_PRICE, WEIGHT and the measured 1h cache write.
3import type { Tier } from './route'
4
5export const PRICE: Record<Tier, number> = { S: 1 / 3, M: 1, L: 2 }
6export const READ = 0.1 // cache read, × input price
7export const WRITE = 2 // Claude Code writes the 1h cache: 2× input price (measured)
8export const OUT = 5 // output, × input price
9
10/** First request of the next prompt when staying on `m`: a read when warm, a full re-write when cold. */
11export const costContinue = (C: number, m: Tier, warm: boolean) => C * PRICE[m] * (warm ? READ : WRITE)
12
13/** First request after moving to `to`. */
14export const costSwitch = (C: number, to: Tier, warmTo: boolean) => costContinue(C, to, warmTo)
15
16/** Haiku reads the chat once (uncached), writes summary S, then `m` writes S to its cache. */
17export const costCompact = (C: number, S: number, m: Tier) => C * PRICE.S + OUT * S * PRICE.S + WRITE * S * PRICE[m]
18
19/** Whether moving one prompt from `inUse` to `to` is cheaper than staying (effortless's routeWorth). */
20export function routeWorth(to: Tier, inUse: Tier, C: number, warm: { to: boolean; inUse: boolean }): boolean {
21  if (!(C > 0)) return true
22  return costSwitch(C, to, warm.to) < costContinue(C, inUse, warm.inUse)
23}
24
25export type Row = { action: 'continue' | 'switch' | 'compact' | 'handoff'; to?: Tier; first: number; after: number }
26
27/**
28 * The options for the next prompt on a chat of C tokens on model m, cheapest first.
29 * `first` is the first request's cost, `after` each later request's. S is the expected summary size;
30 * a handoff also carries HANDOFF.md (`doc` tokens).
31 */
32export function coldAdvice(
33  C: number,
34  m: Tier,
35  warmUntil: Partial<Record<Tier, number>>,
36  now: number,
37  S = 4000,
38  doc = 2000,
39): { best: Row; rows: Row[] } {
40  const warm = (t: Tier) => (warmUntil[t] ?? 0) > now
41  const rows: Row[] = [
42    { action: 'continue', first: costContinue(C, m, warm(m)), after: C * PRICE[m] * READ },
43    ...(['S', 'M', 'L'] as Tier[])
44      .filter(t => t !== m && PRICE[t] < PRICE[m])
45      .map(t => ({ action: 'switch' as const, to: t, first: costSwitch(C, t, warm(t)), after: C * PRICE[t] * READ })),
46    { action: 'compact', first: costCompact(C, S, m), after: S * PRICE[m] * READ },
47    // One per tier: Haiku reads the chat, and the new chat on `t` starts from the summary and the doc, cold.
48    ...(['S', 'M', 'L'] as Tier[]).map(t => ({ action: 'handoff' as const, to: t, first: costCompact(C, S + doc, t), after: (S + doc) * PRICE[t] * READ })),
49  ]
50  rows.sort((a, b) => a.first - b.first)
51  return { best: rows[0], rows }
52}
53
54/** The fewest later turns after which `row` has cost less than staying, or undefined past `max`. */
55export function paysOffAfter(row: Row, stay: Row, max = 20): number | undefined {
56  for (let n = 0; n <= max; n++) if (row.first + n * row.after < stay.first + n * stay.after) return n
57  return undefined
58}
59
60/** What a row costs over this turn and the next `turns - 1`: the "best" tag goes to the cheapest. */
61export const overTurns = (row: Row, turns = 5) => row.first + (turns - 1) * row.after
62
63/** Units to dollars: Sonnet 5.5 input is $3 per million tokens. ponytail: fixed rate, read real prices if they change. */
64export const dollars = (units: number) => (units * 3) / 1e6
65
66// --- Cache clock helpers (ported from effortless cacheTtlOf / mostlyCached) ---
67
68export const CACHE_TTL = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
69export type Ttl = keyof typeof CACHE_TTL
70type Usage = {
71  input_tokens?: number
72  cache_read_input_tokens?: number
73  cache_creation_input_tokens?: number
74  cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
75}
76
77/** The cache lifetime a response's writes got, when it says. */
78export function cacheTtlOf(usage: unknown): Ttl | undefined {
79  const c = (usage as Usage | null)?.cache_creation
80  if ((c?.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
81  if ((c?.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
82  return undefined
83}
84
85/** Most of the prompt came from the cache: it was still warm. */
86export function mostlyCached(usage: unknown): boolean {
87  const u = (usage ?? {}) as Usage
88  const r = u.cache_read_input_tokens ?? 0
89  const all = r + (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
90  return all > 0 && r / all > 0.5
91}
92
93/** Context size C: everything the request sent. */
94export const contextOf = (usage: unknown) => {
95  const u = (usage ?? {}) as Usage
96  return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
97}
98
99/**
100 * The TTL after a response. A read back after more than 5 minutes proves 1h; once 1h is seen,
101 * a short 5m tail write never pulls it back down.
102 */
103export function nextTtl(prev: Ttl, usage: unknown, sinceLastMs: number | undefined): Ttl {
104  const seen = cacheTtlOf(usage) ?? (sinceLastMs !== undefined && sinceLastMs > CACHE_TTL['5m'] && mostlyCached(usage) ? '1h' : undefined)
105  if (!seen || (seen === '5m' && prev === '1h' && mostlyCached(usage))) return prev
106  return seen
107}
108
109export const tierOf = (model: string): Tier | undefined =>
110  /haiku/.test(model) ? 'S' : /sonnet/.test(model) ? 'M' : /opus/.test(model) ? 'L' : undefined
111
112export const mmss = (ms: number) => {
113  const s = Math.max(0, Math.ceil(ms / 1000))
114  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
115}
116
117export type SwitchPlan = { tier: Tier; real: boolean; why: 'same' | 'boundary' | 'worth' | 'sticky' }
118
119/**
120 * Where this prompt's requests go, and whether the real /model moves.
121 * The real model changes only at a cache boundary (chat model cold, or right after a compact).
122 * While warm, requests stay on the chat's model unless routeWorth says the move pays.
123 */
124export function planSwitch(p: {
125  decided: Tier
126  chat: Tier
127  C: number
128  warmUntil: Partial<Record<Tier, number>>
129  now: number
130}): SwitchPlan {
131  const { decided, chat, C, warmUntil, now } = p
132  if (decided === chat) return { tier: chat, real: false, why: 'same' }
133  const warm = (t: Tier) => (warmUntil[t] ?? 0) > now
134  // After a compact the caller passes no warmUntil (the cache atom is cleared), so that is a boundary too.
135  if (!warm(chat)) return { tier: decided, real: true, why: 'boundary' }
136  if (routeWorth(decided, chat, C, { to: warm(decided), inUse: true })) return { tier: decided, real: false, why: 'worth' }
137  return { tier: chat, real: false, why: 'sticky' }
138}
139
140/** The prompt for Haiku's summary: the transcript as text, the last part if it is long. */
141export function summaryPrompt(messages: readonly { role: string; text: string }[], maxChars = 300_000): string {
142  const all = messages.map(m => `${m.role}: ${m.text}`).join('\n\n')
143  return all.length > maxChars ? all.slice(-maxChars) : all
144}
145
146/** The last turn stopped mid-task: it did not end on an answer, or the compact summary names a next step. */
147export const midTask = (reason: string | undefined, summary: string) =>
148  (reason !== undefined && reason !== 'answer') || /next step[^\n:]*:\s*(?!none|n\/a|nothing)\S/i.test(summary)
149
150// --- Band indicator (step 14) ---
151
152// Context window per tier, in tokens. ponytail: assumed sizes (Haiku 200k, Sonnet and Opus 1M); read the model's limit if they differ.
153export const WINDOWS: Record<Tier, number> = { S: 200_000, M: 1_000_000, L: 1_000_000 }
154/** Context fill as a whole percent of the tier's window, never above 100. */
155export const fillOf = (C: number, t: Tier) => Math.min(100, Math.round((C / WINDOWS[t]) * 100))
156/** The cheapest tier, not below `tier`, whose window holds C tokens. Tiers are ordered S < M < L. */
157export function fitTier(tier: Tier, C: number): Tier {
158  const order: Tier[] = ['S', 'M', 'L']
159  return order.find(t => order.indexOf(t) >= order.indexOf(tier) && WINDOWS[t] >= C) ?? 'L'
160}
161export const HEAVY_TOKENS = 150_000 // over this, the band warns that the chat is large
162export type BandState = 'routing' | 'off' | 'paused' | 'cold' | 'heavy' | 'normal'
163export type BandInput = {
164  off: boolean
165  paused: boolean
166  routing: boolean
167  model: string // full model id of the last decision
168  effort?: string
169  warmLeftMs: number // ms until the chat's model cache goes cold; 0 or less = cold
170  C: number
171  flash?: boolean
172  heavyTokens?: number
173}
174
175const SHORT: Record<string, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' }
176const EFFORT_SHORT: Record<string, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhi', max: 'max' }
177export const shortModel = (model: string) => SHORT[(/haiku|sonnet|opus/.exec(model)?.[0] ?? '')] ?? model
178export const shortEffort = (effort?: string) => (effort ? EFFORT_SHORT[effort] ?? effort : '-')
179
180/** The band's state and its two texts: `text` for the band, `short` for the status line. */
181export function bandText(i: BandInput): { state: BandState; text: string; short: string } {
182  const label = `${shortModel(i.model)} · ${shortEffort(i.effort)}`
183  const k = `${Math.round(i.C / 1000)}k`
184  const pct = `${fillOf(i.C, tierOf(i.model) ?? 'M')}%`
185  const flash = i.flash ? ' ▲' : ''
186  // `short` goes to the status line, which the footer already labels with the plugin's name.
187  if (i.off) return { state: 'off', text: `off · ${label}`, short: 'off' }
188  if (i.routing) return { state: 'routing', text: 'routing…', short: '…' }
189  if (i.paused) return { state: 'paused', text: `paused · ${label} (you picked it; /arne on resumes)`, short: `paused · you picked ${shortModel(i.model)}` }
190  if (i.C > 0 && i.warmLeftMs <= 0) return { state: 'cold', text: `cold · ${k} · see the offer below`, short: `cold ${k}` }
191  if (i.C >= (i.heavyTokens ?? HEAVY_TOKENS)) return { state: 'heavy', text: `${label} · heavy ${k} · ◔ ${pct}`, short: `${shortModel(i.model)}·${shortEffort(i.effort)} heavy` }
192  return {
193    state: 'normal',
194    text: `${label} · cache warm ${mmss(i.warmLeftMs)} · ${k}${flash}`,
195    short: `${shortModel(i.model)}·${shortEffort(i.effort)}${flash}`,
196  }
197}
198
199// --- Settings clamps (step 15) ---
200
201const TIER_NAME: Record<string, Tier> = { haiku: 'S', sonnet: 'M', opus: 'L' }
202const TIER_RANK: Record<Tier, number> = { S: 0, M: 1, L: 2 }
203export const EFFORT_RANK: Record<string, number> ={ low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }
204
205/** A tier kept between the floor and ceiling model names (haiku|sonnet|opus). */
206export function clampTier(tier: Tier, floor: string, ceiling: string): Tier {
207  const lo = TIER_RANK[TIER_NAME[floor] ?? 'S']
208  const hi = TIER_RANK[TIER_NAME[ceiling] ?? 'L']
209  const r = Math.min(Math.max(TIER_RANK[tier], lo), Math.max(lo, hi))
210  return (['S', 'M', 'L'] as Tier[])[r]
211}
212
213/** An effort kept between the floor and ceiling names. */
214export function clampEffort<E extends string>(effort: E, floor: string, ceiling: string): E {
215  const lo = EFFORT_RANK[floor] ?? 0
216  const hi = EFFORT_RANK[ceiling] ?? 4
217  const r = Math.min(Math.max(EFFORT_RANK[effort] ?? 1, lo), Math.max(lo, hi))
218  return (Object.keys(EFFORT_RANK).find(k => EFFORT_RANK[k] === r) ?? effort) as E
219}
220
221// --- Words for people (step 17i) ---
222
223/** A decision's internal reason code as a plain phrase. Judge reasons are already words and pass through. */
224export function plainReason(code: string): string {
225  if (code === 'continuation') return 'it continues the last turn'
226  if (code === 'default') return 'there is no clear signal'
227  if (code === 'window') return 'the chat needs a bigger window'
228  if (code.startsWith('prior: ')) return `${code.slice(7)} is a fixed rule`
229  return code
230}
231
232/**
233 * The resting line at a given width. `head` (the mark, model and effort) never shrinks; the reason takes
234 * the leftover and ends in an ellipsis; the cache text falls back through `caches`, longest first.
235 */
236export function fitRest(width: number, head: string, reason: string, caches: string[]): { head: string; reason: string; cache: string } {
237  const GAP = 2
238  const last = caches[caches.length - 1]
239  for (const cache of caches) {
240    const room = width - head.length - cache.length - GAP * 2
241    if (room >= Math.min(reason.length, 12) || cache === last) {
242      if (room <= 0) return { head, reason: '', cache }
243      const r = reason.length <= room ? reason : `${reason.slice(0, Math.max(0, room - 1))}…`
244      return { head, reason: r, cache }
245    }
246  }
247  return { head, reason: '', cache: last }
248}
249
250/** Effort names for the band's panel: the short forms when the panel is narrow. */
251export const EFFORT_LABEL: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' }
252export const EFFORT_LABEL_SHORT: Record<string, string> = { low: 'Low', medium: 'Med', high: 'High', xhigh: 'XHigh', max: 'Max' }
253
hooks/route.ts 99 lines
1export type Tier = 'S' | 'M' | 'L'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3export type Verdict = { tier: Tier; effort: Effort; reason: string }
4// keepEffort: a model-only pin; the request keeps the effort the app sends, whatever `effort` says.
5export type Decision = Verdict & { source: string; keepEffort?: boolean }
6export type Backend = { name: string; ask: (text: string) => Promise<Verdict | undefined> }
7export type Ctx = {
8  prev?: Decision
9  pin?: { tier: Tier; effort: Effort }
10  paused?: boolean
11  agentType?: string // set for subagent runs, absent on the main loop
12  backends?: Backend[] // fallback chain, first verdict wins
13  failed?: boolean // the last attempt failed (failedAttempt): the prompt goes one tier up from prev
14}
15
16export const MODEL: Record<Tier, string> = {
17  S: 'claude-haiku-5-5',
18  M: 'claude-sonnet-5-5',
19  L: 'claude-opus-5-5',
20}
21
22const PRIORS: Record<string, Verdict> = {
23  Explore: { tier: 'S', effort: 'low', reason: 'prior: Explore' },
24  'cavecrew-investigator': { tier: 'S', effort: 'low', reason: 'prior: investigator' },
25  'statusline-setup': { tier: 'S', effort: 'low', reason: 'prior: statusline-setup' },
26  Plan: { tier: 'L', effort: 'high', reason: 'prior: Plan' },
27}
28
29const CONTINUE = /^\W*(y|yes|ok|okay|continue|go on|go ahead|proceed|sure|ok do it|do it)\W*$/i
30// A short follow-up with none of these words carries on the work in hand ("looks good, next one"):
31// it keeps the previous tier and effort and the judge is not asked (17p).
32// No keyword or length sends a prompt to a tier: the judge reads every new task, since a word is
33// a poor guess ("add a security note" is no Opus job) and a judge call costs far less than a wrong tier.
34const FOLLOW_MAX = 80
35const TASK = /\b(add|build|write|fix|implement|create|refactor|make|change|remove|delete|test|debug|explain|why|how|what|update|rename|move|review|design|plan|migrate|install|deploy|port|rewrite|find|search|list|show|check|run|look|read|summari[sz]e|compare|convert|generate|setup|set up|typo|format|security|architect\w*|race)\b/i
36
37const keep = (text: string) => text.trim() === '' || CONTINUE.test(text) || (text.trim().length < FOLLOW_MAX && !TASK.test(text))
38
39const DEFAULT: Verdict = { tier: 'M', effort: 'medium', reason: 'default' }
40
41const UP_TIER: Record<Tier, Tier> = { S: 'M', M: 'L', L: 'L' }
42// The judge's own ceiling: escalation does not reach xhigh or max either.
43const UP_EFFORT: Record<Effort, Effort> = { low: 'medium', medium: 'high', high: 'high', xhigh: 'xhigh', max: 'max' }
44
45// The person says the last attempt did not work.
46export const RETRY = /\b(did ?n[o']?t work|does ?n[o']?t work|not working|still (?:fail\w*|broken|wrong|not \w+|does ?n[o']?t \w+)|try again|that'?s (?:wrong|not right)|wrong (?:answer|number|result|output)|same error)\b/i
47
48type ApiMessage = { role: string; content: unknown }
49const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
50const blocksOf = (c: unknown): any[] => (typeof c === 'string' ? [{ type: 'text', text: c }] : Array.isArray(c) ? c : [])
51const textOf = (c: unknown): string => blocksOf(c).map(b => (b.type === 'text' ? b.text : '')).join('\n')
52// Line numbers, timings and counts change between runs of the same error.
53const signature = (b: any) => textOf(b.content).replace(/\d+/g, '#').trim().slice(0, 300)
54
55// Pure: whether the attempt since the last prompt the person typed failed. After claude-router (MIT): the same
56// tool error twice with an edit between them. Or the new prompt says so (RETRY).
57export function failedAttempt(messages: readonly ApiMessage[], text: string): boolean {
58  if (RETRY.test(text)) return true
59  const isTyped = (m: ApiMessage) => m.role === 'user' && !blocksOf(m.content).some(b => b.type === 'tool_result')
60  let typed = messages.findLastIndex(isTyped)
61  // The new prompt may already be in the transcript: then the attempt is the one before it.
62  if (typed >= 0 && textOf(messages[typed].content).trim() === text.trim()) typed = messages.slice(0, typed).findLastIndex(isTyped)
63  const errors: { at: number; sig: string }[] = []
64  const edits: number[] = []
65  messages.forEach((m, at) => {
66    if (at <= typed) return
67    for (const b of blocksOf(m.content)) {
68      if (m.role === 'user' && b.type === 'tool_result' && b.is_error) errors.push({ at, sig: signature(b) })
69      if (m.role === 'assistant' && b.type === 'tool_use' && EDIT_TOOLS.has(b.name)) edits.push(at)
70    }
71  })
72  return errors.some((a, i) => errors.slice(i + 1).some(b => b.sig === a.sig && edits.some(e => e > a.at && e < b.at)))
73}
74
75// Precedence: pin > paused > failed attempt goes up from prev > follow-up keeps prev > backends > prev > default.
76// undefined = leave the request alone (paused).
77export async function route(text: string, ctx: Ctx = {}): Promise<Decision | undefined> {
78  if (ctx.pin) return { ...ctx.pin, reason: 'pinned', source: 'pin' }
79  if (ctx.paused && !ctx.agentType) return undefined
80
81  const prior = ctx.agentType ? PRIORS[ctx.agentType] : undefined
82  if (prior?.tier === 'L') return { ...prior, source: 'prior' }
83
84  // A failed attempt is not asked again at the tier that failed it: one tier and one effort up.
85  if (!ctx.agentType && ctx.prev && ctx.failed)
86    return { tier: UP_TIER[ctx.prev.tier], effort: UP_EFFORT[ctx.prev.effort], reason: 'last attempt failed', source: 'escalate' }
87
88  // A subagent's brief is a new task every time, so it never just continues the last turn (17x).
89  if (!ctx.agentType && ctx.prev && keep(text)) return { ...ctx.prev, reason: 'continuation', source: 'prev' }
90
91  for (const b of ctx.backends ?? []) {
92    const v = await b.ask(text).catch(() => undefined)
93    if (v) return prior && v.tier !== 'L' ? { ...prior, source: 'prior' } : { ...v, source: b.name }
94  }
95  if (prior) return { ...prior, source: 'prior' }
96  if (ctx.prev) return { ...ctx.prev, source: 'prev' }
97  return { ...DEFAULT, source: 'default' }
98}
99
hooks/version.ts 3 lines
1// Keep equal to .claude-plugin/plugin.json (bump both): a hooks module cannot import JSON.
2export const VERSION = '0.4.1'
3
hooks/stats.ts 124 lines
1// Stats (17q): what arne saved, from the usage rows in decisions.jsonl. An estimate: each turn's real
2// tokens priced again as if it ran on a baseline model. Effort has no price of its own here: a different
3// effort or model would have written different output, and that cannot be known, so the tokens stay as they were.
4import { dollars, OUT, PRICE, READ, tierOf, WRITE } from './econ'
5import type { Tier } from './route'
6
7export type UsageRow = {
8  type: 'usage'
9  at: number
10  kind: 'turn' | 'agent' | 'summary' | 'jev' // jev: one Jev judge call, its tokens only (17s)
11  model: string
12  input: number
13  read: number
14  write: number
15  out: number
16  switched?: boolean // the turn right after arne's own /model: its cache writes are the switch's cost
17}
18
19export type Period = 'today' | 'week' | 'all'
20export const PERIODS: [Period, string][] = [['today', 'Today'], ['week', '7 days'], ['all', 'All time']]
21
22/** The first timestamp a period takes in: local midnight today, seven days back, or the beginning. */
23export function periodStart(period: Period, now: number): number {
24  if (period === 'all') return 0
25  if (period === 'week') return now - 7 * 24 * 60 * 60_000
26  const d = new Date(now)
27  d.setHours(0, 0, 0, 0)
28  return d.getTime()
29}
30
31/** A usage row from turn.complete's usage, or undefined when the turn reported none. */
32export function usageRow(
33  kind: UsageRow['kind'],
34  at: number,
35  usage: { model?: string; input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | undefined,
36  model?: string,
37  switched?: boolean,
38): UsageRow | undefined {
39  const m = usage?.model ?? model
40  if (!usage || !m || !tierOf(m)) return undefined
41  return {
42    type: 'usage',
43    at,
44    kind,
45    model: m,
46    input: usage.input_tokens ?? 0,
47    read: usage.cache_read_input_tokens ?? 0,
48    // The usage carries no 5m/1h split; Claude Code writes the 1h cache (econ.ts WRITE).
49    write: usage.cache_creation_input_tokens ?? 0,
50    out: usage.output_tokens ?? 0,
51    ...(switched ? { switched: true } : {}),
52  }
53}
54
55/** Dollars for a row's tokens on a tier. `asRead` prices the cache writes as reads (no switch happened). */
56export const priceOn = (r: UsageRow, t: Tier, asRead = false) =>
57  dollars(PRICE[t] * (r.input + r.read * READ + r.write * (asRead ? READ : WRITE) + r.out * OUT))
58
59export type Line = { label: string; count: number; cost: number; tokens?: number } // tokens: a line with no known price (Jev)
60export type Summary = { spent: number; opus: number; lines: Line[]; turns: number }
61
62/** Totals for the rows at or after `since`. Rows without usage never get here (usageRow drops them). */
63export function summarize(rows: UsageRow[], since: number): Summary {
64  const picked = rows.filter(r => r.at >= since && tierOf(r.model))
65  // Jev's prices are not published (17s): its calls count in tokens, on their own line, outside the dollars.
66  const jevRows = rows.filter(r => r.at >= since && r.kind === 'jev')
67  const jev: Line = { label: 'Jev judge', count: jevRows.length, cost: 0, tokens: jevRows.reduce((n, r) => n + r.input + r.out, 0) }
68  const tiers: [Tier, string][] = [['S', 'Haiku'], ['M', 'Sonnet'], ['L', 'Opus']]
69  const byTier = new Map<Tier, Line>(tiers.map(([t, label]) => [t, { label, count: 0, cost: 0 }]))
70  const agents: Line = { label: 'Subagents', count: 0, cost: 0 }
71  const extra: Line = { label: 'Switches and summaries', count: 0, cost: 0 }
72  let spent = 0
73  let opus = 0
74  for (const r of picked) {
75    const t = tierOf(r.model)!
76    const cost = priceOn(r, t)
77    spent += cost
78    if (r.kind === 'summary') {
79      // A summary is arne's own spend: the default would not have made one.
80      extra.count++
81      extra.cost += cost
82      continue
83    }
84    // The default stays on one model, so it never pays a switch's cache rewrite: its writes read as reads.
85    opus += priceOn(r, 'L', r.switched)
86    const switchCost = r.switched ? cost - priceOn(r, t, true) : 0
87    if (r.switched) {
88      extra.count++
89      extra.cost += switchCost
90    }
91    const line = r.kind === 'agent' ? agents : byTier.get(t)!
92    line.count++
93    line.cost += cost - switchCost
94  }
95  const lines = [...byTier.values(), agents, extra, jev].filter(l => l.count > 0)
96  return { spent, opus, lines, turns: picked.filter(r => r.kind !== 'summary').length }
97}
98
99/** "Saved $X" against a baseline, or "$X more" when arne cost more; with the percent of the baseline. */
100export function savedText(spent: number, base: number): { headline: string; sub: string } {
101  const diff = base - spent
102  const pct = base > 0 ? Math.round((Math.abs(diff) / base) * 100) : 0
103  const usd = (v: number) => `$${v.toFixed(2)}`
104  return {
105    headline: diff >= 0 ? `Saved ${usd(diff)}` : `${usd(-diff)} more`,
106    sub: `${pct}% ${diff >= 0 ? 'less' : 'more'} than ${usd(base)}`,
107  }
108}
109
110/** The usage rows of a decisions.jsonl text; other rows and broken lines are skipped. */
111export function parseUsage(text: string): UsageRow[] {
112  const rows: UsageRow[] = []
113  for (const line of text.split('\n')) {
114    if (!line.includes('"type":"usage"')) continue
115    try {
116      const r = JSON.parse(line)
117      if (r.type === 'usage' && typeof r.at === 'number' && typeof r.model === 'string') rows.push(r)
118    } catch {
119      // a torn line: skipped
120    }
121  }
122  return rows
123}
124
types/index.d.ts 24 lines
1export type Row = {
2  key: string // turnId, plus agentId for subagent runs
3  model: string
4  effort: string
5  reason: string
6  source: string
7  out: number // output tokens, summed over the turn's steps
8  cacheRead: number // cache-read input tokens, summed
9  waitFor?: string // full model id the decision wanted, when the chat stays on another model until the cache cools
10}
11
12export type Cache = {
13  ttl: '5m' | '1h'
14  C: number // context tokens of the last main response
15  model: 'S' | 'M' | 'L' // the tier that answered last
16  warmUntil: Partial<Record<'S' | 'M' | 'L', number>> // epoch ms, per tier
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    arne: { last: Row | null; log: Row[]; cache: Cache | null; now: number; coldDismissed: number; routing: boolean; isOff: boolean; isPaused: boolean; appEffort: string | null; sessionModel: string | null; held: string | null; shown: { state: string; until: number } | null; effortless: boolean; whyOpen: boolean; thanked: string | null; layaDown: boolean; jevNoKey: boolean; appPicker: string | null; statsPeriod: string; liveRev: number; pinRev: number; heavyDismissed: number; compacting: boolean; disabling: boolean; current: import('../hooks/route').Decision | null; expected: string | null; compactSending: boolean; handedOff: import('../hooks/route').Tier | null; handoffMenu: boolean; panelView: 'main' | 'settings' | 'stats'; subCount: Partial<Record<import('../hooks/route').Tier | 'kept' | 'asked', number>> | null; stats: Record<string, import('../hooks/stats').Summary> | null }
22  }
23}
24