60 lines
3.1 KiB
TypeScript
60 lines
3.1 KiB
TypeScript
/** YAML roster entries, skill metadata, model references, and inherited tools. */
|
|
import { parse } from 'yaml'
|
|
export const CONFIG = '/etc/sausage/config.yaml'
|
|
export const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
export type Thinking = typeof thinkingLevels[number]
|
|
export interface Definition {
|
|
name: string
|
|
description: string
|
|
path: string
|
|
instructions: string
|
|
model: string
|
|
thinking?: Thinking
|
|
tools?: string[]
|
|
addTools?: string[]
|
|
denyTools?: string[]
|
|
isolate?: 'none' | 'worktree'
|
|
resident?: boolean
|
|
fast?: boolean
|
|
summaries?: boolean
|
|
icon?: string
|
|
tint?: string
|
|
}
|
|
export function parseFrontmatter(text: string) {
|
|
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(text)
|
|
if (!match) throw new Error('Expected frontmatter fields between --- lines')
|
|
return {frontmatter:parse(match[1]) as Record<string,unknown>, body:match[2].trim()}
|
|
}
|
|
export function parseRoster(text: string, path: string) {
|
|
const value = parse(text)
|
|
if (!value || !Array.isArray(value.roster)) throw new Error(`${path}: roster must be a list`)
|
|
const names = new Set<string>()
|
|
const definitions = value.roster.map((entry: any): Definition => {
|
|
if (!entry || typeof entry.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error(`${path}: invalid roster name`)
|
|
if (names.has(entry.name)) throw new Error(`${path}: duplicate agent ${entry.name}`)
|
|
names.add(entry.name)
|
|
for (const key of ['description', 'prompt', 'model']) if (typeof entry[key] !== 'string' || !entry[key].trim()) throw new Error(`${path}: ${entry.name}.${key} is required`)
|
|
if (entry.thinking !== undefined && !thinkingLevels.includes(entry.thinking)) throw new Error(`${path}: invalid thinking for ${entry.name}`)
|
|
for (const key of ['tools', 'addTools', 'denyTools']) if (entry[key] !== undefined && (!Array.isArray(entry[key]) || !entry[key].every((tool: any)=>typeof tool === 'string'))) throw new Error(`${path}: ${entry.name}.${key} must be a list of tool names`)
|
|
const {prompt, ...settings} = entry
|
|
const definition = {...settings, path, instructions:prompt}
|
|
definitionRuns(definition)
|
|
return definition
|
|
})
|
|
return {...value, definitions}
|
|
}
|
|
export function parseConfig(text: string) {
|
|
const value = parseRoster(text, CONFIG)
|
|
if (typeof value.prompt !== 'string') throw new Error(`${CONFIG}: prompt must be a string`)
|
|
if (!value.definitions.some((entry: Definition)=>entry.name === value.standing)) throw new Error(`${CONFIG}: standing must name a roster member`)
|
|
return value
|
|
}
|
|
export function definitionRuns(definition: Definition) {
|
|
if (!/^[^/\s]+\/\S+$/.test(definition.model)) throw new Error(`${definition.path}: invalid model for ${definition.name}`)
|
|
const slash = definition.model.indexOf('/')
|
|
return {model:{provider:definition.model.slice(0,slash),modelId:definition.model.slice(slash+1)},thinking:definition.thinking}
|
|
}
|
|
export function toolsFor(definition: Definition, parent: readonly string[]): string[] {
|
|
return [...new Set([...(definition.tools ?? parent), ...(definition.addTools ?? [])])].filter(tool => !definition.denyTools?.includes(tool))
|
|
}
|