/** Pure parsing and ordered overlays for world agent files. The caller supplies all file contents and model configuration. */ export const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const export type Thinking = typeof thinkingLevels[number] export type Family = { model: string; thinking?: Thinking } export type Families = Record export interface Definition { name: string description: string path: string instructions: string model?: string thinking?: Thinking family?: string tools?: string[] addTools?: string[] denyTools?: string[] isolate?: 'none' | 'worktree' resident?: boolean fast?: boolean summaries?: boolean icon?: string tint?: string overlays?: string[] } export type File = { path: string; text: string } const fields = ['name', 'description', 'model', 'thinking', 'family', 'tools', 'denyTools', 'isolate', 'resident', 'fast', 'summaries', 'icon', 'tint'] const familyName = (name: string) => /^[a-z][a-z0-9-]{0,23}$/.test(name) const tools = (value: unknown): value is string[] => Array.isArray(value) && value.every(v => typeof v === 'string') /** Scalars and flow lists, plus the block lists and folded description used in Ultimate's parser tests. Not general YAML. */ function scalar(input: string): unknown { const text = input.trim() if (text.startsWith('[')) { if (!text.endsWith(']')) throw new Error('Unclosed flow list') const items = text.slice(1, -1).match(/\s*(?:"(?:\\.|[^"\\])*"|'(?:''|[^'])*'|[^,]+)/g) ?? [] return items.filter(item => item.trim()).map(item => scalar(item)) } if (text.startsWith('"')) return JSON.parse(text) if (text.startsWith("'")) { if (!text.endsWith("'")) throw new Error('Unclosed scalar') return text.slice(1, -1).replaceAll("''", "'") } if (text === 'true') return true if (text === 'false') return false if (text === 'null' || text === '~' || text === '') return null if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text) if (text.startsWith('{')) return {} // Mapping values cannot satisfy any supported field. return text.replace(/\s+#.*$/, '') } export function parseFrontmatter(text: string): { frontmatter: Record; body: string } { const lines = text.replaceAll('\r\n', '\n').split('\n') const end = lines.indexOf('---', 1) if (lines[0] !== '---' || end < 0) throw new Error('Expected frontmatter fields between --- lines') const frontmatter: Record = {} for (let i = 1; i < end; i++) { if (!lines[i].trim() || lines[i].trimStart().startsWith('#')) continue const match = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(lines[i]) if (!match) throw new Error('Expected frontmatter fields between --- lines') const [, key, raw] = match let value = raw if (value.trim().startsWith('[') && !value.trim().endsWith(']')) { while (i + 1 < end && !value.trim().endsWith(']')) value += ' ' + lines[++i].trim() } if (raw === '>' || raw === '|' || raw === '') { const block: string[] = [] while (i + 1 < end && /^\s+\S/.test(lines[i + 1])) block.push(lines[++i].trim()) if (raw === '>') frontmatter[key] = block.join(' ') else if (raw === '|') frontmatter[key] = block.join('\n') else frontmatter[key] = block[0]?.startsWith('[') ? scalar(block.join(' ')) : block.length ? block.map(line => scalar(line.replace(/^-\s+/, ''))) : null } else frontmatter[key] = scalar(value) } if (!Object.keys(frontmatter).length) throw new Error('Expected frontmatter fields between --- lines') return { frontmatter, body: lines.slice(end + 1).join('\n').trim() } } function settings(data: Record, overlay: boolean) { const unknown = Object.keys(data).find(key => !fields.includes(key) && !(overlay && key === 'addTools')) if (unknown) throw new Error(`Unknown field: ${unknown}`) if (typeof data.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(data.name)) throw new Error('name must be a nonempty identifier') for (const key of ['description', 'model']) { if (data[key] !== undefined && (typeof data[key] !== 'string' || !data[key].trim())) throw new Error(key === 'description' ? 'description is required' : 'model must be a nonempty string') } if (data.family !== undefined && (typeof data.family !== 'string' || !familyName(data.family))) throw new Error('family must be a family name: lowercase letters, digits and -') if (data.thinking !== undefined && !thinkingLevels.includes(data.thinking as Thinking)) throw new Error(`thinking must be one of ${thinkingLevels.join(', ')}`) for (const key of ['tools', 'denyTools', 'addTools']) { if (data[key] !== undefined && !tools(data[key])) throw new Error(`${key} must be a list of tool names`) } if (data.isolate !== undefined && data.isolate !== 'none' && data.isolate !== 'worktree') throw new Error('isolate must be none or worktree') for (const key of ['resident', 'fast', 'summaries']) { if (data[key] !== undefined && typeof data[key] !== 'boolean') throw new Error(`${key} must be a boolean`) } if (data.icon !== undefined && (typeof data.icon !== 'string' || !/^[A-Za-z][A-Za-z0-9]*(-[a-z0-9]+)*$/.test(data.icon))) throw new Error('icon must be a lucide icon name, like Gem or notebook-pen') if (data.tint !== undefined && !['violet', 'blue', 'green', 'amber', 'red', 'pink', 'teal', 'grey'].includes(data.tint as string)) throw new Error('tint must be one of violet, blue, green, amber, red, pink, teal, grey') return { ...data, ...(typeof data.description === 'string' ? { description: data.description.trim() } : {}) } } export function parseDefinition(text: string, path: string): Definition { const { frontmatter, body } = parseFrontmatter(text) const parsed = settings(frontmatter, false) if (!parsed.description) throw new Error('description is required') if (!body) throw new Error('Instructions are required') return { ...parsed, path, instructions: body } as unknown as Definition } export function parseOverlay(text: string, path: string) { const { frontmatter, body } = parseFrontmatter(text) if ('tools' in frontmatter) throw new Error('An overlay adds tools with addTools, not tools') const parsed = settings(frontmatter, true) if (!tools(parsed.addTools)) throw new Error('addTools must be a list of tool names') const { name, addTools, ...rest } = parsed return { name: name as string, addTools, settings: rest as Partial, path, instructions: body } } export function mergeOverlay(base: Definition, overlay: ReturnType): Definition { const { model: _pin, ...unpinned } = base const suffix = overlay.settings.model?.match(/:(off|minimal|low|medium|high|xhigh|max)$/) const resolved = suffix ? { ...overlay.settings, model: overlay.settings.model!.slice(0, -suffix[0].length), thinking: overlay.settings.thinking ?? suffix[1] as Thinking } : overlay.settings return { ...(overlay.settings.family && !overlay.settings.model ? unpinned : base), ...resolved, ...(overlay.settings.denyTools ? { denyTools: [...new Set([...(base.denyTools ?? []), ...overlay.settings.denyTools])] } : {}), ...(base.tools ? { tools: [...new Set([...base.tools, ...overlay.addTools])] } : overlay.addTools.length ? { addTools: [...new Set([...(base.addTools ?? []), ...overlay.addTools])] } : {}), instructions: overlay.instructions ? `${base.instructions}\n\n## Project additions\n\n${overlay.instructions}` : base.instructions, overlays: [...(base.overlays ?? []), overlay.path], } } export function parseFamilies(text: string): Families { const value = JSON.parse(text) if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('families must be an object') for (const [name, spec] of Object.entries(value) as [string, Family][]) { if (!familyName(name) || !spec || typeof spec !== 'object' || !modelRef(spec.model)) throw new Error(`Invalid family: ${name}`) if (spec.thinking !== undefined && !thinkingLevels.includes(spec.thinking)) throw new Error(`Invalid thinking for family: ${name}`) } if (!value.default) throw new Error('default family is required') return value } function modelRef(model: unknown): model is string { return typeof model === 'string' && /^[^/\s]+\/\S+$/.test(model) } export function definitionRuns(definition: Definition, families: Families, models: readonly string[]) { const family = Object.hasOwn(families, definition.family ?? 'default') ? definition.family ?? 'default' : 'default' let model = definition.model ?? families[family].model let thinking = definition.thinking ?? (definition.model ? undefined : families[family].thinking) const suffix = /:(off|minimal|low|medium|high|xhigh|max)$/.exec(model) if (suffix) { model = model.slice(0, -suffix[0].length); thinking = definition.thinking ?? suffix[1] as Thinking } if (!modelRef(model) || !models.includes(model)) throw new Error(`model: Model "${model}" not found`) const slash = model.indexOf('/') return { model: { provider: model.slice(0, slash), modelId: model.slice(slash + 1) }, thinking, ...(definition.model ? { via: 'pin' as const } : { via: 'family' as const, family }) } } /** Sorted built-ins precede sorted project files. Invalid files produce errors without replacing an earlier definition. */ export function discoverAgents(files: readonly File[], builtinRoot: string, repo: string, families: Families, models: readonly string[]) { const definitions = new Map() const errors: { name: string; path: string; source: string; error: string }[] = [] const replaced: { name: string; path: string; source: string }[] = [] for (const [source, root] of [['builtin', builtinRoot + '/agents/'], ['project', repo + '/.sausage/agents/']] as const) { for (const file of files.filter(file => file.path.startsWith(root) && !file.path.slice(root.length).includes('/') && file.path.endsWith('.md')).toSorted((a, b) => a.path.localeCompare(b.path))) { try { let definition: Definition if ('addTools' in parseFrontmatter(file.text).frontmatter) { const overlay = parseOverlay(file.text, file.path) const base = definitions.get(overlay.name) if (!base) throw new Error(`addTools needs an earlier definition named ${overlay.name}`) definition = mergeOverlay(base, overlay) } else definition = parseDefinition(file.text, file.path) definitionRuns(definition, families, models) const previous = definitions.get(definition.name) if (source === 'project' && previous?.source === 'builtin' && !definition.overlays) replaced.push({ name: definition.name, path: file.path, source }) definitions.set(definition.name, { ...definition, source: definition.overlays ? previous!.source : source }) } catch (error) { errors.push({ name: file.path.split('/').at(-1)!.slice(0, -3), path: file.path, source, error: String(error) }) } } } return { definitions: [...definitions.values()].map(definition => ({ ...definition, runs: definitionRuns(definition, families, models) })), errors, replaced } } /** Tool denials also apply to inherited tools. Inputs stay unchanged. */ export function toolsFor(definition: Definition, parent: readonly string[]): string[] { return [...new Set([...(definition.tools ?? parent), ...(definition.addTools ?? [])])].filter(tool => !definition.denyTools?.includes(tool)) }