SLOPSHOPPER

automodel

Auto Model: picks the main model once per fresh conversation, locks it to protect the prompt cache, and hands small self-contained jobs to a helper.

newspinnerrowsguardcommandstatus
v0.1.4MITupdated 2026-10-25mugenracerman/automodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · automodel
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /automodel ⎿ automodel: Auto Model is off. Locked model: not picked yet. Level that chose it: none yet. Jobs sent to helpers this sess ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ Auto Model: Off
README

Auto Model

A Claude Code mod that picks the right model at the start of a conversation, then keeps the main conversation on that one model, so the prompt cache is never thrown away by switching back and forth.

Install

Works on macOS, Windows and Linux, in the terminal and in the Claude desktop app's Code tab.

1. Get Claude Code 2.1.293 or newer

Check your version:

claude --version

If it is older, update it:

claude update

If claude is not installed yet, install it first. On macOS or Linux:

curl -fsSL https://claude.ai/install.sh | bash

On Windows (PowerShell):

irm https://claude.ai/install.ps1 | iex

2. Install Auto Model

Either type this inside a running claude session:

/plugin install automodel --marketplace mugenracerman/automodel

Answer y to add the marketplace, then choose the user scope so it loads in every session.

Or run these two commands from a terminal (Terminal on macOS, PowerShell on Windows), which do the same:

claude plugin marketplace add mugenracerman/automodel
claude plugin install automodel@automodel --scope user

A user-scope install made from the terminal also loads in the desktop app's Code tab.

3. Turn it on

Start a new session and type /automodel on, or click Auto Model: Off in the status bar. It starts off on every machine; the switch is remembered per machine.

Update

claude plugin update automodel@automodel

Then start a new session, or run /reload-plugins in a running one.

Uninstall

claude plugin uninstall automodel@automodel

Turn it off first (/automodel off) so your next session starts on the model you used before.

Use

Auto Model starts off. Turn it on with /automodel on, or click Auto Model: Off in the status bar.

CommandWhat it does
/automodel onStarts sorting your typed prompts.
/automodel offStops sorting. The current session keeps its model; your next new session starts on the model you used before.
/automodel statusShows the locked model, the level that chose it, and how many jobs went to helpers this session.
/automodel repickSorts your next prompt fresh, up or down. A change of model rebuilds the cache once.

