The dialog now explains what it asks about: the skill's description (from its SKILL.md frontmatter, five YAML forms, first sentence, cleaned), the agent's description (from agent.offer), the phase's new 'about' line in routing.json, and the real model id and effort the next step runs on. Options Later / Keep / Change; Change asks the model (fable, opus, sonnet, haiku with their tier role), then the effort among those the phases of that model offer, then the scope; the pair maps to an existing phase (rows stay phase names; the row's current phase wins a tie; same phase = Keep). A main-loop phase (T3) is Later / Keep only. A main-row change toasts the real decision and the /route switch hint when the pick is a downgrade. Descriptions pass a hardened read (name allowlist, stat kind, size cap) and clean(). Kit suite 190 → 232; W3-A dialog tests migrated. Contract .claude/tasks/contracts/2026-10-11-model-router-w3b-dialog-1240.md, plan r3: 3 lenses (2 BLOCKERs: inline rows, phase edits) + 1 confirmation, feater + 2 rounds, GATE 0 MET, verifier CONFORME (3rd pass), security PASS.
2815 lines
99 KiB
TypeScript
2815 lines
99 KiB
TypeScript
// model-router: routes each model request (main loop and sub-agents) to the
|
|
// model and effort its phase deserves. Phases name a TIER (an ordered list of
|
|
// aliases, the first available wins); a circuit breaker fed by the engine's
|
|
// own failure events marks a model down for a while. Phases, skill and agent
|
|
// rows come from routing.json (tracked next to this mod; the first use of a
|
|
// row asks the user once, see "first use" below), the rest from
|
|
// DEFAULT_CONFIG; ~/.claude/model-router.json overrides both per machine.
|
|
// One writer per concern: the Agent tool's own params are never rewritten,
|
|
// the spawn hook sets an agent's model once, turn.step sets efforts.
|
|
import type { EngineInterface, On, Register, TurnStepInput } from 'claude-code'
|
|
|
|
type Api = EngineInterface
|
|
type Level = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
|
type Route = {
|
|
tier?: string // a `tiers` key: the first AVAILABLE alias of its list
|
|
model?: string // alias or full id: explicit, never skipped when down
|
|
effort?: Level
|
|
}
|
|
type PromptMode = 'floor' | 'default'
|
|
type PromptRule = { pattern: string; phase: string; mode?: PromptMode }
|
|
type Config = {
|
|
models: Record<string, string> // alias -> full id
|
|
windows: Record<string, number> // full id -> context window (tokens)
|
|
phases: Record<string, Route>
|
|
agents: Record<string, string> // subagentType -> phase
|
|
skills: Record<string, string> // skill name -> phase
|
|
prompt: PromptRule[]
|
|
tiers: Record<string, string[]> // tier -> alias preference, best first
|
|
fallback: string[] // alias order, best first: the rank and the fallback chain
|
|
cooldownMinutes: number // breaker hold at the first strike
|
|
mainUpgrade: boolean // main may move UP to a phase's tier by itself
|
|
upgradeMaxTokens: number // above this context an upgrade is skipped
|
|
mainModelSwitch: boolean // gates DOWNGRADES only
|
|
verbose: boolean
|
|
spinner: boolean
|
|
enabled: boolean // false: every hook passes through (per machine)
|
|
}
|
|
type Rule = { re: RegExp; phase: string; mode: PromptMode }
|
|
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived'
|
|
type Routed = { phase: string; route: Route; source: Source }
|
|
type Hold = { until: number; reason: string } // until: ms, Infinity = reload
|
|
type Pushed = { prev: Routed | null; spawnIds: Set<string> }
|
|
type Loop = {
|
|
effort?: Level // an agent's model is fixed at spawn: effort is its only axis
|
|
explicitEffort: boolean // Agent call gave an effort: axis frozen
|
|
}
|
|
type State = {
|
|
cfg: Config
|
|
rules: Rule[]
|
|
source: string // 'defaults' or the override path
|
|
userMain: Routed | null // /route by the user, sticky until /route clear
|
|
turnMain: Routed | null // model route tool, skill table row, prompt
|
|
// default rule, derived orchestrate; dropped at turn end
|
|
runMain: Routed | null // best-tier skill row: spans the turns of a run;
|
|
// only a skill, /route clear|off or a user /model write or drop it
|
|
turnFloor: Routed | null // user-explicit level for this turn (prompt rule):
|
|
// a floor, main loop only
|
|
pendingPrompt: Routed | null // typed mid-turn: the next turn's floor
|
|
typedSlash: string | null // the rowed skill the user typed at prompt.submit
|
|
pendingSlash: string | null // same, typed mid-turn: promoted at turn end
|
|
promptAllowed: boolean // this turn's prompt came from a typing origin
|
|
pendingAllowed: boolean // same, for the mid-turn prompt
|
|
spawning: number // agent.spawn hooks in flight (preloads fire inside)
|
|
offers: Map<string, string> // subagentType -> definition source
|
|
descs: Map<string, string> // subagentType -> cleaned one-sentence offer line
|
|
loops: Map<string, Loop> // agentId -> that loop's routing
|
|
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
|
skillCalls: number // Skill tool calls in flight
|
|
off: boolean // /route off or config: every hook passes through
|
|
offConfig: boolean // `off` comes from the config key `enabled`
|
|
spinner: string // "model/effort" of the last main step of this turn
|
|
lastPlan: Plan | null // the plan last sent on main: the breaker's target
|
|
turnModel: string | undefined // sticky main model, set when the router moved
|
|
sessionModel: string // the main loop's model as /model shows it, '' unknown
|
|
down: Map<string, Hold> // canonical id -> unavailable until (breaker)
|
|
strikes: Map<string, number> // canonical id -> episodes, drives the backoff
|
|
agentModels: Map<string, string> // agentId -> exact id sent at spawn
|
|
pushed: Pushed | null // derived orchestrate in force (background dispatch)
|
|
logged: Set<string> // session-once log lines
|
|
turnLogged: Set<string> // turn-once log lines
|
|
warned: Set<string> // hooks whose fail-open was already logged
|
|
mem: Memory // what routing.json says about decisions, as last read
|
|
override: Rec | undefined // the machine override's data, if it loaded
|
|
loaded: boolean // the layers were read once (else: lazily, on first use)
|
|
rebuildDue: boolean // /clear: re-read the layers at the next prompt
|
|
toggles: { switch?: boolean; verbose?: boolean } // session toggles
|
|
asking: string | null // key of the one first-use dialog in flight
|
|
asked: Set<string> // keys answered (or failed) this session
|
|
writes: Promise<unknown> // the serialized routing.json write chain
|
|
}
|
|
type Log = (text: string) => void
|
|
type StepIn = Readonly<TurnStepInput>
|
|
type Plan = { model: string; effort: StepIn['effort'] }
|
|
type RouteInput = {
|
|
phase?: unknown
|
|
effort?: unknown
|
|
clear?: unknown
|
|
agentId?: string
|
|
}
|
|
type Picked = { phase: string; route: Route }
|
|
type Effort = StepIn['effort']
|
|
type EffortBy = 'floor' | 'sticky' | 'turn' | 'engine'
|
|
type Decision = { effort: Effort; by: EffortBy }
|
|
type Rec = Record<string, unknown>
|
|
type Table = Record<string, string>
|
|
type Row = 'skills' | 'agents' // a table of rows in routing.json
|
|
type Kind = Row | 'phases' // what a first-use decision is about
|
|
/** What routing.json says about decisions, as last read. */
|
|
type Memory = {
|
|
ok: boolean // the file was read: false = no dialog, no write
|
|
ask: boolean // dialogs on (the machine override wins over the file)
|
|
askLocal: boolean | undefined // the machine override's own `ask`
|
|
base: Record<Row, Table> // the file's rows, before any project layer
|
|
confirmed: Record<Kind, Table> // name -> the phase a Keep endorsed
|
|
about: Table // phase -> its one-line meaning, shown in the dialog only
|
|
local: Record<Row, Set<string>> // rows of projects[key] or the override
|
|
key: string | undefined // normalized repo key; undefined = no project layer
|
|
}
|
|
|
|
const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
|
|
const MODEL_ID = /^claude-[a-z0-9.-]+$/
|
|
const TOOL = 'mcp__model-router__route'
|
|
const BEST = 'best' // the tier whose skill rows hold for a whole run
|
|
// Origins that are a person typing: only these arm a typed slash.
|
|
const TYPED_ORIGINS: ReadonlySet<string> = new Set([
|
|
'composer', 'sdk', 'bridge',
|
|
])
|
|
// Definition sources of a foreign repo's own agent: its row is skipped.
|
|
const PROJECT_SOURCES: ReadonlySet<string> = new Set([
|
|
'projectSettings', 'localSettings',
|
|
])
|
|
// PostModelSwitch sources a person chose (auto and resume are not).
|
|
const USER_SWITCH: ReadonlySet<string> = new Set([
|
|
'command', 'picker', 'sdk',
|
|
])
|
|
const OVERRIDE = '.claude/model-router.json'
|
|
const ROUTING = 'routing.json' // next to plugin.json: the tracked source
|
|
// Key order of a written routing.json; other keys follow, untouched.
|
|
const ROUTING_ORDER: readonly string[] = [
|
|
'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask',
|
|
]
|
|
// First-use dialog: the answers, and what a question may carry of the outside.
|
|
const LATER = 'Later'
|
|
const KEEP = 'Keep'
|
|
const CHANGE = 'Change'
|
|
const EVERYWHERE = 'Everywhere'
|
|
const THIS_PROJECT = 'This project only'
|
|
const CHOICE_ALIASES: readonly string[] = ['fable', 'opus', 'sonnet', 'haiku']
|
|
const MAX_DESC = 140 // chars of a skill or agent description in a question
|
|
const MAX_ABOUT = 120 // chars of a phase `about`
|
|
const MAX_DESCS = 256 // agent descriptions kept (oldest dropped)
|
|
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
|
const ROWS: readonly Row[] = ['skills', 'agents']
|
|
const KINDS: readonly Kind[] = ['skills', 'agents', 'phases']
|
|
const HAIKU = 'claude-haiku'
|
|
const MAX_PATTERN = 200 // chars of a prompt-rule pattern
|
|
const MAX_PROMPT_SCAN = 4096 // chars of a prompt a rule is run against
|
|
const MAX_CONFIG_BYTES = 65536 // override file size
|
|
const MAX_AGENT_MODELS = 256 // agent -> model entries kept (oldest dropped)
|
|
const PREFIX = 'claude-'
|
|
const ONE_M = /\[1m\]$/ // the long-context variant of a model id
|
|
// Hold = cooldownMinutes x step: 15 -> 30 -> 60 -> 120 -> 300 by default.
|
|
const BACKOFF_STEPS: readonly number[] = [1, 2, 4, 8, 20]
|
|
// StopFailure kinds that mean "this model cannot serve now". The others
|
|
// (context limit, network, auth...) are not availability.
|
|
const UNAVAILABLE: ReadonlySet<string> = new Set([
|
|
'rate_limit', 'overloaded', 'billing_error', 'model_not_found',
|
|
])
|
|
const PHASE_KEY = /^[a-z][a-z0-9_-]{0,31}$/
|
|
|
|
const DEFAULT_CONFIG: Config = {
|
|
models: {
|
|
haiku: 'claude-haiku-4-5-20251001',
|
|
sonnet: 'claude-sonnet-5-5',
|
|
opus: 'claude-opus-5-5',
|
|
fable: 'claude-fable-5-1',
|
|
},
|
|
windows: { 'claude-haiku-4-5-20251001': 200000 },
|
|
// A tier is an ordered alias list: the first one not down is used.
|
|
tiers: {
|
|
best: ['fable', 'opus', 'sonnet'],
|
|
big: ['opus', 'fable', 'sonnet'],
|
|
work: ['sonnet', 'opus'],
|
|
cheap: ['haiku', 'sonnet'],
|
|
},
|
|
fallback: ['fable', 'opus', 'sonnet', 'haiku'], // rank = index, best first
|
|
cooldownMinutes: 15,
|
|
mainUpgrade: true,
|
|
upgradeMaxTokens: 200000, // an upgrade re-reads the whole context cold
|
|
phases: {
|
|
plan: { tier: 'best', effort: 'xhigh' },
|
|
reflect: { tier: 'best', effort: 'high' },
|
|
orchestrate: { tier: 'best', effort: 'medium' },
|
|
escalate: { tier: 'best', effort: 'max' },
|
|
judge: { tier: 'big', effort: 'xhigh' },
|
|
implement: { tier: 'work', effort: 'medium' },
|
|
write: { tier: 'work', effort: 'high' },
|
|
verify: { tier: 'work', effort: 'xhigh' },
|
|
explore: { tier: 'work', effort: 'medium' },
|
|
apply: { tier: 'work', effort: 'low' },
|
|
mechanical: { tier: 'cheap', effort: 'low' },
|
|
},
|
|
// Rows (skill/agent -> phase) live in routing.json, the tracked source;
|
|
// a project-level agent of the same name shadows its row (see spawnRoute).
|
|
agents: {},
|
|
skills: {},
|
|
// `floor` rules set the turn's minimum effort; `default` rules set the
|
|
// turn's route, which a route call or a skill overrides. Neither lowers.
|
|
prompt: [
|
|
{ pattern: '\\bultrathink\\b', phase: 'escalate', mode: 'floor' },
|
|
{
|
|
pattern: '(?<![\\p{L}\\p{N}-])(plan|planifie|planning|brainstorm|' +
|
|
'architecture|con[c\u00e7]ois|design)(?![\\p{L}\\p{N}-])',
|
|
phase: 'plan',
|
|
mode: 'default',
|
|
},
|
|
{
|
|
pattern: '(?<![\\p{L}\\p{N}-])(pourquoi|why|explique|explain|analyse|' +
|
|
'analyze|comprendre|understand|review|audit)(?![\\p{L}\\p{N}-])',
|
|
phase: 'reflect',
|
|
mode: 'default',
|
|
},
|
|
],
|
|
mainModelSwitch: false,
|
|
verbose: false,
|
|
spinner: true,
|
|
enabled: true,
|
|
}
|
|
|
|
// ---- config ----------------------------------------------------------
|
|
|
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
const isLevel = (v: unknown): v is Level => LEVELS.some(l => l === v)
|
|
const isFullId = (v: unknown): v is string =>
|
|
typeof v === 'string' && MODEL_ID.test(v)
|
|
const hasKey = (table: object, key: string): boolean =>
|
|
Object.hasOwn(table, key)
|
|
|
|
function isModelName(models: Record<string, string>, v: unknown): v is string {
|
|
return typeof v === 'string' && (hasKey(models, v) || isFullId(v))
|
|
}
|
|
|
|
function phaseRoute(cfg: Config, phase: string): Route | undefined {
|
|
return hasKey(cfg.phases, phase) ? cfg.phases[phase] : undefined
|
|
}
|
|
|
|
/** Merges a user table over a default one, dropping invalid entries. */
|
|
function mergeTable<T>(
|
|
base: Record<string, T>,
|
|
user: unknown,
|
|
name: string,
|
|
accept: (key: string, value: unknown) => T | undefined,
|
|
log: Log,
|
|
): Record<string, T> {
|
|
const out = { ...base }
|
|
if (!isRecord(user)) {
|
|
if (user !== undefined) {
|
|
log(`model-router: config ${name} ignored: not an object`)
|
|
}
|
|
return out
|
|
}
|
|
for (const [key, value] of Object.entries(user)) {
|
|
const ok = key === '__proto__' ? undefined : accept(key, value)
|
|
if (ok === undefined) log(`model-router: config ${name}.${key} ignored`)
|
|
else out[key] = ok
|
|
}
|
|
return out
|
|
}
|
|
|
|
const acceptModel = (_key: string, v: unknown): string | undefined =>
|
|
isFullId(v) ? v : undefined
|
|
|
|
const acceptWindow = (key: string, v: unknown): number | undefined =>
|
|
isFullId(key) && typeof v === 'number' && Number.isInteger(v) && v > 0
|
|
? v
|
|
: undefined
|
|
|
|
/** A phase names a tier OR a model, never both; a tier must exist. */
|
|
function acceptPhase(
|
|
models: Record<string, string>,
|
|
tiers: Record<string, string[]>,
|
|
) {
|
|
return (key: string, v: unknown): Route | undefined => {
|
|
if (!PHASE_KEY.test(key) || !isRecord(v)) return undefined
|
|
if (v.tier !== undefined && v.model !== undefined) return undefined
|
|
const route: Route = {}
|
|
if (v.effort !== undefined) {
|
|
if (!isLevel(v.effort)) return undefined
|
|
route.effort = v.effort
|
|
}
|
|
if (v.model !== undefined) {
|
|
if (!isModelName(models, v.model)) return undefined
|
|
route.model = v.model
|
|
}
|
|
if (v.tier !== undefined) {
|
|
if (typeof v.tier !== 'string' || !hasKey(tiers, v.tier)) return undefined
|
|
route.tier = v.tier
|
|
}
|
|
return route.effort || route.model || route.tier ? route : undefined
|
|
}
|
|
}
|
|
|
|
/** A tier: a non-empty alias list; unknown aliases dropped, duplicates too. */
|
|
function acceptTier(models: Record<string, string>, log: Log) {
|
|
return (key: string, v: unknown): string[] | undefined => {
|
|
// a tier named like an alias would make a bare name ambiguous
|
|
if (!PHASE_KEY.test(key) || hasKey(models, key)) return undefined
|
|
if (!Array.isArray(v)) return undefined
|
|
const items = v as unknown[]
|
|
const kept = items.filter(
|
|
(a): a is string => typeof a === 'string' && hasKey(models, a))
|
|
if (kept.length < items.length) {
|
|
log(`model-router: config tiers.${key} entries dropped`)
|
|
}
|
|
return kept.length > 0 ? [...new Set(kept)] : undefined
|
|
}
|
|
}
|
|
|
|
/** Appends the `models` aliases a user's chain omits, so all are ranked. */
|
|
function withEveryAlias(
|
|
chain: string[],
|
|
models: Record<string, string>,
|
|
log: Log,
|
|
): string[] {
|
|
const missing = Object.keys(models).filter(a => !chain.includes(a))
|
|
if (missing.length > 0) {
|
|
log(`model-router: config fallback lacks ${missing.join(', ')}; appended`)
|
|
}
|
|
return [...chain, ...missing]
|
|
}
|
|
|
|
/** The fallback chain: deduplicated aliases of `models`, else the default. */
|
|
function pickFallback(
|
|
user: unknown,
|
|
base: string[],
|
|
models: Record<string, string>,
|
|
log: Log,
|
|
): string[] {
|
|
if (user === undefined) return base
|
|
const items = Array.isArray(user) ? (user as unknown[]) : []
|
|
const kept = items.filter(
|
|
(a): a is string => typeof a === 'string' && hasKey(models, a))
|
|
if (kept.length > 0) return withEveryAlias([...new Set(kept)], models, log)
|
|
log('model-router: config fallback ignored: need a list of model aliases')
|
|
return base
|
|
}
|
|
|
|
function pickPositive(v: unknown, fallback: number, name: string, log: Log) {
|
|
if (v === undefined) return fallback
|
|
if (typeof v === 'number' && Number.isInteger(v) && v > 0) return v
|
|
log(`model-router: config ${name} ignored: not a positive integer`)
|
|
return fallback
|
|
}
|
|
|
|
function acceptPhaseRef(phases: Record<string, Route>) {
|
|
return (_key: string, v: unknown): string | undefined =>
|
|
typeof v === 'string' && PHASE_KEY.test(v) && hasKey(phases, v)
|
|
? v
|
|
: undefined
|
|
}
|
|
|
|
/** Unicode-aware first; a pattern only valid without `u` still works. */
|
|
function buildRegex(pattern: string): RegExp | undefined {
|
|
for (const flags of ['iu', 'i']) {
|
|
try {
|
|
return new RegExp(pattern, flags)
|
|
} catch {
|
|
// retry with the next flag set
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Absent `mode` = floor: override files written before modes keep meaning. */
|
|
function acceptRule(
|
|
phases: Record<string, Route>,
|
|
v: unknown,
|
|
): PromptRule | undefined {
|
|
if (!isRecord(v)) return undefined
|
|
const { pattern, phase, mode } = v
|
|
if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined
|
|
if (mode !== undefined && mode !== 'floor' && mode !== 'default') {
|
|
return undefined
|
|
}
|
|
if (pattern.length > MAX_PATTERN) return undefined
|
|
return hasKey(phases, phase) && buildRegex(pattern)
|
|
? { pattern, phase, mode: mode ?? 'floor' }
|
|
: undefined
|
|
}
|
|
|
|
/** A user prompt array replaces the default rules; bad rules are dropped. */
|
|
function mergePrompt(
|
|
base: PromptRule[],
|
|
user: unknown,
|
|
phases: Record<string, Route>,
|
|
log: Log,
|
|
): PromptRule[] {
|
|
if (!Array.isArray(user)) {
|
|
if (user !== undefined) {
|
|
log('model-router: config prompt ignored: not a list')
|
|
}
|
|
return base
|
|
}
|
|
const rules: PromptRule[] = []
|
|
for (const item of user as unknown[]) {
|
|
const rule = acceptRule(phases, item)
|
|
if (rule) rules.push(rule)
|
|
else log('model-router: config prompt rule ignored')
|
|
}
|
|
return rules
|
|
}
|
|
|
|
const pickBool = (v: unknown, fallback: boolean): boolean =>
|
|
typeof v === 'boolean' ? v : fallback
|
|
|
|
/** The kill switch: a non-boolean value is dropped, and said so. */
|
|
function pickEnabled(v: unknown, fallback: boolean, log: Log): boolean {
|
|
if (v !== undefined && typeof v !== 'boolean') {
|
|
log('model-router: config "enabled" is not a boolean; ignored')
|
|
}
|
|
return pickBool(v, fallback)
|
|
}
|
|
|
|
type Routing = Pick<Config, 'tiers' | 'fallback' | 'cooldownMinutes' |
|
|
'mainUpgrade' | 'upgradeMaxTokens'>
|
|
|
|
/** Tiers, fallback chain and breaker knobs, validated against `models`. */
|
|
function mergeRouting(
|
|
base: Config,
|
|
user: Record<string, unknown>,
|
|
models: Record<string, string>,
|
|
log: Log,
|
|
): Routing {
|
|
const tiers = mergeTable(
|
|
base.tiers, user.tiers, 'tiers', acceptTier(models, log), log)
|
|
const fallback = pickFallback(user.fallback, base.fallback, models, log)
|
|
if (tiers.best?.[0] !== fallback[0]) {
|
|
log('model-router: tiers.best does not lead the fallback chain; rank ' +
|
|
'comes from the fallback list alone')
|
|
}
|
|
return {
|
|
tiers,
|
|
fallback,
|
|
cooldownMinutes: pickPositive(
|
|
user.cooldownMinutes, base.cooldownMinutes, 'cooldownMinutes', log),
|
|
mainUpgrade: pickBool(user.mainUpgrade, base.mainUpgrade),
|
|
upgradeMaxTokens: pickPositive(
|
|
user.upgradeMaxTokens, base.upgradeMaxTokens, 'upgradeMaxTokens', log),
|
|
}
|
|
}
|
|
|
|
/** A row table: `name: null` in the override drops a default row. */
|
|
function mergeRows(
|
|
base: Record<string, string>,
|
|
user: unknown,
|
|
name: string,
|
|
ref: (key: string, value: unknown) => string | undefined,
|
|
log: Log,
|
|
): Record<string, string> {
|
|
const entries = isRecord(user) ? Object.entries(user) : []
|
|
const kept = isRecord(user)
|
|
? Object.fromEntries(entries.filter(([, v]) => v !== null))
|
|
: user
|
|
const rows = mergeTable(base, kept, name, ref, log)
|
|
for (const [key, value] of entries) {
|
|
if (value === null && key !== '__proto__') delete rows[key]
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/** `seed` (the defaults) overlaid with the user's entries, each validated. */
|
|
function mergeConfig(
|
|
user: unknown,
|
|
log: Log,
|
|
seed: Config = DEFAULT_CONFIG,
|
|
): Config {
|
|
const base = structuredClone(seed)
|
|
if (!isRecord(user)) {
|
|
if (user !== undefined) log('model-router: config ignored: not an object')
|
|
return base
|
|
}
|
|
const models = mergeTable(
|
|
base.models, user.models, 'models', acceptModel, log)
|
|
const routing = mergeRouting(base, user, models, log)
|
|
const phases = mergeTable(base.phases, user.phases, 'phases',
|
|
acceptPhase(models, routing.tiers), log)
|
|
const ref = acceptPhaseRef(phases)
|
|
return {
|
|
models,
|
|
...routing,
|
|
windows: mergeTable(
|
|
base.windows, user.windows, 'windows', acceptWindow, log),
|
|
phases,
|
|
agents: mergeRows(base.agents, user.agents, 'agents', ref, log),
|
|
skills: mergeRows(base.skills, user.skills, 'skills', ref, log),
|
|
prompt: mergePrompt(base.prompt, user.prompt, phases, log),
|
|
mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch),
|
|
verbose: pickBool(user.verbose, base.verbose),
|
|
spinner: pickBool(user.spinner, base.spinner),
|
|
enabled: pickEnabled(user.enabled, base.enabled, log),
|
|
}
|
|
}
|
|
|
|
/** The file's text, or undefined (logged) when it exceeds the cap. */
|
|
async function readCapped(
|
|
$: Api,
|
|
path: string,
|
|
label: string,
|
|
log: Log,
|
|
): Promise<string | undefined> {
|
|
const tooBig = `model-router: ${label} over ${MAX_CONFIG_BYTES} bytes`
|
|
if ((await $.fs.stat(path)).size > MAX_CONFIG_BYTES) {
|
|
log(tooBig)
|
|
return undefined
|
|
}
|
|
const text = await $.fs.read(path)
|
|
if (text.length <= MAX_CONFIG_BYTES) return text
|
|
log(tooBig)
|
|
return undefined
|
|
}
|
|
|
|
type Layer = { path: string; data: Rec } | 'absent' | 'failed'
|
|
|
|
/** 'absent': no file (defaults apply). 'failed': present but unusable. */
|
|
async function readLayer(
|
|
$: Api,
|
|
path: string,
|
|
label: string,
|
|
log: Log,
|
|
): Promise<Layer> {
|
|
try {
|
|
if (!(await $.fs.exists(path))) return 'absent'
|
|
const text = await readCapped($, path, label, log)
|
|
if (text === undefined) return 'failed'
|
|
const data: unknown = JSON.parse(text)
|
|
if (isRecord(data)) return { path, data }
|
|
log(`model-router: ${label} is not an object`)
|
|
return 'failed'
|
|
} catch (err) {
|
|
log(`model-router: ${label} unreadable (${String(err)})`)
|
|
return 'failed'
|
|
}
|
|
}
|
|
|
|
const readRouting = ($: Api, log: Log): Promise<Layer> =>
|
|
readLayer($, `${$.plugin.root}/${ROUTING}`, ROUTING, log)
|
|
|
|
async function readOverride($: Api, log: Log): Promise<Layer> {
|
|
try {
|
|
const home = await $.env.get('HOME')
|
|
if (!home) return 'absent'
|
|
return await readLayer($, `${home}/${OVERRIDE}`, OVERRIDE, log)
|
|
} catch (err) {
|
|
log(`model-router: ${OVERRIDE} unreadable (${String(err)})`)
|
|
return 'failed'
|
|
}
|
|
}
|
|
|
|
// ---- routing.json: decision memory and project exceptions --------------
|
|
|
|
const strTable = (v: unknown): Table =>
|
|
isRecord(v)
|
|
? Object.fromEntries(Object.entries(v).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === 'string'))
|
|
: {}
|
|
|
|
const subRec = (v: unknown, key: string): Rec => {
|
|
const found = isRecord(v) && hasKey(v, key) ? v[key] : undefined
|
|
return isRecord(found) ? found : {}
|
|
}
|
|
|
|
/** The rows of `projects[key]`: this repo's exceptions. */
|
|
function projectRows(file: Rec, key: string | undefined): Record<Row, Table> {
|
|
const mine = key === undefined ? {} : subRec(file.projects, key)
|
|
return { skills: strTable(mine.skills), agents: strTable(mine.agents) }
|
|
}
|
|
|
|
function emptyMemory(): Memory {
|
|
return {
|
|
ok: false,
|
|
ask: true,
|
|
askLocal: undefined,
|
|
base: { skills: {}, agents: {} },
|
|
confirmed: { skills: {}, agents: {}, phases: {} },
|
|
about: {},
|
|
local: { skills: new Set(), agents: new Set() },
|
|
key: undefined,
|
|
}
|
|
}
|
|
|
|
/** The phases' `about` lines; a non-string or overlong one is dropped, said. */
|
|
function aboutOf(phases: unknown, log: Log): Table {
|
|
const out: Table = {}
|
|
if (!isRecord(phases)) return out
|
|
for (const [name, phase] of Object.entries(phases)) {
|
|
const about = isRecord(phase) ? phase.about : undefined
|
|
if (about === undefined || name === '__proto__') continue
|
|
if (typeof about === 'string' && about.length <= MAX_ABOUT) {
|
|
out[name] = about
|
|
} else {
|
|
log(`model-router: ${ROUTING} phases.${name}.about ignored`)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** What the file and the machine override say about decisions. */
|
|
function buildMemory(
|
|
file: Rec,
|
|
key: string | undefined,
|
|
override: Rec | undefined,
|
|
log: Log,
|
|
): Memory {
|
|
const project = projectRows(file, key)
|
|
const flag = override?.ask
|
|
const askLocal = typeof flag === 'boolean' ? flag : undefined
|
|
const sure = subRec(file, 'confirmed')
|
|
const local = (row: Row): Set<string> => new Set([
|
|
...Object.keys(project[row]), ...Object.keys(strTable(override?.[row])),
|
|
])
|
|
return {
|
|
ok: true,
|
|
ask: askLocal ?? file.ask !== false,
|
|
askLocal,
|
|
base: { skills: strTable(file.skills), agents: strTable(file.agents) },
|
|
confirmed: {
|
|
skills: strTable(sure.skills),
|
|
agents: strTable(sure.agents),
|
|
phases: strTable(sure.phases),
|
|
},
|
|
about: aboutOf(file.phases, log),
|
|
local: { skills: local('skills'), agents: local('agents') },
|
|
key,
|
|
}
|
|
}
|
|
|
|
const SCP_REMOTE = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):([^:@]+)$/
|
|
const URL_REMOTE = /^[a-z][a-z0-9+.-]*:\/\//i
|
|
|
|
/** [host, path] of a remote, userinfo never read; undefined if unclear. */
|
|
function remoteParts(url: string): [string, string] | undefined {
|
|
const scp = SCP_REMOTE.exec(url)
|
|
if (scp) return [scp[1] as string, scp[2] as string]
|
|
if (!URL_REMOTE.test(url)) return undefined
|
|
// a second `@` in the authority hides a secret in the userinfo
|
|
const authority = url.replace(URL_REMOTE, '').split('/')[0] ?? ''
|
|
if (authority.split('@').length > 2) return undefined
|
|
try {
|
|
const u = new URL(url)
|
|
return [u.host, u.pathname]
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A repo's identity across clones and machines: its origin remote reduced to
|
|
* `host/path` (scheme, userinfo, `.git` and trailing slash dropped, host
|
|
* lowercased). A remote that cannot be read with certainty, or that leaves
|
|
* an `@` or `:` in the host or an `@` in the path, gives undefined: no
|
|
* credential ever reaches the tracked file.
|
|
*/
|
|
function normalizeRemote(url: string): string | undefined {
|
|
const parts = remoteParts(url.trim())
|
|
if (parts === undefined) return undefined
|
|
const host = parts[0].toLowerCase()
|
|
const path = parts[1].replace(/^\/+/, '').replace(/\/+$/, '')
|
|
.replace(/\.git$/i, '')
|
|
if (host === '' || path === '' || path.includes('@')) return undefined
|
|
return /[@:]/.test(host.replace(/:\d+$/, '')) ? undefined : `${host}/${path}`
|
|
}
|
|
|
|
async function projectKey($: Api): Promise<string | undefined> {
|
|
try {
|
|
const repo = await $.session.repo()
|
|
return repo?.remote ? normalizeRemote(repo.remote) : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** routing.json's phases and rows as a user table, this repo's rows last. */
|
|
function routingTables(file: Rec, key: string | undefined): Rec {
|
|
const project = projectRows(file, key)
|
|
return {
|
|
phases: file.phases,
|
|
skills: { ...strTable(file.skills), ...project.skills },
|
|
agents: { ...strTable(file.agents), ...project.agents },
|
|
}
|
|
}
|
|
|
|
type Loaded = {
|
|
cfg: Config
|
|
source: string
|
|
mem: Memory
|
|
override: Rec | undefined
|
|
}
|
|
|
|
function sourceWord(file: Layer, over: Layer): string {
|
|
const fileWord = typeof file === 'string' ? undefined : ROUTING
|
|
const overWord = typeof over === 'string' ? undefined : over.path
|
|
if (fileWord && overWord) return `${fileWord} + ${overWord}`
|
|
return fileWord ?? overWord ?? 'defaults'
|
|
}
|
|
|
|
/** A phase routing.json lacks keeps the code default: one line names them. */
|
|
function logMissingPhases(phases: unknown, log: Log): void {
|
|
if (phases !== undefined && !isRecord(phases)) return // mergeTable says it
|
|
const missing = Object.keys(DEFAULT_CONFIG.phases)
|
|
.filter(name => !isRecord(phases) || !hasKey(phases, name))
|
|
if (missing.length === 0) return
|
|
log(`model-router: ${ROUTING} lacks phases ${missing.join(', ')}; ` +
|
|
'code defaults kept')
|
|
}
|
|
|
|
/**
|
|
* Defaults < routing.json < the machine override. The phases of both files
|
|
* merge first; every layer's rows then validate against that final table,
|
|
* so a routing.json row may name a phase only the override defines.
|
|
*/
|
|
function layerConfig(
|
|
tables: Rec | undefined,
|
|
override: Rec | undefined,
|
|
log: Log,
|
|
): Config {
|
|
const seeded = mergeConfig(tables && { phases: tables.phases }, log)
|
|
const cfg = mergeConfig(
|
|
override && { ...override, skills: undefined, agents: undefined },
|
|
log, seeded)
|
|
const ref = acceptPhaseRef(cfg.phases)
|
|
for (const row of ROWS) {
|
|
const base = mergeRows(cfg[row], tables?.[row], row, ref, log)
|
|
cfg[row] = mergeRows(base, override?.[row], row, ref, log)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
/** Defaults < routing.json (phases, rows, this repo's rows) < override. */
|
|
async function assemble(
|
|
$: Api,
|
|
file: Layer,
|
|
over: Layer,
|
|
log: Log,
|
|
): Promise<Loaded> {
|
|
const fileData = typeof file === 'string' ? undefined : file.data
|
|
const override = typeof over === 'string' ? undefined : over.data
|
|
if (file === 'absent') {
|
|
log(`model-router: ${ROUTING} missing; default phases, no rows, no asks`)
|
|
}
|
|
const key = fileData === undefined ? undefined : await projectKey($)
|
|
if (fileData !== undefined) logMissingPhases(fileData.phases, log)
|
|
const cfg = layerConfig(
|
|
fileData && routingTables(fileData, key), override, log)
|
|
const mem = fileData === undefined
|
|
? emptyMemory()
|
|
: buildMemory(fileData, key, override, log)
|
|
return { cfg, source: sourceWord(file, over), mem, override }
|
|
}
|
|
|
|
/**
|
|
* Reads the layers. At the first load a failed layer is skipped (logged); in
|
|
* every later read any failure, or a routing.json gone missing, returns
|
|
* undefined so the caller keeps the whole previous config.
|
|
*/
|
|
async function loadLayers(
|
|
$: Api,
|
|
first: boolean,
|
|
log: Log,
|
|
): Promise<Loaded | undefined> {
|
|
const file = await readRouting($, log)
|
|
const over = await readOverride($, log)
|
|
const broken = file === 'failed' || file === 'absent' || over === 'failed'
|
|
if (broken && !first) return undefined
|
|
return assemble($, file, over, log)
|
|
}
|
|
|
|
/** Compiles the prompt rules; one that fails to compile is dropped, logged. */
|
|
function compileRules(cfg: Config, log: Log): Rule[] {
|
|
const rules: Rule[] = []
|
|
for (const r of cfg.prompt) {
|
|
const re = buildRegex(r.pattern)
|
|
if (re) rules.push({ re, phase: r.phase, mode: r.mode ?? 'floor' })
|
|
else log(`model-router: prompt rule for ${r.phase} does not compile`)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// ---- state -----------------------------------------------------------
|
|
|
|
function newState(cfg: Config, source: string): State {
|
|
return {
|
|
cfg,
|
|
rules: compileRules(cfg, () => undefined),
|
|
source,
|
|
userMain: null,
|
|
turnMain: null,
|
|
runMain: null,
|
|
turnFloor: null,
|
|
pendingPrompt: null,
|
|
typedSlash: null,
|
|
pendingSlash: null,
|
|
promptAllowed: false,
|
|
pendingAllowed: false,
|
|
spawning: 0,
|
|
offers: new Map(),
|
|
descs: new Map(),
|
|
loops: new Map(),
|
|
explicitEffort: new Map(),
|
|
skillCalls: 0,
|
|
off: false,
|
|
offConfig: false,
|
|
spinner: '',
|
|
lastPlan: null,
|
|
turnModel: undefined,
|
|
sessionModel: '',
|
|
down: new Map(),
|
|
strikes: new Map(),
|
|
agentModels: new Map(),
|
|
pushed: null,
|
|
logged: new Set(),
|
|
turnLogged: new Set(),
|
|
warned: new Set(),
|
|
mem: emptyMemory(),
|
|
override: undefined,
|
|
loaded: false,
|
|
rebuildDue: false,
|
|
toggles: {},
|
|
asking: null,
|
|
asked: new Set(),
|
|
writes: Promise.resolve(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* /clear rebuilds the state but keeps what is account- or process-wide:
|
|
* the breaker (a model's quota outlives the conversation), the model, the
|
|
* agent definitions' sources (the listing is not offered again), the config
|
|
* with its session toggles and the dialog in flight. The layers are read
|
|
* again at the next prompt; the config is swapped only if that read works.
|
|
*/
|
|
function resetSession(st: State): void {
|
|
const kept = {
|
|
down: st.down,
|
|
strikes: st.strikes,
|
|
agentModels: st.agentModels,
|
|
sessionModel: st.sessionModel,
|
|
offers: st.offers,
|
|
descs: st.descs,
|
|
mem: st.mem,
|
|
override: st.override,
|
|
loaded: st.loaded,
|
|
toggles: st.toggles,
|
|
asking: st.asking,
|
|
writes: st.writes,
|
|
}
|
|
Object.assign(st, newState(st.cfg, st.source), kept)
|
|
st.rebuildDue = st.loaded
|
|
}
|
|
|
|
/** Logs a hook's fail-open once per session; never throws itself. */
|
|
function warnOnce(st: State, $: Api, hook: string, kind: string): void {
|
|
if (st.warned.has(hook)) return
|
|
st.warned.add(hook)
|
|
try {
|
|
$.ui.log(`model-router: ${hook} failed (${kind}): ` +
|
|
'routing skipped for this event')
|
|
} catch {
|
|
// a failing log must not break the fail-open itself
|
|
}
|
|
}
|
|
|
|
/** Runs post-`next` bookkeeping so its failure can never re-run `next`. */
|
|
function safely(st: State, $: Api, hook: string, work: () => void): void {
|
|
try {
|
|
work()
|
|
} catch {
|
|
warnOnce(st, $, hook, 'bookkeeping')
|
|
}
|
|
}
|
|
|
|
// ---- models: ids, tiers, breaker, the main decision -------------------
|
|
|
|
/** The table id an alias, a `[1m]` variant or a dated id stands for. */
|
|
function canonical(cfg: Config, id: string): string {
|
|
const bare = id.replace(ONE_M, '')
|
|
if (hasKey(cfg.models, bare)) return cfg.models[bare] ?? bare
|
|
if (!bare.startsWith(PREFIX) || bare.length <= PREFIX.length) return bare
|
|
const hit = Object.values(cfg.models).find(
|
|
known => bare.startsWith(known))
|
|
return hit ?? bare
|
|
}
|
|
|
|
/** The table alias of a canonical id; undefined for an id the table lacks. */
|
|
const aliasOf = (cfg: Config, id: string): string | undefined =>
|
|
Object.keys(cfg.models).find(alias => cfg.models[alias] === id)
|
|
|
|
/** Position in the fallback chain, best first; undefined when unranked. */
|
|
function modelRank(cfg: Config, id: string): number | undefined {
|
|
const alias = aliasOf(cfg, id)
|
|
const at = alias === undefined ? -1 : cfg.fallback.indexOf(alias)
|
|
return at < 0 ? undefined : at
|
|
}
|
|
|
|
const routeName = (r: Route | null | undefined): string | undefined =>
|
|
r?.tier ?? r?.model
|
|
|
|
/** First alias of the list whose model is not down. */
|
|
function availableIn(st: State, aliases: readonly string[]) {
|
|
for (const alias of aliases) {
|
|
const id = st.cfg.models[alias]
|
|
if (id !== undefined && !st.down.has(id)) return id
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** The first model not down in the fallback chain, after `after`'s place. */
|
|
function nextAvailable(st: State, after: string | undefined) {
|
|
const { fallback, models } = st.cfg
|
|
const at = fallback.findIndex(alias => models[alias] === after)
|
|
return availableIn(st, fallback.slice(at + 1))
|
|
}
|
|
|
|
/**
|
|
* A tier, alias or id as an id to run on. A tier skips down aliases, then
|
|
* walks the global chain (from `after`, default the tier's last alias); an
|
|
* alias or id is explicit and never skipped.
|
|
*/
|
|
function resolveName(st: State, name: string, after?: string) {
|
|
const tier = hasKey(st.cfg.tiers, name) ? st.cfg.tiers[name] : undefined
|
|
if (tier === undefined) return canonical(st.cfg, name)
|
|
const last = tier[tier.length - 1]
|
|
const from = after ?? (last === undefined ? undefined : st.cfg.models[last])
|
|
return availableIn(st, tier) ?? nextAvailable(st, from)
|
|
}
|
|
|
|
function resolveRoute(st: State, route: Route | undefined) {
|
|
const name = routeName(route)
|
|
return name === undefined ? undefined : resolveName(st, name)
|
|
}
|
|
|
|
/** Drops lapsed holds; the clock is read only while something is held. */
|
|
async function prune($: Api, st: State): Promise<number> {
|
|
if (st.down.size === 0) return 0
|
|
const now = await $.clock.now()
|
|
for (const [id, hold] of st.down) {
|
|
if (hold.until <= now) st.down.delete(id)
|
|
}
|
|
return now
|
|
}
|
|
|
|
const holdWord = (until: number, now: number): string =>
|
|
until === Infinity
|
|
? 'until reload'
|
|
: `for ${Math.max(1, Math.ceil((until - now) / 60000))} min`
|
|
|
|
function holdMinutes(cfg: Config, strikes: number, reason: string): number {
|
|
if (reason === 'model_not_found') return Infinity
|
|
const step = BACKOFF_STEPS[Math.min(strikes, BACKOFF_STEPS.length) - 1] ?? 1
|
|
return cfg.cooldownMinutes * step
|
|
}
|
|
|
|
/**
|
|
* Marks a table model down for an episode. A mark on a model already down
|
|
* adds no strike and no log, except `model_not_found`, which lengthens a
|
|
* timed hold to "until reload". Inert while the router is off.
|
|
*/
|
|
function markDown(
|
|
$: Api,
|
|
st: State,
|
|
id: string,
|
|
reason: string,
|
|
now: number,
|
|
): void {
|
|
if (st.off || aliasOf(st.cfg, id) === undefined) return
|
|
const held = st.down.get(id)
|
|
const lengthen = reason === 'model_not_found' && held?.until !== Infinity
|
|
if (held && !lengthen) return
|
|
const strikes = (st.strikes.get(id) ?? 0) + (held ? 0 : 1)
|
|
st.strikes.set(id, strikes)
|
|
const until = now + holdMinutes(st.cfg, strikes, reason) * 60000
|
|
st.down.set(id, { until, reason })
|
|
$.ui.log(`model-router: ${id} unavailable (${reason}) ${
|
|
holdWord(until, now)}; routing falls back`)
|
|
}
|
|
|
|
/** `/route reload` and a user /model: the marks are stale. */
|
|
function clearBreaker(st: State, id?: string): void {
|
|
if (id === undefined) {
|
|
st.down.clear()
|
|
st.strikes.clear()
|
|
} else {
|
|
st.down.delete(id)
|
|
st.strikes.delete(id)
|
|
}
|
|
st.turnModel = undefined
|
|
}
|
|
|
|
type Call = {
|
|
model: string // exact string to send: `cur` verbatim when not moved
|
|
why: string
|
|
moved: boolean
|
|
log?: string // turn-once line
|
|
logKey?: string // its dedupe key when the text varies; default the text
|
|
once?: string // session-once line
|
|
}
|
|
|
|
const keep = (cur: string, why = 'unchanged', log?: string): Call =>
|
|
({ model: cur, why, moved: false, ...(log === undefined ? {} : { log }) })
|
|
|
|
/** The replacement keeps the session's `[1m]` tier, haiku has none. */
|
|
function moveTo(cur: string, target: string, why: string): Call {
|
|
const carried = ONE_M.test(cur) && !target.startsWith(HAIKU)
|
|
const model = carried ? `${target}[1m]` : target
|
|
const once = carried
|
|
? `model-router: ${why} to ${model}: the [1m] variant is carried over`
|
|
: undefined
|
|
return { model, why, moved: true, ...(once === undefined ? {} : { once }) }
|
|
}
|
|
|
|
/** True when the context still fits the target model's known window. */
|
|
function fits(st: State, id: string, tokens: number | undefined): boolean {
|
|
const limit = hasKey(st.cfg.windows, id) ? st.cfg.windows[id] : undefined
|
|
return limit === undefined || (tokens !== undefined && tokens < limit)
|
|
}
|
|
|
|
const noFit = (id: string): string =>
|
|
`model-router: no switch to ${id}: context not known to fit`
|
|
|
|
/** Why the upgrade cap blocks a move, or undefined. Unknown size = blocked. */
|
|
function capBlock(st: State, tokens: number | undefined): string | undefined {
|
|
if (tokens === undefined) return 'context size unknown'
|
|
const max = st.cfg.upgradeMaxTokens
|
|
return tokens > max ? `context ${tokens} tokens over ${max}` : undefined
|
|
}
|
|
|
|
/** True when `wanted` ranks above `id` in the fallback chain. */
|
|
function ranksAbove(st: State, wanted: string, id: string): boolean {
|
|
const rw = modelRank(st.cfg, wanted)
|
|
const rc = modelRank(st.cfg, id)
|
|
return rw !== undefined && rc !== undefined && rw < rc
|
|
}
|
|
|
|
/**
|
|
* `cur` is down: `wanted`, else the next model of the chain that fits. A
|
|
* better `wanted` is an upgrade and passes the same switch and cap.
|
|
*/
|
|
function leaveDown(
|
|
st: State,
|
|
cur: string,
|
|
wanted: string | undefined,
|
|
tokens: number | undefined,
|
|
): Call {
|
|
const id = canonical(st.cfg, cur)
|
|
const upOk = st.cfg.mainUpgrade && capBlock(st, tokens) === undefined
|
|
for (const option of [wanted, nextAvailable(st, id)]) {
|
|
if (option === undefined || st.down.has(option)) continue
|
|
if (aliasOf(st.cfg, option) === undefined) continue
|
|
if (option === wanted && !upOk && ranksAbove(st, option, id)) continue
|
|
if (fits(st, option, tokens)) return moveTo(cur, option, 'fallback')
|
|
}
|
|
return keep(cur, 'fallback unavailable')
|
|
}
|
|
|
|
/** `wanted` ranks above `cur`: upgrade, under the switch and the cap. */
|
|
function upgradeCall(
|
|
st: State,
|
|
cur: string,
|
|
wanted: string,
|
|
tokens: number | undefined,
|
|
): Call {
|
|
if (!st.cfg.mainUpgrade) return keep(cur, 'switch off')
|
|
const block = capBlock(st, tokens)
|
|
if (block !== undefined) {
|
|
const why = `upgrade skipped: ${block}`
|
|
const call = keep(cur, why, `model-router: ${why}`)
|
|
return { ...call, logKey: 'upgrade-skipped' }
|
|
}
|
|
if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted))
|
|
return moveTo(cur, wanted, 'upgrade')
|
|
}
|
|
|
|
/** `wanted` is cheaper (or unranked): only with the downgrade switch on. */
|
|
function downgradeCall(
|
|
st: State,
|
|
cur: string,
|
|
wanted: string,
|
|
tokens: number | undefined,
|
|
): Call {
|
|
if (!st.cfg.mainModelSwitch) return keep(cur, 'switch off')
|
|
if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted))
|
|
return moveTo(cur, wanted, 'downgrade')
|
|
}
|
|
|
|
/** A model the table does not rank is never switched; said once. */
|
|
function unknownCall(cur: string, id: string): Call {
|
|
const call = keep(cur, 'model unknown to the table')
|
|
if (id === '') return call
|
|
const once = `model-router: ${id} unknown to the models table; no switch`
|
|
return { ...call, once }
|
|
}
|
|
|
|
/**
|
|
* The one decision of the main loop's model, in this order: router off, a
|
|
* model the table does not rank (never touched), `cur` down (leave it,
|
|
* always), no or same wanted model, a better one (upgrade), a cheaper one
|
|
* (downgrade). Used by the step AND by every text, so they agree.
|
|
*/
|
|
function decideMain(
|
|
st: State,
|
|
cur: string,
|
|
wanted: string | undefined,
|
|
tokens: number | undefined,
|
|
): Call {
|
|
const id = canonical(st.cfg, cur)
|
|
if (st.off) return keep(cur)
|
|
if (modelRank(st.cfg, id) === undefined) return unknownCall(cur, id)
|
|
if (st.down.has(id)) return leaveDown(st, cur, wanted, tokens)
|
|
if (wanted === undefined) return keep(cur)
|
|
if (aliasOf(st.cfg, wanted) === aliasOf(st.cfg, id)) return keep(cur)
|
|
return ranksAbove(st, wanted, id)
|
|
? upgradeCall(st, cur, wanted, tokens)
|
|
: downgradeCall(st, cur, wanted, tokens)
|
|
}
|
|
|
|
/** Model axis: sticky, turn route, run slot, then the floor's own model. */
|
|
const mainModel = (st: State): string | undefined =>
|
|
routeName(st.userMain?.route) ??
|
|
routeName(st.turnMain?.route) ??
|
|
routeName(st.runMain?.route) ??
|
|
routeName(st.turnFloor?.route)
|
|
|
|
async function readTokens($: Api): Promise<number | undefined> {
|
|
try {
|
|
return (await $.session.usage()).context.tokens
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
type Verdict = { call: Call; wanted: string | undefined }
|
|
|
|
/** What the main loop would run on from `cur`: wanted model + decision. */
|
|
async function decideFor($: Api, st: State, cur: string): Promise<Verdict> {
|
|
const name = mainModel(st)
|
|
const wanted = name === undefined
|
|
? undefined
|
|
: resolveName(st, name, canonical(st.cfg, cur))
|
|
const needsTokens = wanted !== undefined || st.down.size > 0
|
|
const tokens = needsTokens ? await readTokens($) : undefined
|
|
return { call: decideMain(st, cur, wanted, tokens), wanted }
|
|
}
|
|
|
|
function logCall($: Api, st: State, call: Call): void {
|
|
const key = call.logKey ?? call.log
|
|
if (call.log !== undefined && key !== undefined && !st.turnLogged.has(key)) {
|
|
st.turnLogged.add(key)
|
|
$.ui.log(call.log)
|
|
}
|
|
if (call.once !== undefined && !st.logged.has(call.once)) {
|
|
st.logged.add(call.once)
|
|
$.ui.log(call.once)
|
|
}
|
|
}
|
|
|
|
/** The main loop's effective route: user /route > turn route > run slot. */
|
|
const mainRoute = (st: State): Routed | null =>
|
|
st.userMain ?? st.turnMain ?? st.runMain
|
|
|
|
const rank = (l: Level | undefined): number =>
|
|
l === undefined ? -1 : LEVELS.indexOf(l)
|
|
|
|
/** Lifts `effort` to `floor`; a lower level, a number or none is replaced. */
|
|
function floored(effort: Effort, floor: Level | undefined): Effort {
|
|
if (floor === undefined) return effort
|
|
return isLevel(effort) && rank(effort) >= rank(floor) ? effort : floor
|
|
}
|
|
|
|
/** Two floors in one turn: the higher level stays (a tie takes the new). */
|
|
function higherFloor(cur: Routed | null, next: Routed): Routed {
|
|
return cur && rank(cur.route.effort) > rank(next.route.effort) ? cur : next
|
|
}
|
|
|
|
/**
|
|
* The one decision of the main loop's effort. The user's floor is the turn's
|
|
* default (no sticky or turn route names an effort) and its minimum.
|
|
* `by` names who set the value: the floor when it raised or supplied it.
|
|
*/
|
|
function mainEffort(st: State, engine: Effort): Decision {
|
|
const sticky = st.userMain?.route.effort
|
|
const named = sticky ?? st.turnMain?.route.effort ??
|
|
st.runMain?.route.effort
|
|
const floor = st.turnFloor?.route.effort
|
|
const base = named ?? floor ?? engine
|
|
const effort = floored(base, floor)
|
|
if (floor !== undefined && (named === undefined || effort !== base)) {
|
|
return { effort, by: 'floor' }
|
|
}
|
|
if (named === undefined) return { effort, by: 'engine' }
|
|
return { effort, by: sticky === undefined ? 'turn' : 'sticky' }
|
|
}
|
|
|
|
const floorWord = (f: Routed): string =>
|
|
`user floor ${f.route.effort ?? '-'} (prompt rule ${f.phase})`
|
|
|
|
/**
|
|
* Why main will not run at `asked`, or '' when it will. Truthful tail of
|
|
* every answer that records an effort for the main loop.
|
|
*/
|
|
function mainNote(st: State, asked: Level | undefined): string {
|
|
const d = mainEffort(st, undefined)
|
|
if (d.by === 'floor') {
|
|
if (!st.turnFloor || asked === undefined || d.effort === asked) return ''
|
|
return `${floorWord(st.turnFloor)} keeps main at ${String(d.effort)}; ` +
|
|
'/route clear to drop it'
|
|
}
|
|
return d.by === 'sticky' && st.userMain
|
|
? `a sticky /route ${st.userMain.phase} is in force and wins until ` +
|
|
'/route clear'
|
|
: ''
|
|
}
|
|
|
|
function loopOf(st: State, agentId: string): Loop {
|
|
const known = st.loops.get(agentId)
|
|
if (known) return known
|
|
const fresh: Loop = {
|
|
explicitEffort: false,
|
|
}
|
|
st.loops.set(agentId, fresh)
|
|
return fresh
|
|
}
|
|
|
|
/** Writes a route on an agent loop, never on an axis given explicitly. */
|
|
function writeLoop(loop: Loop, route: Route): void {
|
|
if (!loop.explicitEffort) loop.effort = route.effort
|
|
}
|
|
|
|
function clearRoutes(st: State): void {
|
|
st.userMain = null
|
|
st.turnMain = null
|
|
st.runMain = null
|
|
st.turnFloor = null
|
|
st.pendingPrompt = null
|
|
}
|
|
|
|
/** Config `enabled: false` switches the router off; true lifts only that. */
|
|
function applyEnabled(st: State, enabled: boolean): void {
|
|
if (!enabled) {
|
|
st.off = true
|
|
st.offConfig = true
|
|
} else if (st.offConfig) {
|
|
st.off = false
|
|
st.offConfig = false
|
|
}
|
|
}
|
|
|
|
const routerWord = (st: State): string =>
|
|
st.off ? (st.offConfig ? 'off (config)' : 'off') : 'on'
|
|
|
|
// ---- text ------------------------------------------------------------
|
|
|
|
/** The floor's level when it carries one and the router is on. */
|
|
function liveFloor(st: State): { f: Routed; level: Level } | undefined {
|
|
const f = st.turnFloor
|
|
const level = f?.route.effort
|
|
return st.off || !f || level === undefined ? undefined : { f, level }
|
|
}
|
|
|
|
/** An id the table knows, else "session model": '' and foreign ids alike. */
|
|
function idWord(st: State, model: string): string {
|
|
const known = aliasOf(st.cfg, canonical(st.cfg, model)) !== undefined
|
|
return known ? model : 'session model'
|
|
}
|
|
|
|
/** The router left a down model, or wanted to and found nowhere to go. */
|
|
const isFallback = (call: Call): boolean => call.why.startsWith('fallback')
|
|
|
|
/** Model words of the main line, from the decision a step would take. */
|
|
function modelWord(st: State, v: Verdict): string {
|
|
const id = idWord(st, v.call.model)
|
|
if (v.call.moved) return `${id} (${v.call.why})`
|
|
if (v.wanted === undefined) {
|
|
return isFallback(v.call) ? `${id} (${v.call.why})` : '-'
|
|
}
|
|
if (canonical(st.cfg, v.call.model) === v.wanted) return id
|
|
return `asked ${v.wanted}, keeps ${id} (${v.call.why})`
|
|
}
|
|
|
|
/** The tier a route names, shown beside the model it resolved to. */
|
|
function tierWord(st: State, word: string): string {
|
|
const name = mainModel(st)
|
|
const tier = name !== undefined && hasKey(st.cfg.tiers, name)
|
|
return tier ? `${word} [tier ${name}]` : word
|
|
}
|
|
|
|
/** Haiku takes no effort: the main line says so when it will run there. */
|
|
function effortWord(st: State, v: Verdict): string {
|
|
if (v.call.model.startsWith(HAIKU)) return '- (haiku takes none)'
|
|
return String(mainEffort(st, undefined).effort ?? '-')
|
|
}
|
|
|
|
function mainText(st: State, v: Verdict): string {
|
|
const r = mainRoute(st)
|
|
const live = liveFloor(st)
|
|
const floor = live ? ` · floor ${live.level} (${live.f.phase})` : ''
|
|
if (!r) {
|
|
const left = v.call.moved || isFallback(v.call)
|
|
? ` · model ${modelWord(st, v)}`
|
|
: ''
|
|
return 'main: session defaults' + left + floor
|
|
}
|
|
const model = tierWord(st, modelWord(st, v))
|
|
return `main: ${r.source} ${r.phase} · model ${model} · effort ${
|
|
effortWord(st, v)}${floor}`
|
|
}
|
|
|
|
/** `name=<tier or model>→<resolved id>/<effort>` for every phase. */
|
|
function phasesText(st: State): string {
|
|
const entries = Object.entries(st.cfg.phases).map(([name, r]) => {
|
|
const asked = routeName(r)
|
|
const id = asked === undefined ? undefined : resolveName(st, asked)
|
|
const model = asked === undefined
|
|
? 'session'
|
|
: asked === id ? asked : `${asked}→${id ?? '-'}`
|
|
return `${name}=${model}/${r.effort ?? 'session'}`
|
|
})
|
|
return `phases: ${entries.join(' ')}`
|
|
}
|
|
|
|
function downText(st: State, now: number): string {
|
|
const held = [...st.down].map(([id, h]) =>
|
|
`${id} ${holdWord(h.until, now)} (${h.reason})`)
|
|
return `down: ${held.length > 0 ? held.join(', ') : 'none'}`
|
|
}
|
|
|
|
/** The verdict a text reports: what the next main step would decide. */
|
|
async function snapshot($: Api, st: State) {
|
|
const now = await prune($, st)
|
|
const cur = st.turnModel ?? st.sessionModel
|
|
return { now, ...(await decideFor($, st, cur)) }
|
|
}
|
|
|
|
async function show($: Api, st: State): Promise<string> {
|
|
const c = st.cfg
|
|
const flag = (b: boolean) => (b ? 'on' : 'off')
|
|
const s = await snapshot($, st)
|
|
return [
|
|
mainText(st, s),
|
|
`router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} ` +
|
|
`(downgrade) · upgrade: ${flag(c.mainUpgrade)} · verbose: ${
|
|
flag(c.verbose)} · spinner: ${flag(c.spinner)}`,
|
|
downText(st, s.now),
|
|
`live loops: ${st.loops.size}`,
|
|
phasesText(st),
|
|
`config: ${st.source}`,
|
|
].join('\n')
|
|
}
|
|
|
|
function statusLine(st: State): string {
|
|
const r = mainRoute(st)
|
|
const now = st.off
|
|
? routerWord(st)
|
|
: r ? `${r.source} ${r.phase}` : 'session defaults'
|
|
const floor = liveFloor(st)
|
|
return `route: ${now}${floor ? ` · floor ${floor.level}` : ''}${
|
|
st.cfg.mainModelSwitch ? ' · switch on' : ''}`
|
|
}
|
|
|
|
const refresh = ($: Api, st: State): void => $.ui.status(statusLine(st))
|
|
|
|
function vlog($: Api, st: State, text: string): void {
|
|
if (st.cfg.verbose) $.ui.log(text)
|
|
}
|
|
|
|
const unknownText = (cfg: Config, token: string): string =>
|
|
`unknown token "${token}"; phases: ${Object.keys(cfg.phases).join(' ')}; ` +
|
|
`levels: ${LEVELS.join(' ')}; models: ${Object.keys(cfg.models).join(' ')} ` +
|
|
'or a full claude-* id'
|
|
|
|
// ---- /route command --------------------------------------------------
|
|
|
|
/** One token: `model=x`, `effort=y`, a bare alias, id or level. */
|
|
function applyToken(cfg: Config, route: Route, token: string): boolean {
|
|
const eq = token.indexOf('=')
|
|
const key = eq < 0 ? '' : token.slice(0, eq)
|
|
const value = eq < 0 ? token : token.slice(eq + 1)
|
|
if (key !== '' && key !== 'model' && key !== 'effort') return false
|
|
if (key !== 'model' && isLevel(value)) route.effort = value
|
|
else if (key !== 'effort' && isModelName(cfg.models, value)) {
|
|
route.model = value
|
|
} else return false
|
|
return true
|
|
}
|
|
|
|
function parseRoute(cfg: Config, args: string): Routed | string {
|
|
const words = args.trim().split(/\s+/)
|
|
const only = words.length === 1 ? words[0] : undefined
|
|
const named = only === undefined ? undefined : phaseRoute(cfg, only)
|
|
if (only !== undefined && named) {
|
|
return { phase: only, route: { ...named }, source: 'user' }
|
|
}
|
|
const route: Route = {}
|
|
for (const word of words) {
|
|
if (!applyToken(cfg, route, word)) return unknownText(cfg, word)
|
|
}
|
|
return { phase: 'custom', route, source: 'user' }
|
|
}
|
|
|
|
async function toggle(
|
|
$: Api,
|
|
st: State,
|
|
what: string,
|
|
arg: string | undefined,
|
|
): Promise<string> {
|
|
if (arg !== 'on' && arg !== 'off') return `usage: /route ${what} on|off`
|
|
if (what === 'switch') st.toggles.switch = arg === 'on'
|
|
else st.toggles.verbose = arg === 'on'
|
|
applyToggles(st)
|
|
return show($, st)
|
|
}
|
|
|
|
async function setUserRoute($: Api, st: State, args: string) {
|
|
const parsed = parseRoute(st.cfg, args)
|
|
if (typeof parsed === 'string') return parsed
|
|
st.userMain = parsed
|
|
refresh($, st)
|
|
return show($, st)
|
|
}
|
|
|
|
/** Session toggles outlive any rebuild of the config. */
|
|
function applyToggles(st: State): void {
|
|
const { switch: downgrade, verbose } = st.toggles
|
|
if (downgrade !== undefined) st.cfg.mainModelSwitch = downgrade
|
|
if (verbose !== undefined) st.cfg.verbose = verbose
|
|
}
|
|
|
|
function applyLoaded(st: State, loaded: Loaded, log: Log): void {
|
|
st.cfg = loaded.cfg
|
|
st.rules = compileRules(loaded.cfg, log)
|
|
st.source = loaded.source
|
|
st.mem = loaded.mem
|
|
st.override = loaded.override
|
|
st.loaded = true
|
|
st.rebuildDue = false
|
|
applyToggles(st)
|
|
applyEnabled(st, loaded.cfg.enabled)
|
|
}
|
|
|
|
/**
|
|
* (Re)loads the layers into the state and re-registers the route tool.
|
|
* After the first load an unusable layer keeps the whole previous config:
|
|
* the kill switch fails closed, never back to the defaults. True when the
|
|
* new config was swapped in.
|
|
*/
|
|
async function reloadConfig($: Api, st: State): Promise<boolean> {
|
|
const log = (text: string): void => $.ui.log(text)
|
|
const loaded = await loadLayers($, !st.loaded, log)
|
|
if (loaded) applyLoaded(st, loaded, log)
|
|
else log('model-router: config unreadable; keeping the previous config')
|
|
await registerTool($, st)
|
|
return loaded !== undefined
|
|
}
|
|
|
|
/**
|
|
* The layers are read at session.start; a state that never saw it (a
|
|
* /reload-plugins re-runs register) loads on its first use. /clear leaves
|
|
* a re-read due, taken by the next prompt only (`atPrompt`).
|
|
*/
|
|
async function ensureConfig($: Api, st: State, atPrompt = false) {
|
|
if (st.loaded && !(atPrompt && st.rebuildDue)) return
|
|
st.rebuildDue = false
|
|
await reloadConfig($, st)
|
|
}
|
|
|
|
/** The breaker is cleared first, whatever the config read then does. */
|
|
async function reloadCommand($: Api, st: State): Promise<string> {
|
|
clearBreaker(st)
|
|
await reloadConfig($, st)
|
|
refresh($, st)
|
|
return 'config reloaded\n' + (await show($, st))
|
|
}
|
|
|
|
async function handleCommand($: Api, st: State, args: string): Promise<string> {
|
|
const [head = '', ...rest] = args.trim().split(/\s+/)
|
|
switch (head) {
|
|
case '':
|
|
case 'show':
|
|
return show($, st)
|
|
case 'clear': {
|
|
const run = st.runMain !== null
|
|
clearRoutes(st)
|
|
refresh($, st)
|
|
return 'route cleared' + (run ? '; run slot dropped' : '') +
|
|
'\n' + (await show($, st))
|
|
}
|
|
case 'on':
|
|
case 'off':
|
|
st.off = head === 'off'
|
|
st.offConfig = false
|
|
if (st.off) st.runMain = null
|
|
refresh($, st)
|
|
return show($, st)
|
|
case 'reload':
|
|
return reloadCommand($, st)
|
|
case 'pending':
|
|
return pendingText(st)
|
|
case 'ask':
|
|
return askCommand($, st, rest[0])
|
|
case 'switch':
|
|
case 'verbose':
|
|
return toggle($, st, head, rest[0])
|
|
default:
|
|
return setUserRoute($, st, args)
|
|
}
|
|
}
|
|
|
|
// ---- first use: one dialog per row, the answer kept in routing.json -----
|
|
// The first time a row routes (a typed skill, an agent spawn, a phase
|
|
// declared through the route tool) the user confirms it once, for every
|
|
// project; a change can be a project exception. Only a dialog answer or
|
|
// `/route ask` writes; nothing is asked on the step path or in an agent.
|
|
|
|
const HEADER = 'model-router'
|
|
const UPDATED = 'routing.json updated: commit it from the config repo ' +
|
|
'(chore branch)'
|
|
|
|
type Req = { kind: Kind; name: string; phase: string }
|
|
type RowReq = Req & { kind: Row }
|
|
type Answer =
|
|
| { act: 'later' }
|
|
| { act: 'keep' }
|
|
| { act: 'change'; phase: string }
|
|
type Scope = 'everywhere' | 'project' | 'later'
|
|
type Patch = (file: Rec) => void
|
|
|
|
const keyOf = (req: { kind: Kind; name: string }): string =>
|
|
`${req.kind}:${req.name}`
|
|
const isRowReq = (req: Req): req is RowReq => req.kind !== 'phases'
|
|
const own = (t: Table, name: string): string | undefined =>
|
|
hasKey(t, name) ? t[name] : undefined
|
|
|
|
/** Decided: a Keep or change confirmed the row, or a layer set it. */
|
|
function isDecided(mem: Memory, kind: Kind, name: string): boolean {
|
|
if (kind === 'phases') return own(mem.confirmed.phases, name) === name
|
|
if (mem.local[kind].has(name)) return true
|
|
const base = own(mem.base[kind], name)
|
|
return base !== undefined && own(mem.confirmed[kind], name) === base
|
|
}
|
|
|
|
const wantsAsk = (st: State, req: Req): boolean =>
|
|
!st.off && st.mem.ask && !st.asked.has(keyOf(req)) &&
|
|
!isDecided(st.mem, req.kind, req.name)
|
|
|
|
/** The file as it is now: another live session may have decided since. */
|
|
async function refreshMemory($: Api, st: State): Promise<boolean> {
|
|
const file = await readRouting($, text => $.ui.log(text))
|
|
if (typeof file === 'string') return false
|
|
st.mem = buildMemory(
|
|
file.data, await projectKey($), st.override, () => undefined)
|
|
return true
|
|
}
|
|
|
|
async function askOr(
|
|
$: Api,
|
|
text: string,
|
|
options: readonly string[],
|
|
): Promise<string> {
|
|
try {
|
|
return await $.ui.ask(text, { options, header: HEADER })
|
|
} catch {
|
|
return LATER // dismissed, or no one to ask (-p run)
|
|
}
|
|
}
|
|
|
|
// What a question says about its row. Everything read from outside (a
|
|
// SKILL.md, an agent's listing line, routing.json) is cleaned and capped.
|
|
|
|
/**
|
|
* Text safe in a question: every whitespace folded to a space, then control
|
|
* and format characters (bidi included) stripped, cut to `n` code points
|
|
* with an ellipsis (total <= n).
|
|
*/
|
|
function clean(text: string, n: number): string {
|
|
const flat = text.replace(/[\s\p{Z}]+/gu, ' ')
|
|
.replace(/[\p{Cc}\p{Cf}]/gu, '').replace(/ {2,}/g, ' ').trim()
|
|
const points = [...flat]
|
|
return points.length <= n ? flat : `${points.slice(0, n - 1).join('')}…`
|
|
}
|
|
|
|
/** The first sentence of a description, cleaned; undefined when empty. */
|
|
function oneSentence(text: string): string | undefined {
|
|
const end = /\.(?:\s|$)/.exec(text)
|
|
const line = clean(end ? text.slice(0, end.index) : text, MAX_DESC)
|
|
return line === '' ? undefined : line
|
|
}
|
|
|
|
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/
|
|
const BLOCK_HEAD = /^[|>][+-]?\d*$/
|
|
|
|
/** A YAML scalar without its quotes: `''` and JSON escapes resolved. */
|
|
function unquote(value: string): string {
|
|
const quote = value[0]
|
|
if (value.length < 2 || value[value.length - 1] !== quote) return value
|
|
if (quote === "'") return value.slice(1, -1).replace(/''/g, "'")
|
|
if (quote !== '"') return value
|
|
try {
|
|
const parsed: unknown = JSON.parse(value)
|
|
return typeof parsed === 'string' ? parsed : value.slice(1, -1)
|
|
} catch {
|
|
return value.slice(1, -1)
|
|
}
|
|
}
|
|
|
|
/** `description:` of a SKILL.md frontmatter: plain, block or quoted. */
|
|
function frontDescription(text: string): string | undefined {
|
|
const front = FRONTMATTER.exec(text)?.[1]
|
|
const lines = front === undefined ? [] : front.split(/\r?\n/)
|
|
const at = lines.findIndex(line => line.startsWith('description:'))
|
|
if (at < 0) return undefined
|
|
const head = (lines[at] ?? '').slice('description:'.length).trim()
|
|
const body: string[] = []
|
|
for (const line of lines.slice(at + 1)) {
|
|
if (line.trim() !== '' && !/^\s/.test(line)) break
|
|
body.push(line.trim())
|
|
}
|
|
const first = BLOCK_HEAD.test(head) ? '' : head
|
|
return unquote([first, ...body].filter(w => w !== '').join(' '))
|
|
}
|
|
|
|
/**
|
|
* A skill's description from ${HOME}/.claude/skills/<name>/SKILL.md: a
|
|
* regular file under the size cap, a name from the allowlist. Any failure
|
|
* gives undefined: the question names the skill alone.
|
|
*/
|
|
async function skillDesc($: Api, name: string): Promise<string | undefined> {
|
|
if (!SKILL_NAME.test(name)) return undefined
|
|
try {
|
|
const home = await $.env.get('HOME')
|
|
if (!home) return undefined
|
|
const path = `${home}/.claude/skills/${name}/SKILL.md`
|
|
if ((await $.fs.stat(path)).kind !== 'file') return undefined
|
|
const text = await readCapped($, path, 'SKILL.md', () => undefined)
|
|
const raw = text === undefined ? undefined : frontDescription(text)
|
|
return raw === undefined ? undefined : oneSentence(raw)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** The agent listing line, cleaned once at offer time; oldest dropped. */
|
|
function rememberOffer(
|
|
st: State,
|
|
e: { agent: string; source: string; description: string },
|
|
): void {
|
|
st.offers.set(e.agent, e.source)
|
|
const given: unknown = e.description
|
|
const line = typeof given === 'string'
|
|
? oneSentence(given.slice(0, 4096))
|
|
: undefined
|
|
st.descs.delete(e.agent)
|
|
if (line !== undefined) st.descs.set(e.agent, line)
|
|
if (st.descs.size <= MAX_DESCS) return
|
|
const oldest = st.descs.keys().next().value
|
|
if (oldest !== undefined) st.descs.delete(oldest)
|
|
}
|
|
|
|
const plural = (n: number, word: string): string =>
|
|
`${n} ${word}${n === 1 ? '' : 's'}`
|
|
|
|
const withDesc = (lead: string, desc: string | undefined): string =>
|
|
desc === undefined ? lead : `${lead} — ${desc}`
|
|
|
|
function aboutLine(st: State, phase: string): string | undefined {
|
|
const raw = own(st.mem.about, phase)
|
|
const line = raw === undefined ? '' : clean(raw, MAX_ABOUT)
|
|
return line === '' ? undefined : line
|
|
}
|
|
|
|
/** "Routed to <phase> (<about>)": the parenthesis only when about is set. */
|
|
function routedTo(st: State, phase: string): string {
|
|
const about = aboutLine(st, phase)
|
|
return `Routed to ${phase}${about === undefined ? '' : ` (${about})`}`
|
|
}
|
|
|
|
/** Who uses a phase: its rows, the derived dispatch route, prompt rules. */
|
|
function consumers(st: State, phase: string): string {
|
|
const { skills, agents, prompt } = st.cfg
|
|
const rows = [...Object.values(skills), ...Object.values(agents)]
|
|
.filter(p => p === phase).length
|
|
const rules = prompt.filter(r => r.phase === phase)
|
|
const floors = rules.filter(r => r.mode !== 'default').length
|
|
const parts = [plural(rows, 'row')]
|
|
if (phase === 'orchestrate') parts.push('the derived route of dispatches')
|
|
if (rules.length > floors) {
|
|
parts.push(plural(rules.length - floors, 'prompt rule'))
|
|
}
|
|
if (floors > 0) parts.push(plural(floors, 'floor rule'))
|
|
return `used by ${parts.join(', ')}`
|
|
}
|
|
|
|
// ---- the dialog's steps: ASK1 Later/Keep/Change, then model, effort, scope
|
|
|
|
const optionsFor = (req: Req): string[] =>
|
|
isRowReq(req) ? [LATER, KEEP, CHANGE] : [LATER, KEEP]
|
|
|
|
/**
|
|
* One step: the chosen label, or undefined (a Later). A dismissed or
|
|
* headless dialog is silent; any other answer is toasted, never echoed.
|
|
*/
|
|
async function askStep(
|
|
$: Api,
|
|
text: string,
|
|
labels: readonly string[],
|
|
): Promise<string | undefined> {
|
|
const answer = await askOr($, text, labels)
|
|
if (answer === LATER) return undefined
|
|
if (labels.includes(answer)) return answer
|
|
$.ui.toast('answer not recognised, default kept')
|
|
return undefined
|
|
}
|
|
|
|
/** The alias a phase's route leads with: its tier's head, or its model. */
|
|
function headAlias(cfg: Config, route: Route): string | undefined {
|
|
if (route.tier !== undefined) {
|
|
const list = hasKey(cfg.tiers, route.tier) ? cfg.tiers[route.tier] : []
|
|
return list?.[0]
|
|
}
|
|
if (route.model === undefined) return undefined
|
|
return hasKey(cfg.models, route.model)
|
|
? route.model
|
|
: aliasOf(cfg, canonical(cfg, route.model))
|
|
}
|
|
|
|
/** "fable (best)": the alias and the tier it heads ("custom" if none). */
|
|
function modelLabel(cfg: Config, alias: string): string {
|
|
const tier = Object.keys(cfg.tiers).find(t => cfg.tiers[t]?.[0] === alias)
|
|
return `${alias} (${tier ?? 'custom'})`
|
|
}
|
|
|
|
async function askModel($: Api, st: State, req: RowReq) {
|
|
const labels = CHOICE_ALIASES.map(a => modelLabel(st.cfg, a))
|
|
const label = await askStep($, `Which model for ${req.name}?`, labels)
|
|
return label === undefined ? undefined : CHOICE_ALIASES[labels.indexOf(label)]
|
|
}
|
|
|
|
/** The efforts of the phases headed by `alias`, ascending, four at most. */
|
|
function effortsOf(cfg: Config, alias: string): Level[] {
|
|
const found = Object.values(cfg.phases)
|
|
.filter(r => headAlias(cfg, r) === alias).map(r => r.effort)
|
|
return LEVELS.filter(level => found.includes(level)).slice(0, 4)
|
|
}
|
|
|
|
/** One effort is no question; none is a Later, said. */
|
|
async function askEffort($: Api, st: State, alias: string) {
|
|
const efforts = effortsOf(st.cfg, alias)
|
|
if (efforts.length === 0) $.ui.toast(`no phase uses ${alias}`)
|
|
if (efforts.length < 2) return efforts[0]
|
|
const label = await askStep($, `Which effort on ${alias}?`, efforts)
|
|
return efforts.find(effort => effort === label)
|
|
}
|
|
|
|
/** The row's current phase when it matches, else the first that does. */
|
|
function targetPhase(cfg: Config, now: string, alias: string, effort: Level) {
|
|
const fits = (name: string): boolean => {
|
|
const route = phaseRoute(cfg, name)
|
|
return route?.effort === effort && headAlias(cfg, route) === alias
|
|
}
|
|
return fits(now) ? now : Object.keys(cfg.phases).find(fits)
|
|
}
|
|
|
|
/** Change: model, then effort, mapped to a phase of the table. */
|
|
async function changeRoute($: Api, st: State, req: RowReq): Promise<Answer> {
|
|
const alias = await askModel($, st, req)
|
|
const effort = alias === undefined ? undefined : await askEffort($, st, alias)
|
|
if (alias === undefined || effort === undefined) return { act: 'later' }
|
|
const phase = targetPhase(st.cfg, req.phase, alias, effort)
|
|
if (phase === undefined) return { act: 'later' }
|
|
return phase === req.phase ? { act: 'keep' } : { act: 'change', phase }
|
|
}
|
|
|
|
async function decide($: Api, st: State, req: Req, text: string) {
|
|
const choice = await askStep($, text, optionsFor(req))
|
|
if (choice === KEEP) return { act: 'keep' } as const
|
|
if (choice === CHANGE && isRowReq(req)) return changeRoute($, st, req)
|
|
return { act: 'later' } as const
|
|
}
|
|
|
|
async function askScope(
|
|
$: Api,
|
|
st: State,
|
|
req: Req,
|
|
to: string,
|
|
): Promise<Scope> {
|
|
const key = st.mem.key
|
|
const text = `Use ${to} for ${req.name} everywhere, or only in this project?`
|
|
const choice = await askStep($, text,
|
|
[EVERYWHERE, key === undefined ? LATER : THIS_PROJECT])
|
|
if (choice === EVERYWHERE) return 'everywhere'
|
|
return choice === THIS_PROJECT && key !== undefined ? 'project' : 'later'
|
|
}
|
|
|
|
function table(parent: Rec, key: string): Rec {
|
|
const found = isRecord(parent[key]) ? parent[key] : undefined
|
|
if (found) return found
|
|
const fresh: Rec = {}
|
|
parent[key] = fresh
|
|
return fresh
|
|
}
|
|
|
|
function setKey(target: Rec, key: string, value: unknown): void {
|
|
if (key !== '__proto__') target[key] = value
|
|
}
|
|
|
|
const confirmIn = (file: Rec, kind: Kind, name: string, phase: string) =>
|
|
setKey(table(table(file, 'confirmed'), kind), name, phase)
|
|
|
|
/** A Keep endorses the row and its phase (no second ask at the route tool). */
|
|
function keepPatch(req: Req): Patch {
|
|
return file => {
|
|
if (isRowReq(req)) confirmIn(file, req.kind, req.name, req.phase)
|
|
confirmIn(file, 'phases', req.phase, req.phase)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Everywhere: the row moves, `changed` keeps the FIRST shipped phase for
|
|
* the census. This project only: the row stays, the exception goes under
|
|
* the repo key and the base row counts as confirmed (nobody else asks).
|
|
*/
|
|
function changePatch(req: RowReq, to: string, scope: Scope, key?: string) {
|
|
return (file: Rec): void => {
|
|
const rows = table(file, req.kind)
|
|
const was = typeof rows[req.name] === 'string' ? rows[req.name] : req.phase
|
|
if (scope === 'project' && key !== undefined) {
|
|
const mine = table(table(file, 'projects'), key)
|
|
setKey(table(mine, req.kind), req.name, to)
|
|
confirmIn(file, req.kind, req.name, String(was))
|
|
return
|
|
}
|
|
const changed = table(table(file, 'changed'), req.kind)
|
|
const first = subRec(changed, req.name).from ?? was
|
|
setKey(rows, req.name, to)
|
|
setKey(changed, req.name, { from: first, to })
|
|
confirmIn(file, req.kind, req.name, to)
|
|
}
|
|
}
|
|
|
|
function serializeRouting(file: Rec): string {
|
|
const known = ROUTING_ORDER.filter(k => hasKey(file, k))
|
|
const extra = Object.keys(file).filter(k => !ROUTING_ORDER.includes(k))
|
|
const ordered = Object.fromEntries(
|
|
[...known, ...extra].map(k => [k, file[k]]))
|
|
return JSON.stringify(ordered, null, 2) + '\n'
|
|
}
|
|
|
|
/**
|
|
* Read-modify-write of routing.json, one at a time (a chain). Refuses a
|
|
* missing or unparsable file and never creates it; rebuilds the config
|
|
* after, swapped only if the read works.
|
|
*/
|
|
async function writeNow($: Api, st: State, patch: Patch): Promise<boolean> {
|
|
const file = await readRouting($, text => $.ui.log(text))
|
|
if (typeof file === 'string') {
|
|
$.ui.toast(`${ROUTING} is missing or unreadable: nothing saved`)
|
|
return false
|
|
}
|
|
patch(file.data)
|
|
const out = serializeRouting(file.data)
|
|
if (out.length > MAX_CONFIG_BYTES) {
|
|
$.ui.toast(`${ROUTING} would exceed ${MAX_CONFIG_BYTES} bytes: ` +
|
|
'nothing saved')
|
|
return false
|
|
}
|
|
await $.fs.write(file.path, out)
|
|
const rebuilt = await reloadConfig($, st)
|
|
$.ui.toast(UPDATED)
|
|
if (!rebuilt) throw new Error('rebuild failed after the write')
|
|
return true
|
|
}
|
|
|
|
function writeRouting($: Api, st: State, patch: Patch): Promise<boolean> {
|
|
const run = st.writes.then(() => writeNow($, st, patch))
|
|
st.writes = run.catch(() => undefined)
|
|
return run
|
|
}
|
|
|
|
async function saveAnswer($: Api, st: State, req: Req, ans: Answer) {
|
|
if (ans.act === 'keep') return writeRouting($, st, keepPatch(req))
|
|
if (ans.act !== 'change' || !isRowReq(req)) return false
|
|
const scope = await askScope($, st, req, ans.phase)
|
|
if (scope === 'later') return false
|
|
const patch = changePatch(req, ans.phase, scope, st.mem.key)
|
|
return writeRouting($, st, patch)
|
|
}
|
|
|
|
/** The dialog and what follows; true when routing.json was rewritten. */
|
|
async function runDialog($: Api, st: State, req: Req, text: string) {
|
|
st.asked.add(keyOf(req))
|
|
return saveAnswer($, st, req, await decide($, st, req, text))
|
|
}
|
|
|
|
function failOnce($: Api, st: State, err: unknown): void {
|
|
const text = `model-router: first-use dialog failed (${String(err)}); ` +
|
|
'default route kept'
|
|
if (st.logged.has(text)) return
|
|
st.logged.add(text)
|
|
$.ui.log(text)
|
|
}
|
|
|
|
/**
|
|
* Asks once whether `req.phase` is the right route for this row. At most
|
|
* one dialog is open per session: any other use finds `asking` set and
|
|
* routes unasked. The caller awaits it outside `safely` (it never throws).
|
|
* `say` builds the question from the real decision, only if one is asked.
|
|
* True when routing.json changed: the caller recomputes its route.
|
|
*/
|
|
async function confirmFirst(
|
|
$: Api,
|
|
st: State,
|
|
req: Req,
|
|
say: () => Promise<string>,
|
|
): Promise<boolean> {
|
|
if (st.asking !== null || !wantsAsk(st, req)) return false
|
|
st.asking = keyOf(req)
|
|
try {
|
|
if (!(await refreshMemory($, st)) || !wantsAsk(st, req)) return false
|
|
return await runDialog($, st, req, await say())
|
|
} catch (err) {
|
|
st.asked.delete(keyOf(req))
|
|
failOnce($, st, err)
|
|
return false
|
|
} finally {
|
|
st.asking = null
|
|
}
|
|
}
|
|
|
|
/** First-use question text: the id and level the next step really gets. */
|
|
async function mainQuestion($: Api, st: State, lead: string) {
|
|
const v = await snapshot($, st)
|
|
return `${lead}: ${idWord(st, v.call.model)} at ${effortWord(st, v)}. OK?`
|
|
}
|
|
|
|
function spawnQuestion(
|
|
$: Api,
|
|
st: State,
|
|
e: SpawnIn,
|
|
req: Req,
|
|
route: Route,
|
|
): string {
|
|
const id = spawnTarget($, st, e, route) ?? 'its own model'
|
|
const effort = st.explicitEffort.get(e.tool_use_id) ?? route.effort
|
|
const lead = withDesc(`First dispatch of ${req.name}`,
|
|
st.descs.get(req.name))
|
|
return `${lead}. ${routedTo(st, req.phase)}: ${id} at ${
|
|
effort ?? 'its own effort'}. OK?`
|
|
}
|
|
|
|
async function skillQuestion($: Api, st: State, skill: string, phase: string) {
|
|
const head = withDesc(`First route for /${skill}`, await skillDesc($, skill))
|
|
return mainQuestion($, st, `${head}. ${routedTo(st, phase)}`)
|
|
}
|
|
|
|
/** A downgrade the switch holds back: main keeps its model, said. */
|
|
function downgradeHeld(st: State, v: Verdict, cur: string): boolean {
|
|
return v.wanted !== undefined && v.call.why === 'switch off' &&
|
|
!ranksAbove(st, v.wanted, canonical(st.cfg, cur))
|
|
}
|
|
|
|
/** After a Change: what main will really run, and the switch hint. */
|
|
async function toastChange($: Api, st: State, skill: string) {
|
|
const cur = st.turnModel ?? st.sessionModel
|
|
const v = await decideFor($, st, cur)
|
|
const hint = downgradeHeld(st, v, cur)
|
|
? ', main moves up only: /route switch on allows a downgrade'
|
|
: ''
|
|
$.ui.toast(`/${skill} now runs ${idWord(st, v.call.model)} at ${
|
|
effortWord(st, v)}${hint}`)
|
|
}
|
|
|
|
/** Typed skill: a changed row re-routes the turn at once. */
|
|
async function confirmSkill($: Api, st: State, skill: string, row: Picked) {
|
|
const req: Req = { kind: 'skills', name: skill, phase: row.phase }
|
|
const say = () => skillQuestion($, st, skill, row.phase)
|
|
if (!(await confirmFirst($, st, req, say))) return
|
|
const after = skillRow(st, skill)
|
|
if (after === undefined) return
|
|
routeMainBySkill(st, after, true)
|
|
refresh($, st)
|
|
if (after.phase !== row.phase) await toastChange($, st, skill)
|
|
}
|
|
|
|
/** Rowed spawn: the route in force after the dialog (changed or not). */
|
|
async function routeSpawn($: Api, st: State, e: SpawnIn) {
|
|
const frozen = e.fork || e.workflow !== undefined
|
|
const route = spawnRoute(st, e, frozen)
|
|
const phase = hasKey(st.cfg.agents, e.subagentType)
|
|
? st.cfg.agents[e.subagentType]
|
|
: undefined
|
|
const inside = e.parentAgentId !== undefined || e.model !== undefined
|
|
if (!route || !phase || inside) return route
|
|
const req: Req = { kind: 'agents', name: e.subagentType, phase }
|
|
const say = async () => spawnQuestion($, st, e, req, route)
|
|
return (await confirmFirst($, st, req, say))
|
|
? spawnRoute(st, e, frozen)
|
|
: route
|
|
}
|
|
|
|
/** A phase declared through the route tool: confirm-only. */
|
|
async function confirmPhase($: Api, st: State, phase: string) {
|
|
const req: Req = { kind: 'phases', name: phase, phase }
|
|
const meaning = [aboutLine(st, phase), consumers(st, phase)]
|
|
.filter(part => part !== undefined).join('; ')
|
|
const lead = `First use of ${phase} on the main loop — ${meaning}`
|
|
await confirmFirst($, st, req, () => mainQuestion($, st, lead))
|
|
}
|
|
|
|
// ---- first-use commands ----------------------------------------------
|
|
|
|
/** Rows and phases not confirmed yet, those asked this session first. */
|
|
function pendingText(st: State): string {
|
|
const m = st.mem
|
|
if (!m.ok) return `${ROUTING} missing or unreadable: nothing to confirm`
|
|
const names = (kind: Kind): string[] => Object.keys(
|
|
kind === 'phases' ? st.cfg.phases : m.base[kind])
|
|
.filter(name => !isDecided(m, kind, name))
|
|
const all = KINDS.flatMap(kind => names(kind).map(name => ({ kind, name })))
|
|
.map(keyOf)
|
|
const asked = all.filter(k => st.asked.has(k))
|
|
const rest = all.filter(k => !st.asked.has(k))
|
|
return [
|
|
`pending confirmation: ${all.length}` +
|
|
(m.ask ? '' : ' (asking is off: /route ask on)'),
|
|
`asked this session: ${asked.join(' ') || 'none'}`,
|
|
`not asked yet: ${rest.join(' ') || 'none'}`,
|
|
].join('\n')
|
|
}
|
|
|
|
async function askCommand($: Api, st: State, arg: string | undefined) {
|
|
if (arg !== 'on' && arg !== 'off') return 'usage: /route ask on|off'
|
|
const patch: Patch = file => { file.ask = arg === 'on' }
|
|
try {
|
|
if (!(await writeRouting($, st, patch))) {
|
|
return `${ROUTING} missing or unreadable: ask unchanged`
|
|
}
|
|
} catch (err) {
|
|
return `ask not applied (${String(err)})`
|
|
}
|
|
const local = st.mem.askLocal
|
|
const note = local === undefined ? '' : `; ~/${OVERRIDE} sets ask ${
|
|
local ? 'on' : 'off'} and wins here`
|
|
return `ask ${arg}${note}`
|
|
}
|
|
|
|
// ---- route tool ------------------------------------------------------
|
|
|
|
async function registerTool($: Api, st: State): Promise<void> {
|
|
try {
|
|
await $.tool.register({
|
|
name: 'route',
|
|
description:
|
|
'Declare the phase of the work ahead so the next requests of THIS ' +
|
|
'loop run at the right effort and model. Call it before a span of ' +
|
|
'work changes nature (planning, orchestrating, mechanical work). ' +
|
|
'The main loop moves up to a phase\'s tier by itself (below the ' +
|
|
'context cap), down only with the switch on; a sub-agent\'s model ' +
|
|
'is fixed at spawn. ' +
|
|
`Phases: ${Object.keys(st.cfg.phases).join(', ')}.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
phase: { type: 'string', enum: Object.keys(st.cfg.phases) },
|
|
effort: { type: 'string', enum: [...LEVELS] },
|
|
clear: { type: 'boolean', description: 'drop this loop\'s route' },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
$.ui.log(`model-router: route tool not registered: ${String(err)}`)
|
|
}
|
|
}
|
|
|
|
async function registerCommand($: Api): Promise<void> {
|
|
try {
|
|
await $.command.register({
|
|
name: 'route',
|
|
description: 'model-router: show or set the model and effort route',
|
|
argumentHint:
|
|
'[show|clear|off|on|reload|pending|ask on|off|<phase>|' +
|
|
'model=<alias|id> effort=<level>|switch on|off|verbose on|off]',
|
|
immediate: true,
|
|
})
|
|
} catch (err) {
|
|
$.ui.log(`model-router: /route not registered: ${String(err)}`)
|
|
}
|
|
}
|
|
|
|
function pickRoute(cfg: Config, phase: unknown, effort: unknown) {
|
|
const phases = Object.keys(cfg.phases).join(', ')
|
|
const named = typeof phase === 'string' ? phaseRoute(cfg, phase) : undefined
|
|
if (phase !== undefined && !named) {
|
|
return `unknown phase "${String(phase)}"; phases: ${phases}`
|
|
}
|
|
if (effort !== undefined && !isLevel(effort)) {
|
|
return `unknown effort "${String(effort)}"; levels: ${LEVELS.join(', ')}`
|
|
}
|
|
if (phase === undefined && effort === undefined) {
|
|
return `give a phase (${phases}), an effort, or clear`
|
|
}
|
|
const route: Route = { ...named, ...(isLevel(effort) ? { effort } : {}) }
|
|
const name = typeof phase === 'string' ? phase : `effort-${String(effort)}`
|
|
return { phase: name, route }
|
|
}
|
|
|
|
function applyRoute(st: State, agentId: string | undefined, p: Picked): void {
|
|
if (agentId === undefined) {
|
|
st.turnMain = { phase: p.phase, route: p.route, source: 'model' }
|
|
} else {
|
|
writeLoop(loopOf(st, agentId), p.route)
|
|
}
|
|
}
|
|
|
|
function clearLoop(st: State, agentId: string | undefined): string {
|
|
if (agentId === undefined) {
|
|
st.turnMain = null
|
|
const f = st.turnFloor
|
|
const held = f && f.route.effort !== undefined
|
|
? `; ${floorWord(f)} still holds, /route clear drops it`
|
|
: ''
|
|
const run = st.runMain
|
|
? `; run ${st.runMain.phase} still holds`
|
|
: ''
|
|
return 'route cleared for main' + held + run
|
|
}
|
|
const loop = st.loops.get(agentId)
|
|
if (loop) writeLoop(loop, {})
|
|
return 'route cleared for this agent'
|
|
}
|
|
|
|
/** The id the next main step runs on, with the decision's reason. */
|
|
async function mainAnswer($: Api, st: State): Promise<string> {
|
|
const { call } = await snapshot($, st)
|
|
return `${idWord(st, call.model)} (${call.why})`
|
|
}
|
|
|
|
/** Main's answer: effort and model asked, the sticky/floor note appended. */
|
|
async function mainRoutedText($: Api, st: State, p: Picked) {
|
|
const base = `routed main to ${p.phase}: effort ${
|
|
p.route.effort ?? 'unchanged'}, model ${await mainAnswer($, st)}`
|
|
const note = mainNote(st, p.route.effort)
|
|
return note ? `${base}; but ${note}` : base
|
|
}
|
|
|
|
/** Truthful answer: states what the calling loop will actually do. */
|
|
async function routedText(
|
|
$: Api,
|
|
st: State,
|
|
agentId: string | undefined,
|
|
p: Picked,
|
|
): Promise<string> {
|
|
if (agentId === undefined) return mainRoutedText($, st, p)
|
|
const explicit = st.loops.get(agentId)?.explicitEffort
|
|
const model = routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged'
|
|
return `routed this agent to ${p.phase}: effort ${
|
|
explicit ? 'unchanged' : p.route.effort ?? 'unchanged'}, model ${model}`
|
|
}
|
|
|
|
async function handleRouteTool($: Api, st: State, e: RouteInput) {
|
|
if (st.off) {
|
|
return {
|
|
result: 'model-router is off (/route on to resume); nothing routed',
|
|
}
|
|
}
|
|
if (e.clear === true) return { result: clearLoop(st, e.agentId) }
|
|
const picked = pickRoute(st.cfg, e.phase, e.effort)
|
|
if (typeof picked === 'string') return { deny: picked }
|
|
applyRoute(st, e.agentId, picked)
|
|
if (e.agentId === undefined && typeof e.phase === 'string') {
|
|
await confirmPhase($, st, picked.phase)
|
|
}
|
|
return { result: await routedText($, st, e.agentId, picked) }
|
|
}
|
|
|
|
// ---- skills ----------------------------------------------------------
|
|
|
|
/** A skill's table row: its phase and that phase's route, if both exist. */
|
|
function skillRow(st: State, skill: string): Picked | undefined {
|
|
const phase = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined
|
|
const route = phase === undefined ? undefined : phaseRoute(st.cfg, phase)
|
|
return phase !== undefined && route ? { phase, route } : undefined
|
|
}
|
|
|
|
/**
|
|
* A rowed skill on main sets the turn route; a best-tier row also holds the
|
|
* run slot. A typed non-best row drops the run; one the model loads (a
|
|
* helper skill inside a run) leaves it.
|
|
*/
|
|
function routeMainBySkill(st: State, row: Picked, typed: boolean): void {
|
|
const turn: Routed = { phase: row.phase, route: { ...row.route },
|
|
source: 'skill' }
|
|
st.turnMain = turn
|
|
if (row.route.tier === BEST) st.runMain = { ...turn, source: 'run' }
|
|
else if (typed) st.runMain = null
|
|
}
|
|
|
|
/** A model-loaded skill: its row applies; an unrowed one changes nothing. */
|
|
function onSkillLoad(st: State, skill: string, agentId: string | undefined) {
|
|
const row = skillRow(st, skill)
|
|
if (agentId === undefined) {
|
|
if (row) routeMainBySkill(st, row, false)
|
|
return
|
|
}
|
|
if (row) writeLoop(loopOf(st, agentId), row.route)
|
|
}
|
|
|
|
type TypedPath = 'typed-marker' | 'typed-fallback'
|
|
|
|
/**
|
|
* Whether a skill.prompt on main is the user's own typing. The marker from
|
|
* prompt.submit names the skill; failing that, an allowed-origin prompt with
|
|
* no live or spawning sub-agent (a preload fires inside one) still counts.
|
|
*/
|
|
function typedPath(st: State, skill: string): TypedPath | undefined {
|
|
if (st.typedSlash === skill) {
|
|
st.typedSlash = null
|
|
return 'typed-marker'
|
|
}
|
|
const idle = st.loops.size === 0 && st.spawning === 0
|
|
return st.promptAllowed && idle ? 'typed-fallback' : undefined
|
|
}
|
|
|
|
/** Routes main by the typed skill's row; the row, for the first-use ask. */
|
|
function armTypedSkill($: Api, st: State, skill: string) {
|
|
const row = skillRow(st, skill)
|
|
const path = typedPath(st, skill)
|
|
if (row === undefined || path === undefined) return undefined
|
|
routeMainBySkill(st, row, true)
|
|
refresh($, st)
|
|
vlog($, st, `skill ${skill}: ${path} → ${row.phase}`)
|
|
return row
|
|
}
|
|
|
|
/** Bookkeeping that may fail without breaking the hook: fail-open. */
|
|
function armed($: Api, st: State, skill: string): Picked | undefined {
|
|
try {
|
|
return armTypedSkill($, st, skill)
|
|
} catch {
|
|
warnOnce(st, $, 'skill.prompt', 'bookkeeping')
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// ---- agents ----------------------------------------------------------
|
|
|
|
type SpawnIn = {
|
|
tool_use_id: string
|
|
subagentType: string
|
|
model?: string
|
|
parentAgentId?: string // set when the spawn happens inside an agent
|
|
fork: boolean
|
|
workflow?: unknown
|
|
}
|
|
|
|
/**
|
|
* The table row of an agent, for every provider. Skipped for a fork, a
|
|
* workflow agent, and an agent whose definition came from the project (a
|
|
* foreign repo's own verifier.md). Known limit: the source is recorded by
|
|
* name only, so an offer fired inside a sub-agent with another cwd
|
|
* overwrites it; no record = the row applies (fail-open on routing).
|
|
*/
|
|
function spawnRoute(st: State, e: SpawnIn, frozen: boolean) {
|
|
if (frozen || !hasKey(st.cfg.agents, e.subagentType)) return undefined
|
|
if (PROJECT_SOURCES.has(st.offers.get(e.subagentType) ?? '')) {
|
|
return undefined
|
|
}
|
|
const phase = st.cfg.agents[e.subagentType]
|
|
return phase === undefined ? undefined : phaseRoute(st.cfg, phase)
|
|
}
|
|
|
|
/** Once per turn: a spawn whose tier has nothing at or above its head. */
|
|
function tierDownLog($: Api, st: State, agent: string, tier: string): void {
|
|
const text = `model-router: ${agent} tier ${tier} down, ` +
|
|
'frontmatter model kept'
|
|
const key = `spawn-down:${agent}:${tier}`
|
|
if (st.turnLogged.has(key)) return
|
|
st.turnLogged.add(key)
|
|
$.ui.log(text)
|
|
}
|
|
|
|
/**
|
|
* The first model of the route's tier that is not down, only when it ranks
|
|
* at or above the tier's head: an agent moves UP from its frontmatter alias,
|
|
* never below. Nothing qualifies: no write, the frontmatter model runs.
|
|
*/
|
|
function inTier($: Api, st: State, agent: string, tier: string) {
|
|
const aliases = hasKey(st.cfg.tiers, tier) ? st.cfg.tiers[tier] : undefined
|
|
const head = aliases?.[0]
|
|
const headId = head === undefined ? undefined : st.cfg.models[head]
|
|
if (aliases === undefined || headId === undefined) return undefined
|
|
const id = availableIn(st, aliases)
|
|
if (id !== undefined && (id === headId || ranksAbove(st, id, headId))) {
|
|
return id
|
|
}
|
|
tierDownLog($, st, agent, tier)
|
|
return undefined
|
|
}
|
|
|
|
/** The model a spawn is rewritten to; an explicit `model` param wins. */
|
|
function spawnTarget($: Api, st: State, e: SpawnIn, route: Route | undefined) {
|
|
if (e.model !== undefined || route === undefined) return undefined
|
|
if (route.tier === undefined) return resolveRoute(st, route)
|
|
return inTier($, st, e.subagentType, route.tier)
|
|
}
|
|
|
|
function trackLoop(st: State, e: SpawnIn, started: {
|
|
agentId?: string
|
|
}, route: Route | undefined): void {
|
|
const given = st.explicitEffort.get(e.tool_use_id)
|
|
st.explicitEffort.delete(e.tool_use_id)
|
|
if (started.agentId === undefined) return
|
|
st.loops.set(started.agentId, {
|
|
explicitEffort: given !== undefined,
|
|
effort: given ? undefined : route?.effort,
|
|
})
|
|
}
|
|
|
|
/** The breaker's target for an agent's failure; oldest entries dropped. */
|
|
function rememberAgent(st: State, agentId: string, model: string): void {
|
|
st.agentModels.set(agentId, model.replace(ONE_M, ''))
|
|
if (st.agentModels.size <= MAX_AGENT_MODELS) return
|
|
const oldest = st.agentModels.keys().next().value
|
|
if (oldest !== undefined) st.agentModels.delete(oldest)
|
|
}
|
|
|
|
// ---- derived orchestrate ---------------------------------------------
|
|
|
|
/**
|
|
* A main Agent dispatch is orchestration: the turn's route becomes
|
|
* `orchestrate` until the spawned agents end. A route the model or a skill
|
|
* declared is never replaced.
|
|
*/
|
|
function pushOrchestrate(st: State): void {
|
|
const src = st.turnMain?.source
|
|
const route = phaseRoute(st.cfg, 'orchestrate')
|
|
if (st.pushed !== null || !route) return
|
|
if (src === 'model' || src === 'skill') return
|
|
st.pushed = { prev: st.turnMain, spawnIds: new Set() }
|
|
st.turnMain = { phase: 'orchestrate', route: { ...route }, source: 'derived' }
|
|
}
|
|
|
|
/** Restores the route from before the dispatch, unless one replaced it. */
|
|
function popOrchestrate(st: State): void {
|
|
if (st.pushed && st.turnMain?.source === 'derived') {
|
|
st.turnMain = st.pushed.prev
|
|
}
|
|
st.pushed = null
|
|
}
|
|
|
|
/** The agent id of a background launch, from the Agent tool's result. */
|
|
function launchedId(result: unknown): string | undefined {
|
|
if (!isRecord(result) || result.status !== 'async_launched') return undefined
|
|
return typeof result.agentId === 'string' ? result.agentId : undefined
|
|
}
|
|
|
|
/** After the Agent call: wait for a background agent, else pop at once. */
|
|
function afterDispatch(st: State, result: unknown): void {
|
|
const id = launchedId(result)
|
|
if (id !== undefined) {
|
|
pushOrchestrate(st)
|
|
st.pushed?.spawnIds.add(id)
|
|
} else if (st.pushed && st.pushed.spawnIds.size === 0) {
|
|
popOrchestrate(st)
|
|
}
|
|
}
|
|
|
|
/** An agent's loop ended: forget it, pop when it was the last awaited. */
|
|
function endAgent(st: State, agentId: string): void {
|
|
st.loops.delete(agentId)
|
|
const awaited = st.pushed?.spawnIds
|
|
if (awaited?.delete(agentId) && awaited.size === 0) popOrchestrate(st)
|
|
}
|
|
|
|
// ---- turn steps ------------------------------------------------------
|
|
|
|
function agentPlan(st: State, e: StepIn): Plan {
|
|
const loop = e.agentId === undefined ? undefined : st.loops.get(e.agentId)
|
|
return { model: e.model, effort: loop?.effort ?? e.effort }
|
|
}
|
|
|
|
/**
|
|
* The main loop's plan: effort from the floor/sticky/turn decision, model
|
|
* from `decideMain`. `cur` is the model the router moved this turn, else
|
|
* the engine's own (verbatim: an engine fallback is respected).
|
|
*/
|
|
async function mainPlan($: Api, st: State, e: StepIn): Promise<Plan> {
|
|
const { effort } = mainEffort(st, e.effort)
|
|
await prune($, st)
|
|
const cur = st.turnModel ?? e.model
|
|
const { call } = await decideFor($, st, cur)
|
|
logCall($, st, call)
|
|
if (call.moved) st.turnModel = call.model
|
|
return { model: call.model, effort }
|
|
}
|
|
|
|
async function planStep($: Api, st: State, e: StepIn): Promise<Plan> {
|
|
const plan = e.agentId === undefined
|
|
? await mainPlan($, st, e)
|
|
: agentPlan(st, e)
|
|
// Haiku takes no effort: omit it rather than send a hook-set value.
|
|
return plan.model.startsWith(HAIKU) ? { ...plan, effort: undefined } : plan
|
|
}
|
|
|
|
function withPlan(e: StepIn, plan: Plan): StepIn {
|
|
const { effort: _replaced, ...rest } = e
|
|
const base = { ...rest, model: plan.model }
|
|
return plan.effort === undefined ? base : { ...base, effort: plan.effort }
|
|
}
|
|
|
|
function stepLog(e: StepIn, plan: Plan): string {
|
|
const id = e.agentId
|
|
const loop = id === undefined ? 'main' : `agent ${id.slice(0, 8)}`
|
|
return `step ${e.index} ${loop}: ${e.model}/${String(e.effort)} → ` +
|
|
`${plan.model}/${String(plan.effort)}`
|
|
}
|
|
|
|
function noteMain($: Api, st: State, plan: Plan): void {
|
|
st.lastPlan = plan
|
|
st.spinner = `${plan.model.replace(/^claude-/, '')}/${plan.effort ?? '-'}`
|
|
refresh($, st)
|
|
}
|
|
|
|
function endMainTurn($: Api, st: State): void {
|
|
st.turnFloor = st.pendingPrompt
|
|
st.pendingPrompt = null
|
|
st.turnMain = null
|
|
st.pushed = null
|
|
st.turnModel = undefined
|
|
st.typedSlash = st.pendingSlash
|
|
st.promptAllowed = st.pendingAllowed
|
|
st.pendingSlash = null
|
|
st.pendingAllowed = false
|
|
st.explicitEffort.clear()
|
|
st.spinner = ''
|
|
st.turnLogged.clear()
|
|
refresh($, st)
|
|
}
|
|
|
|
// ---- breaker inputs --------------------------------------------------
|
|
|
|
/** The exact id a failure is charged to: the agent's, else main's plan. */
|
|
function failureTarget(st: State, agentId: string | undefined) {
|
|
if (agentId !== undefined) return st.agentModels.get(agentId)
|
|
return st.lastPlan?.model.replace(ONE_M, '')
|
|
}
|
|
|
|
/** An engine StopFailure of an availability kind marks its model down. */
|
|
async function onStopFailure(
|
|
$: Api,
|
|
st: State,
|
|
e: { error: string; agent_id?: string },
|
|
): Promise<void> {
|
|
if (st.off || !UNAVAILABLE.has(e.error)) return
|
|
const sent = failureTarget(st, e.agent_id)
|
|
if (sent === undefined) return
|
|
if (e.error === 'model_not_found' && aliasOf(st.cfg, sent) === undefined) {
|
|
$.ui.log(`model-router: model_not_found for ${sent}: not a table id; ` +
|
|
'no mark')
|
|
return
|
|
}
|
|
await prune($, st)
|
|
markDown($, st, canonical(st.cfg, sent), e.error, await $.clock.now())
|
|
}
|
|
|
|
type SwitchIn = {
|
|
from_model: string
|
|
to_model: string
|
|
requested_model: string | null
|
|
source: string
|
|
}
|
|
|
|
/**
|
|
* The engine switched the model by itself. The model it left is marked, one
|
|
* strike, unless it landed where the router already was; the mark targets
|
|
* the model actually sent, else the model reported as left.
|
|
*/
|
|
async function onAutoSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
|
|
const cfg = st.cfg
|
|
const from = canonical(cfg, e.from_model)
|
|
const to = canonical(cfg, e.to_model)
|
|
$.ui.log(`model-router: engine switched ${from} → ${to} ` +
|
|
`(requested ${e.requested_model ?? '-'})`)
|
|
const sent = st.lastPlan ? canonical(cfg, st.lastPlan.model) : undefined
|
|
if (to === sent) return
|
|
const target = sent !== undefined && aliasOf(cfg, sent) ? sent : from
|
|
await prune($, st)
|
|
markDown($, st, target, 'engine fallback', await $.clock.now())
|
|
}
|
|
|
|
/** Any model change: keeps `sessionModel`; a user choice clears its mark. */
|
|
async function onModelSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
|
|
st.sessionModel = e.to_model
|
|
if (USER_SWITCH.has(e.source)) st.runMain = null
|
|
if (st.off || e.source === 'resume') return
|
|
if (e.source === 'auto') await onAutoSwitch($, st, e)
|
|
else clearBreaker(st, canonical(st.cfg, e.to_model))
|
|
}
|
|
|
|
// ---- registration ----------------------------------------------------
|
|
|
|
async function readSessionModel($: Api): Promise<string> {
|
|
try {
|
|
return await $.session.model()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function registerSession(on: On, st: State): void {
|
|
on('session.start', async ($, e, next) => {
|
|
await reloadConfig($, st)
|
|
st.sessionModel = await readSessionModel($)
|
|
await registerCommand($)
|
|
refresh($, st)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'session.start', next.error.kind)
|
|
return next(e)
|
|
})
|
|
on('session.end', async ($, e, next) => {
|
|
resetSession(st)
|
|
// session.start never fires after /clear: re-apply the config's
|
|
// `enabled` so a config-disabled router (offConfig) stays off.
|
|
applyEnabled(st, st.cfg.enabled)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'session.end', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
function registerBreaker(on: On, st: State): void {
|
|
on('classic.StopFailure', async ($, e, next) => {
|
|
await onStopFailure($, st, e)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'StopFailure', next.error.kind)
|
|
return next(e)
|
|
})
|
|
on('classic.PostModelSwitch', async ($, e, next) => {
|
|
await onModelSwitch($, st, e)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'PostModelSwitch', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
function registerCwd(on: On, st: State): void {
|
|
on('classic.CwdChanged', async ($, e, next) => {
|
|
// the repo key follows the directory: rebuild the layers
|
|
if (st.loaded) await reloadConfig($, st)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'CwdChanged', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
function registerCommandHook(on: On, st: State): void {
|
|
on('command.run', { command: 'route' }, async ($, e) => {
|
|
if (e.origin.kind !== 'composer') {
|
|
return { text: 'route: user-only command' }
|
|
}
|
|
await ensureConfig($, st)
|
|
return { text: await handleCommand($, st, e.args) }
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'command.run', next.error.kind)
|
|
return { text: `route failed (${next.error.kind})` }
|
|
})
|
|
}
|
|
|
|
function registerRouteTool(on: On, st: State): void {
|
|
on('tool.call', { tool: TOOL }, async ($, e) => {
|
|
await ensureConfig($, st)
|
|
const out = await handleRouteTool($, st, e)
|
|
refresh($, st)
|
|
vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`)
|
|
return out
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'route tool', next.error.kind)
|
|
return { result: `route failed (${next.error.kind}); nothing routed` }
|
|
})
|
|
}
|
|
|
|
function registerSkills(on: On, st: State): void {
|
|
on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
|
|
await ensureConfig($, st)
|
|
const skill = typeof e.skill === 'string' ? e.skill : undefined
|
|
if (st.off || skill === undefined) return next(e)
|
|
st.skillCalls += 1
|
|
try {
|
|
safely(st, $, 'Skill', () => onSkillLoad(st, skill, e.agentId))
|
|
return await next(e)
|
|
} finally {
|
|
st.skillCalls -= 1
|
|
}
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'Skill', next.error.kind)
|
|
return next(e)
|
|
})
|
|
on('skill.prompt', async ($, e, next) => {
|
|
await ensureConfig($, st)
|
|
if (st.off || st.skillCalls > 0) return next(e)
|
|
const row = armed($, st, e.skill)
|
|
if (row) await confirmSkill($, st, e.skill, row)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'skill.prompt', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
function registerAgents(on: On, st: State): void {
|
|
on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
|
|
if (st.off) return next(e)
|
|
if (isLevel(e.effort) && typeof e.tool_use_id === 'string') {
|
|
st.explicitEffort.set(e.tool_use_id, e.effort)
|
|
}
|
|
if (e.agentId !== undefined) return next(e)
|
|
pushOrchestrate(st)
|
|
const out = await next(e)
|
|
safely(st, $, 'Agent', () => afterDispatch(st, out.result))
|
|
return out
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'Agent', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
/** Bookkeeping once the agent started: its loop, its model, a log line. */
|
|
function afterSpawn(
|
|
$: Api,
|
|
st: State,
|
|
e: SpawnIn,
|
|
started: { agentId?: unknown; model: string },
|
|
route: Route | undefined,
|
|
): void {
|
|
if (typeof started.agentId !== 'string') return
|
|
const agentId = started.agentId
|
|
safely(st, $, 'agent.spawn', () => {
|
|
trackLoop(st, e, { agentId }, route)
|
|
rememberAgent(st, agentId, started.model)
|
|
vlog($, st,
|
|
`spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`)
|
|
})
|
|
}
|
|
|
|
function registerSpawn(on: On, st: State): void {
|
|
on('agent.offer', async ($, e, next) => {
|
|
if (!st.off) rememberOffer(st, e)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'agent.offer', next.error.kind)
|
|
return next(e)
|
|
})
|
|
on('agent.spawn', async ($, e, next) => {
|
|
await ensureConfig($, st)
|
|
if (st.off) return next(e)
|
|
st.spawning += 1
|
|
try {
|
|
await prune($, st)
|
|
const route = await routeSpawn($, st, e)
|
|
const wanted = spawnTarget($, st, e, route)
|
|
const started = await next(wanted === undefined
|
|
? e
|
|
: { ...e, model: wanted })
|
|
afterSpawn($, st, e, started, route)
|
|
return started
|
|
} finally {
|
|
st.spawning = Math.max(0, st.spawning - 1)
|
|
}
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'agent.spawn', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
function registerTurns(on: On, st: State): void {
|
|
on('turn.step', async function* ($, e, next) {
|
|
if (st.off) return yield* next(e)
|
|
const plan = await planStep($, st, e)
|
|
const changed = plan.model !== e.model || plan.effort !== e.effort
|
|
if (e.agentId === undefined) noteMain($, st, plan)
|
|
vlog($, st, stepLog(e, plan))
|
|
const result = yield* next(changed ? withPlan(e, plan) : e)
|
|
safely(st, $, 'turn.step', () => vlog($, st,
|
|
`step ${e.index} answered by ${result.usage?.model ?? '?'}`))
|
|
return result
|
|
}).catch(async function* ($, e, next) {
|
|
warnOnce(st, $, 'turn.step', next.error.kind)
|
|
return yield* next(e)
|
|
})
|
|
on('turn.complete', async ($, e, next) => {
|
|
const agentId = e.agentId
|
|
if (agentId !== undefined) safely(st, $, 'turn.complete', () =>
|
|
endAgent(st, agentId))
|
|
else endMainTurn($, st)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'turn.complete', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* A prompt's level is a floor now; typed mid-turn (`wait` is ignored, the
|
|
* engine queues either way) it is also kept for the next turn.
|
|
*/
|
|
function floorFromPrompt(st: State, midTurn: boolean, routed: Routed): void {
|
|
st.turnFloor = higherFloor(st.turnFloor, routed)
|
|
if (midTurn) st.pendingPrompt = higherFloor(st.pendingPrompt, routed)
|
|
}
|
|
|
|
const firstRule = (st: State, mode: PromptMode, text: string) =>
|
|
st.rules.find(r => r.mode === mode && r.re.test(text))
|
|
|
|
/** A default rule sets the idle turn's route; routes and skills override. */
|
|
function defaultFromPrompt(st: State, scanned: string): void {
|
|
const rule = firstRule(st, 'default', scanned)
|
|
const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined
|
|
if (rule && route) {
|
|
st.turnMain = { phase: rule.phase, route: { ...route }, source: 'prompt' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Floor rules first (the user's explicit minimum). Default rules run only
|
|
* on an idle, plain prompt: not mid-turn, not a slash command or skill
|
|
* (they route themselves), not one that already carries a floor.
|
|
*/
|
|
function routeFromPrompt(st: State, text: string, midTurn: boolean): void {
|
|
const scanned = text.slice(0, MAX_PROMPT_SCAN)
|
|
const floor = firstRule(st, 'floor', scanned)
|
|
const route = floor ? phaseRoute(st.cfg, floor.phase) : undefined
|
|
if (floor && route) {
|
|
const routed: Routed = { phase: floor.phase, route, source: 'prompt' }
|
|
floorFromPrompt(st, midTurn, routed)
|
|
}
|
|
const slash = text.trimStart().startsWith('/')
|
|
if (!midTurn && !slash && !floor) defaultFromPrompt(st, scanned)
|
|
}
|
|
|
|
/** The rowed skill a slash prompt names, else null. */
|
|
function typedSkill(st: State, text: string): string | null {
|
|
const typed = text.trimStart()
|
|
if (!typed.startsWith('/')) return null
|
|
const name = typed.slice(1).split(/\s/, 1)[0] ?? ''
|
|
return hasKey(st.cfg.skills, name) ? name : null
|
|
}
|
|
|
|
/**
|
|
* Records what skill.prompt needs to tell a typed slash from a preload: the
|
|
* skill named and whether a person typed it. A mid-turn prompt waits in the
|
|
* pending slots for the turn end; a foreign one leaves them alone. A later
|
|
* queued prompt replaces an earlier one (a shortcut: one pending slot).
|
|
*/
|
|
function noteTyped(st: State, kind: string, text: string, midTurn: boolean) {
|
|
const allowed = TYPED_ORIGINS.has(kind)
|
|
const slash = allowed ? typedSkill(st, text) : null
|
|
if (!midTurn) {
|
|
st.typedSlash = slash
|
|
st.promptAllowed = allowed
|
|
} else if (allowed) {
|
|
st.pendingSlash = slash
|
|
st.pendingAllowed = true
|
|
}
|
|
}
|
|
|
|
function registerPrompt(on: On, st: State): void {
|
|
on('prompt.submit', async ($, e, next) => {
|
|
await ensureConfig($, st, true)
|
|
if (st.off) return next(e)
|
|
const midTurn = e.turnId !== undefined
|
|
noteTyped(st, e.origin.kind, e.text, midTurn)
|
|
if (e.origin.kind === 'composer') routeFromPrompt(st, e.text, midTurn)
|
|
refresh($, st)
|
|
return next(e)
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'prompt.submit', next.error.kind)
|
|
return next(e)
|
|
})
|
|
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
|
|
if (st.off || !st.cfg.spinner || !st.spinner) return next(e)
|
|
const suffix = ` · ${st.spinner}…`
|
|
return next({ ...e, props: { ...e.props, suffix } })
|
|
}).catch(($, e, next) => {
|
|
warnOnce(st, $, 'ui.render', next.error.kind)
|
|
return next(e)
|
|
})
|
|
}
|
|
|
|
export const register: Register = on => {
|
|
const st = newState(mergeConfig(undefined, () => undefined), 'defaults')
|
|
registerSession(on, st)
|
|
registerBreaker(on, st)
|
|
registerCwd(on, st)
|
|
registerCommandHook(on, st)
|
|
registerRouteTool(on, st)
|
|
registerSkills(on, st)
|
|
registerAgents(on, st)
|
|
registerSpawn(on, st)
|
|
registerTurns(on, st)
|
|
registerPrompt(on, st)
|
|
}
|