How it decides

  • Sorting. Before a typed prompt is sent, the smallest model in your install sorts it into small, everyday, strong or strongest (8 tokens, 4 seconds). If that fails, a guess from the prompt's words decides, strong when in doubt. The four levels map to the model lines your install lists (Haiku, Sonnet, Opus, Fable), stepping down when your plan lacks one.
  • When the model changes. On the first typed prompt of a new session, and the first after /clear or a compaction, it picks fresh. After that the model is locked. A later prompt that sorts higher steps up once; it never steps back down in the same session. A resumed session keeps the model it had. Turning it on mid-session locks to the model already in use.
  • Helpers. A later, smaller job that has real material to get through (files, or a long pasted text) and does not depend on the conversation gets a short note asking Claude to hand it to one helper agent on a smaller model. Short jobs stay on the locked model, which answers them from its cache.
  • Left alone. Short replies ("yes", "keep going"), slash commands, messages sent while Claude is working, and helper agents you start yourself.
  • Picking a model by hand (/model, /config, the app's picker) turns Auto Model off so the two never fight.

It sets the model through your model setting, so nothing appears in the chat; the model name in the status line is the only sign.

Develop

claude plugin validate .
claude plugin test .

claude --plugin-dir <this folder> runs it from disk for one session.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 482 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Level, Lock } from '../types'
5import {
6  EMPTY_LOCK,
7  LINE_ORDER,
8  SORTER_SYSTEM,
9  bigNumber,
10  decide,
11  guessLevel,
12  helperNote,
13  isShortReply,
14  kindOf,
15  learnId,
16  letGo,
17  levelOfLine,
18  lineFor,
19  linesDownFrom,
20  linesIn,
21  parseLevel,
22  stepModel,
23  title,
24  turnedOff,
25} from './logic'
26import type { KnownIds } from './logic'
27
28type Engine = EngineInterface
29
30const isOnAtom = atom({ plugin: 'automodel', key: 'isOn' } as const, false)
31const lockAtom = atom({ plugin: 'automodel', key: 'lock' } as const, EMPTY_LOCK)
32
33const GOLD = '#F2C46D'
34const HAIR = '#262B33'
35const MUTED = '#7D8590'
36const TEXT = '#E6EDF3'
37const GOOD = '#3DD68C'
38const WARN = '#E3B341'
39
40const TYPED = new Set(['composer', 'bridge', 'sdk'])
41const SORT_TIMEOUT_MS = 4000
42const SORT_MAX_TOKENS = 8
43
44/** The `/config` row that holds the session's model, when this install has one. */
45async function modelRow($: Engine) {
46  try {
47    const rows = await $.config.list()
48    return rows.find(row => row.key === 'model' && !row.isLocked)
49  } catch {
50    return undefined
51  }
52}
53
54/** The model lines this install offers: read from its model picker, else the Agent tool's lines. */
55async function installLines($: Engine): Promise<string[]> {
56  const row = await modelRow($)
57  const names = [...(row?.options ?? []), typeof row?.value === 'string' ? row.value : '']
58  const fromPicker = linesIn(names)
59  if (fromPicker.length >= 2) return fromPicker
60  try {
61    const agent = (await $.tool.list()).find(tool => tool.name === 'Agent')
62    const fromAgent = linesIn([agent?.description ?? ''])
63    if (fromAgent.length >= 2) return fromAgent
64  } catch {
65    // fall through to the lines the engine's aliases name
66  }
67  return [...LINE_ORDER]
68}
69
70/** Asks the smallest model for one level; the words of the prompt decide when it cannot. */
71export async function sortPrompt($: Engine, text: string, lines: readonly string[]): Promise<Level> {
72  const small = lineFor('small', lines) ?? 'haiku'
73  try {
74    const reply = await $.model.complete({
75      model: small,
76      system: SORTER_SYSTEM,
77      prompt: `Request:\n${text.slice(0, 6000)}\n\nLevel:`,
78      maxTokens: SORT_MAX_TOKENS,
79      timeoutMs: SORT_TIMEOUT_MS,
80    })
81    if (reply.isAnswered) {
82      const level = parseLevel(reply.text)
83      if (level !== undefined) return level
84    }
85  } catch {
86    // the guess below answers
87  }
88  return guessLevel(text)
89}
90
91/**
92 * Puts the main conversation on `wanted`, without a word in the chat. When the
93 * model setting refuses it (a plan without that line), the next line down that
94 * the setting takes is the one locked.
95 */
96async function setMain($: Engine, wanted: string, level: Level, lines: readonly string[]) {
97  const row = await modelRow($)
98  let line = wanted
99  let isViaConfig = false
100  if (row !== undefined) {
101    for (const candidate of linesDownFrom(wanted, lines)) {
102      const option = row.options?.find(o => o.toLowerCase().includes(candidate)) ?? candidate
103      try {
104        const done = await $.config.set({ key: 'model', value: option })
105        isViaConfig = done.deny === undefined
106      } catch {
107        isViaConfig = false
108      }
109      if (isViaConfig) {
110        line = candidate
111        break
112      }
113    }
114  }
115  const seenModel = await $.session.model()
116  await learnModel($, seenModel)
117  await update($, lockAtom, lock => ({ ...lock, line, level, isFresh: false, seenModel, pendingHelper: null }))
118  $.ui.status(isViaConfig ? undefined : `Model: ${title(line)}`)
119  await rememberLock($, line, level)
120}
121
122async function knownIds($: Engine): Promise<KnownIds> {
123  return ((await $.store.get('ids')) ?? {}) as KnownIds
124}
125
126/** Remembers the full id behind a model the engine named, so a step can name it later. */
127async function learnModel($: Engine, model: string) {
128  const ids = await knownIds($)
129  const learned = learnId(ids, model)
130  if (learned !== ids) await $.store.set('ids', learned)
131}
132
133async function rememberLock($: Engine, line: string, level: Level) {
134  const id = await $.session.id()
135  const saved = ((await $.store.get('locks')) ?? {}) as Record<string, { line: string; level: Level }>
136  const kept = Object.entries(saved).filter(([key]) => key !== id).slice(-49)
137  await $.store.set('locks', Object.fromEntries([...kept, [id, { line, level }]]))
138}
139
140/** Saves the switch and remembers which model to give back once it is off. */
141async function setOn($: Engine, isOn: boolean) {
142  await $.store.set('isOn', isOn)
143  await update($, isOnAtom, () => isOn)
144  if (isOn) {
145    const isRestorePending = (await $.store.get('restorePending')) === true
146    if (!isRestorePending) {
147      const own = await usersModel($)
148      const row = own === undefined ? await modelRow($) : undefined
149      const previous = own ?? (typeof row?.value === 'string' ? row.value : undefined)
150      if (previous !== undefined) await $.store.set('restoreModel', previous)
151    }
152    await $.store.set('restorePending', false)
153    const isUnderWay = (await $.session.turns()) > 0
154    const seenModel = await $.session.model()
155    const current = linesIn([seenModel])[0] ?? null
156    await update($, lockAtom, lock =>
157      lock.line === null && isUnderWay && current !== null
158        ? { ...lock, line: current, level: levelOfLine(current), isFresh: false, isPinned: false, seenModel }
159        : { ...lock, isPinned: false },
160    )
161    return
162  }
163  await update($, lockAtom, turnedOff)
164  await $.store.set('restorePending', true)
165  const lock = await read($, lockAtom)
166  if (lock.line === null) await restoreModel($)
167}
168
169/** The model as the person wrote it in their own settings (`opus`, not a pinned id). */
170async function usersModel($: Engine): Promise<string | undefined> {
171  try {
172    const own = (await $.settings.read({ source: 'user' })) as { model?: unknown }
173    return typeof own.model === 'string' && own.model !== '' ? own.model : undefined
174  } catch {
175    return undefined
176  }
177}
178
179/** Gives the person's own model back for new sessions, once Auto Model is off. */
180async function restoreModel($: Engine) {
181  const previous = await $.store.get('restoreModel')
182  if ((await $.store.get('restorePending')) !== true) return
183  if (typeof previous === 'string' && previous !== '') {
184    try {
185      await $.config.set({ key: 'model', value: previous })
186    } catch {
187      // nothing to give back on an install without the row
188    }
189  }
190  await $.store.set('restorePending', false)
191}
192
193/** A model picked by hand wins: Auto Model turns off and lets go. */
194async function handPicked($: Engine) {
195  await $.store.set('isOn', false)
196  await $.store.set('restorePending', false)
197  await update($, isOnAtom, () => false)
198  await update($, lockAtom, letGo)
199  $.ui.status(undefined)
200}
201
202async function toggle($: Engine) {
203  await setOn($, !(await read($, isOnAtom)))
204}
205
206function statusText(isOn: boolean, lock: Lock): string {
207  return [
208    `Auto Model is ${isOn ? 'on' : 'off'}.`,
209    `Locked model: ${title(lock.line)}.`,
210    `Level that chose it: ${lock.level ?? 'none yet'}.`,
211    `Jobs sent to helpers this session: ${lock.helpers}.`,
212  ].join(' ')
213}
214
215async function runCommand($: Engine, args: string): Promise<string> {
216  const word = args.trim().toLowerCase().split(/\s+/)[0] ?? ''
217  const isOn = await read($, isOnAtom)
218  const lock = await read($, lockAtom)
219  if (word === 'on') {
220    if (isOn) return 'Auto Model is already on.'
221    await setOn($, true)
222    return lock.line === null || lock.isFresh
223      ? 'Auto Model is on. Your next typed prompt picks the model.'
224      : `Auto Model is on. This session stays on ${title(lock.line)} and steps up only for a harder job.`
225  }
226  if (word === 'off') {
227    if (!isOn) return 'Auto Model is already off.'
228    await setOn($, false)
229    return lock.line === null
230      ? 'Auto Model is off.'
231      : `Auto Model is off. This session stays on ${title(lock.line)}; your next new session starts on the model you used before.`
232  }
233  if (word === 'status' || word === '') return statusText(isOn, lock)
234  if (word === 'repick') {
235    if (!isOn) return 'Auto Model is off. Turn it on with /automodel on first.'
236    await update($, lockAtom, now => ({ ...now, isFresh: true }))
237    return 'Your next prompt is sorted fresh and may move the model up or down. A change of model rebuilds the cache once.'
238  }
239  return 'Use /automodel on, /automodel off, /automodel status or /automodel repick.'
240}
241
242// Damon's Tools (it registers /damons-tools) shows every plugin's switch in its
243// own dashboard and keeps the footer to one button; while it is installed this
244// plugin leaves its footer toggle out. Looked up at most every few seconds.
245let dashboardSeen: { at: number; isThere: boolean } | undefined
246async function hasDashboard($: {
247  clock: { now: () => Promise<number> }
248  command: { list: () => Promise<readonly { name: string }[]> }
249}): Promise<boolean> {
250  // Where the lookup cannot run, keep the toggle.
251  try {
252    const now = await $.clock.now()
253    if (dashboardSeen && now - dashboardSeen.at < 5000) return dashboardSeen.isThere
254    const isThere = (await $.command.list()).some(c => c.name === 'damons-tools')
255    dashboardSeen = { at: now, isThere }
256    return isThere
257  } catch {
258    return false
259  }
260}
261
262export const register: Register = on => {
263  on('session.start', async ($, e, next) => {
264    await $.command.register({
265      name: 'automodel',
266      description: 'Auto Model: on, off, status, or repick the model on your next prompt',
267      argumentHint: 'on | off | status | repick',
268      immediate: true,
269    })
270    const isOn = (await $.store.get('isOn')) === true
271    // Saved even when unchanged: Damon's Tools reads `isOn` as this plugin's switch.
272    await $.store.set('isOn', isOn)
273    await update($, isOnAtom, () => isOn)
274    if (!isOn) await restoreModel($)
275
276    return next(e)
277  })
278
279  on('classic.SessionStart', async ($, e, next) => {
280    if (e.source === 'clear') {
281      await update($, lockAtom, lock => ({ ...lock, isFresh: true, helpers: 0, pendingHelper: null }))
282    } else if (e.source === 'compact') {
283      await update($, lockAtom, lock => ({ ...lock, isFresh: true }))
284    } else if (e.source === 'resume') {
285      const saved = ((await $.store.get('locks')) ?? {}) as Record<string, { line: string; level: Level }>
286      const had = saved[e.session_id]
287      const seenModel = await $.session.model()
288      const line = had?.line ?? linesIn([seenModel])[0] ?? null
289      await update($, lockAtom, () => ({
290        ...EMPTY_LOCK,
291        line,
292        level: had?.level ?? (line === null ? null : levelOfLine(line)),
293        isFresh: false,
294        seenModel,
295      }))
296    }
297
298    return next(e)
299  }).catch(($, e, next) => next(e))
300
301  on('session.end', async ($, e, next) => {
302    if (e.reason === 'clear') {
303      await update($, lockAtom, lock => ({ ...lock, isFresh: true, helpers: 0, pendingHelper: null }))
304    }
305
306    return next(e)
307  })
308
309  on('session.compact', async ($, e, next) => {
310    const done = await next(e)
311    if (e.agentId === undefined) await update($, lockAtom, lock => ({ ...lock, isFresh: true }))
312
313    return done
314  }).catch(($, e, next) => next(e))
315
316  on('prompt.submit', async ($, e, next) => {
317    if (!TYPED.has(e.origin.kind) || e.turnId !== undefined) return next(e)
318
319    const before = await read($, lockAtom)
320    if (before.seenModel !== null && (await $.session.model()) !== before.seenModel) {
321      await handPicked($)
322      return next(e)
323    }
324    if (!(await read($, isOnAtom))) return next(e)
325
326    const text = e.text.trim()
327    if (text.startsWith('/') || isShortReply(text)) return next(e)
328
329    const lines = await installLines($)
330    const level = await sortPrompt($, text, lines)
331    const lock = await read($, lockAtom)
332    const decision = decide(lock, level, kindOf(text))
333
334    if (decision.kind === 'set') {
335      const line = lineFor(decision.level, lines)
336      if (line !== undefined) await setMain($, line, decision.level, lines)
337      return next(e)
338    }
339    if (decision.kind === 'note') {
340      const line = lineFor(decision.level, lines)
341      if (line !== undefined && line !== lock.line) {
342        await update($, lockAtom, now => ({ ...now, pendingHelper: line }))
343        return next({ ...e, context: [...(e.context ?? []), helperNote(line)] })
344      }
345    }
346    await update($, lockAtom, now => ({ ...now, pendingHelper: null }))
347
348    return next(e)
349  }).catch(($, e, next) => next(e))
350
351  on('turn.step', async function* ($, e, next) {
352    if (e.agentId !== undefined) return yield* next(e)
353    await learnModel($, e.model)
354    const lock = await read($, lockAtom)
355    const isHeld = lock.isPinned || (await read($, isOnAtom))
356    // A step's model goes to the API as written, so it must be a full id:
357    // a bare alias (`haiku`) is refused with a 404.
358    const model = isHeld ? stepModel(e.model, lock.line, await knownIds($)) : undefined
359
360    return yield* next(model === undefined ? e : { ...e, model })
361  })
362
363  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
364    const lock = await read($, lockAtom)
365    if (lock.pendingHelper !== null && e.model === lock.pendingHelper) {
366      await update($, lockAtom, now => ({ ...now, helpers: now.helpers + 1, pendingHelper: null }))
367    }
368
369    return next(e)
370  }).catch(($, e, next) => next(e))
371
372  on('command.run', { command: 'model' }, async ($, e, next) => {
373    const before = await $.session.model()
374    const done = await next(e)
375    const isPerson = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
376    if (isPerson && (await $.session.model()) !== before) await handPicked($)
377
378    return done
379  }).catch(($, e, next) => next(e))
380
381  on('config.set', { key: 'model' }, async ($, e, next) => {
382    const done = await next(e)
383    const isPerson = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
384    if (isPerson && done.deny === undefined) await handPicked($)
385
386    return done
387  }).catch(($, e, next) => next(e))
388
389  on('command.run', { command: 'automodel' }, async ($, e) => ({ text: await runCommand($, e.args) })).catch(
390    ($, e, next) => (next.called ? next(e) : { text: 'Auto Model could not do that just now. Try again in a moment.' }),
391  )
392
393  // The button sits in the footer beside whatever else is drawn there: the
394  // engine's mode labels and other mods' buttons all come through `next`.
395  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
396    if (await hasDashboard($)) return next(e)
397    const { Box, Button } = $.ui.resolve(e)
398    const isOn = await read($, isOnAtom)
399    const theirs = await next(e)
400
401    return (
402      <Box flexDirection="row" columnGap={1}>
403        {theirs}
404        <Box backgroundColor={HAIR} paddingX={1}>
405          <Button key="automodel-toggle" plain label={`Auto Model: ${isOn ? 'On' : 'Off'}`} onPress={() => toggle($)} />
406        </Box>
407      </Box>
408    )
409  })
410
411  on('ui.render', { component: 'CommandOutput', props: { command: 'automodel' } }, async ($, e, next) => {
412    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
413    if (!/^\s*(status)?\s*$/i.test(e.props.args)) return next(e)
414
415    const { Box, Text, Button, Code } = $.ui.resolve(e)
416    const isOn = await read($, isOnAtom)
417    const lock = await read($, lockAtom)
418    const width = Math.max(24, (e.viewport?.columns ?? 80) - 4)
419    const rule = (lead: string, color: string) => (
420      <Box flexDirection="row" width={width}>
421        <Box flexShrink={0}>
422          <Text color={color}>{lead}</Text>
423        </Box>
424        <Box flexGrow={1} flexShrink={1} overflow="hidden">
425          <Text color={HAIR} wrap="truncate">{'─'.repeat(width)}</Text>
426        </Box>
427      </Box>
428    )
429    const row = (label: string, value: string, color: string) => (
430      <Box flexDirection="row" height={1} overflow="hidden">
431        <Box width={15} flexShrink={0}>
432          <Text color={MUTED} wrap="truncate">{label}</Text>
433        </Box>
434        <Text color={color} wrap="truncate">{value}</Text>
435      </Box>
436    )
437    const digits = bigNumber(lock.helpers)
438
439    return (
440      <Box flexDirection="column" width={width}>
441        <Text bold color={TEXT} wrap="truncate">AUTO MODEL</Text>
442        {rule('━'.repeat(10), GOLD)}
443        <Box marginTop={1} flexDirection="column">
444          {rule('SWITCH ', MUTED)}
445          {row('Auto Model', isOn ? 'On' : 'Off', isOn ? GOOD : WARN)}
446          {row('Locked model', title(lock.line), TEXT)}
447          {row('Chosen by', lock.level ?? 'none yet', TEXT)}
448        </Box>
449        <Box marginTop={1} flexDirection="column">
450          {rule('HELPERS ', MUTED)}
451          <Box flexDirection="row">
452            {e.surface === 'desktop' ? (
453              <Box flexShrink={0}>
454                <Code source={digits.join('\n')} wrap="truncate-end" />
455              </Box>
456            ) : (
457              <Box borderStyle="round" borderColor={HAIR} paddingX={1} flexDirection="column" flexShrink={0}>
458                <Text color={TEXT}>{digits[0]}</Text>
459                <Text color={TEXT}>{digits[1]}</Text>
460                <Text color={TEXT}>{digits[2]}</Text>
461              </Box>
462            )}
463            <Box paddingLeft={2} flexDirection="column" justifyContent="center">
464              <Text color={MUTED} wrap="truncate">jobs sent to helpers this session</Text>
465            </Box>
466          </Box>
467        </Box>
468        <Box marginTop={1} flexDirection="row" columnGap={1}>
469          <Box backgroundColor={GOLD} paddingX={1}>
470            <Button key="automodel-switch" plain label={isOn ? 'Turn off' : 'Turn on'} onPress={() => toggle($)} />
471          </Box>
472          {isOn && (
473            <Box backgroundColor={HAIR} paddingX={1}>
474              <Button key="automodel-repick" plain label="Repick next prompt" onPress={() => runCommand($, 'repick')} />
475            </Box>
476          )}
477        </Box>
478      </Box>
479    )
480  })
481}
482
hooks/logic.ts 210 lines
1import type { Level, Lock } from '../types'
2
3export const LEVELS: readonly Level[] = ['small', 'everyday', 'strong', 'strongest']
4
5/** The four model lines, least to most capable, as the engine names their aliases. */
6export const LINE_ORDER: readonly string[] = ['haiku', 'sonnet', 'opus', 'fable']
7
8export const EMPTY_LOCK: Lock = {
9  line: null,
10  level: null,
11  isFresh: true,
12  isPinned: false,
13  seenModel: null,
14  helpers: 0,
15  pendingHelper: null,
16}
17
18export const SORTER_SYSTEM = [
19  'Sort the request into exactly one level. Answer with the one word only.',
20  'small: quick, simple jobs. Summarize, sort, extract, rename, translate, reformat, a short factual answer.',
21  'everyday: an email, a post, a short draft, a small edit, a simple question about one file, a quick chart.',
22  'strong: the default for anything that matters. Building or changing software, multi-step projects, research, analysis, long documents, planning, anything touching several files or tools.',
23  'strongest: only the very hardest. Deep research that runs for hours, a very large build or migration, a problem the strong model would likely fall short on.',
24  'When unsure, say strong.',
25].join('\n')
26
27export const rank = (level: Level): number => LEVELS.indexOf(level)
28
29/** Reads the sorter's one-word answer; undefined when it named no level. */
30export function parseLevel(text: string): Level | undefined {
31  const word = text.toLowerCase().match(/\b(strongest|strong|everyday|small)\b/)
32  return word ? (word[1] as Level) : undefined
33}
34
35const SHORT_REPLIES = new Set([
36  'yes', 'y', 'yep', 'yeah', 'sure', 'ok', 'okay', 'k', 'no', 'nope', 'thanks', 'thank you',
37  'go', 'go ahead', 'do it', 'keep going', 'continue', 'carry on', 'sounds good', 'looks good',
38  'lgtm', 'perfect', 'great', 'nice', 'please do', 'yes please', 'ok do it', 'proceed', 'next',
39])
40
41/** A short reply to what Claude just said: fewer than four words, or a known go-ahead. */
42export function isShortReply(text: string): boolean {
43  const clean = text.trim().toLowerCase().replace(/[.!?,]+$/g, '').replace(/\s+/g, ' ')
44  if (clean === '') return true
45  if (SHORT_REPLIES.has(clean)) return true
46  return clean.split(' ').length < 4
47}
48
49const STRONGEST = /\b(for hours|overnight|deep research|entire (codebase|system|repo)|whole (codebase|system|repo)|large[- ]scale|migrat(e|ion) (the|our|all|every)|from scratch.*(platform|system)|very large)\b/
50const STRONG = /\b(build|implement|refactor|debug|fix|architect\w*|design|research|analy[sz]\w*|plan\w*|project|app|application|feature|bug|test(s|ing)?|deploy|migrat\w*|several files|multiple files|codebase|repo|pipeline|investigate|integrat\w*|optimi[sz]\w*)\b/
51const EVERYDAY = /\b(email|e-mail|post|tweet|letter|reply to|draft|note|message|chart|graph|small edit|tweak|this file|one file|blurb|caption|bio)\b/
52const SMALL = /\b(summari[sz]e|sort|extract|rename|translate|reformat|format|convert|list|define|what is|what's|who is|when (is|was|did)|spell|capitali[sz]e|count|tl;?dr)\b/
53
54/** Guesses the level from the prompt's words when the sorter cannot answer: strong when in doubt. */
55export function guessLevel(text: string): Level {
56  const t = text.toLowerCase()
57  if (STRONGEST.test(t)) return 'strongest'
58  if (STRONG.test(t)) return 'strong'
59  if (EVERYDAY.test(t)) return 'everyday'
60  if (SMALL.test(t) && t.split(/\s+/).length <= 40) return 'small'
61  return 'strong'
62}
63
64export type Kind = 'answer' | 'work' | 'contextual'
65
66const ACTION = /\b(read|open|summari[sz]e|rename|reformat|format|convert|translate|extract|sort|clean up|tidy|list|count|draft|rewrite|proofread|merge|split)\b/
67const MATERIAL = /\b(file|files|folder|folders|directory|csv|docx|pdf|spreadsheet|xlsx|logs?)\b|[\w-]+\.(md|txt|ts|tsx|js|py|json|csv|docx|pdf|xlsx|log|html|ya?ml)\b|[\w.-]+[\\/][\w.-]+/
68const CONTEXTUAL = /\b(this|that|it|these|those|above|earlier|before|previous|again|same|we|our|us|you said|you just|your (last|previous)|the plan|the change|the fix|continue|so far)\b/
69
70/** A pasted text this long is worth a helper's fresh start. */
71export const LONG_TEXT = 1500
72
73/**
74 * What a later, smaller prompt needs: a one-line answer, a self-contained job,
75 * or the conversation. A job is only `work` when it has material to get through
76 * (files, or a long pasted text): a helper starts cold, loading its tools and
77 * instructions, so a short translation or rewrite is cheaper from the locked
78 * model's cache.
79 */
80export function kindOf(text: string): Kind {
81  const t = text.toLowerCase()
82  const hasMaterial = MATERIAL.test(t) || text.length >= LONG_TEXT
83  if (CONTEXTUAL.test(t) && text.length < LONG_TEXT) return 'contextual'
84  if (ACTION.test(t) && hasMaterial) return 'work'
85  return 'answer'
86}
87
88/** Picks the line for a level from the lines this install offers, stepping down when it lacks one. */
89export function lineFor(level: Level, lines: readonly string[]): string | undefined {
90  const ordered = LINE_ORDER.filter(line => lines.includes(line))
91  if (ordered.length === 0) return undefined
92  const wanted = Math.min(rank(level), LINE_ORDER.length - 1)
93  for (let i = wanted; i >= 0; i -= 1) {
94    const line = LINE_ORDER[i]
95    if (line !== undefined && ordered.includes(line)) return line
96  }
97  return ordered[0]
98}
99
100/** The full model ids seen for each line (`opus` → `claude-opus-5-5`), learned from the engine. */
101export type KnownIds = Readonly<Record<string, string>>
102
103/** True for an id the API takes, as against a bare alias (`haiku`) that it rejects. */
104export function isFullId(model: string): boolean {
105  return !LINE_ORDER.includes(model.trim().toLowerCase()) && linesIn([model]).length === 1
106}
107
108/** Adds the line `model` serves to `ids` when it is a full id not known yet; the same object otherwise. */
109export function learnId(ids: KnownIds, model: string): KnownIds {
110  const line = linesIn([model])[0]
111  if (line === undefined || !isFullId(model) || ids[line] === model) return ids
112  return { ...ids, [line]: model }
113}
114
115/**
116 * The model a main-loop step should name to stay on `line`: undefined when it
117 * already does, or when no full id is known for the line yet. Never a bare
118 * alias, which a turn step sends to the API as it is.
119 */
120export function stepModel(model: string, line: string | null, ids: KnownIds): string | undefined {
121  if (line === null || model.toLowerCase().includes(line)) return undefined
122  return ids[line]
123}
124
125/**
126 * `line` and the lines below it that `lines` offers, most capable first: the
127 * order to try them in when the model setting refuses one (a plan without Fable).
128 */
129export function linesDownFrom(line: string, lines: readonly string[]): string[] {
130  const at = LINE_ORDER.indexOf(line)
131  return LINE_ORDER.slice(0, at + 1).filter(l => l === line || lines.includes(l)).reverse()
132}
133
134/** The level a line serves, for a lock read back. */
135export function levelOfLine(line: string): Level {
136  const at = LINE_ORDER.indexOf(line)
137  return LEVELS[Math.max(0, Math.min(at, LEVELS.length - 1))] ?? 'strong'
138}
139
140/** Finds the model lines named in a list of model names or ids, least capable first. */
141export function linesIn(names: readonly string[]): string[] {
142  const text = names.join(' ').toLowerCase()
143  return LINE_ORDER.filter(line => text.includes(line))
144}
145
146export type Decision =
147  | { kind: 'set'; level: Level; reason: 'fresh' | 'step-up' }
148  | { kind: 'note'; level: Level }
149  | { kind: 'keep' }
150
151/**
152 * What to do with one sorted prompt. Fresh: pick, up or down. Locked: step up
153 * once when it sorts higher, never down; a smaller self-contained job gets a
154 * helper note; everything else stays on the locked model.
155 */
156export function decide(lock: Lock, level: Level, kind: Kind): Decision {
157  if (lock.isFresh || lock.level === null) return { kind: 'set', level, reason: 'fresh' }
158  if (rank(level) > rank(lock.level)) return { kind: 'set', level, reason: 'step-up' }
159  if (rank(level) < rank(lock.level) && rank(level) <= rank('everyday') && kind === 'work') {
160    return { kind: 'note', level }
161  }
162  return { kind: 'keep' }
163}
164
165/** The note that asks Claude to hand a small self-contained job to one helper. */
166export function helperNote(line: string): string {
167  return [
168    'Auto Model note: this request is a small, self-contained job.',
169    `Hand it to exactly one helper agent with the Agent tool, model "${line}".`,
170    'Give the helper only what it needs for this job (the file paths or the text itself), wait for it, then pass its result back to the user in your own reply.',
171    'If the job turns out to depend on this conversation, do it yourself instead.',
172  ].join(' ')
173}
174
175/** The state after the plugin turns Auto Model off: sorting stops, the model stays. */
176export function turnedOff(lock: Lock): Lock {
177  return { ...lock, isPinned: lock.line !== null, pendingHelper: null }
178}
179
180/** The state after a pick by hand: Auto Model lets go of the model entirely. */
181export function letGo(lock: Lock): Lock {
182  return { ...lock, line: null, level: null, isPinned: false, seenModel: null, pendingHelper: null }
183}
184
185/**
186 * Heavy box-drawing digits, three rows tall, from the common heavy glyphs only
187 * (no half-line glyphs, which the desktop's fonts lack).
188 */
189const DIGITS: Record<string, readonly [string, string, string]> = {
190  '0': ['┏━┓', '┃ ┃', '┗━┛'],
191  '1': ['━┓ ', ' ┃ ', '━┻━'],
192  '2': ['━━┓', '┏━┛', '┗━━'],
193  '3': ['━━┓', ' ━┫', '━━┛'],
194  '4': ['┃ ┃', '┗━┫', '  ┃'],
195  '5': ['┏━━', '┗━┓', '━━┛'],
196  '6': ['┏━━', '┣━┓', '┗━┛'],
197  '7': ['━━┓', '  ┃', '  ┃'],
198  '8': ['┏━┓', '┣━┫', '┗━┛'],
199  '9': ['┏━┓', '┗━┫', '━━┛'],
200}
201
202export function bigNumber(n: number): [string, string, string] {
203  const glyphs = String(Math.max(0, Math.floor(n))).split('').map(d => DIGITS[d] ?? DIGITS['0']!)
204  return [0, 1, 2].map(row => glyphs.map(g => g[row]).join(' ')) as [string, string, string]
205}
206
207export function title(line: string | null): string {
208  return line === null ? 'not picked yet' : line.charAt(0).toUpperCase() + line.slice(1)
209}
210
types/index.d.ts 25 lines
1export type Level = 'small' | 'everyday' | 'strong' | 'strongest'
2
3export type Lock = {
4  /** The model line the main conversation is locked to (`opus`), or null before the first pick. */
5  line: string | null
6  /** The level the sorter gave when that line was chosen. */
7  level: Level | null
8  /** True when the next typed prompt may pick fresh: a new session, /clear, a compaction, /automodel repick. */
9  isFresh: boolean
10  /** True while the main thread should stay on `line` even with Auto Model off (turned off mid-session). */
11  isPinned: boolean
12  /** The session model as last seen after Auto Model set it; a different one later means a pick by hand. */
13  seenModel: string | null
14  /** Jobs handed to helper agents this session. */
15  helpers: number
16  /** The helper line a note just asked for, until the Agent call that answers it. */
17  pendingHelper: string | null
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    automodel: { isOn: boolean; lock: Lock }
23  }
24}
25