57 lines
3.2 KiB
TypeScript
57 lines
3.2 KiB
TypeScript
/** Markdown roster files, YAML settings and 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 const ROSTER = '/etc/sausage/roster'
|
|
export function parseDefinition(text: string, path: string, inherited?: Definition): Definition {
|
|
const {frontmatter, body} = parseFrontmatter(text)
|
|
const name = path.split('/').pop()!.replace(/\.md$/, '')
|
|
const settings = {...(inherited ?? {}), ...frontmatter}
|
|
const instructions = inherited && frontmatter.addTools !== undefined ? [inherited.instructions, body].filter(Boolean).join('\n\n## Project additions\n\n') : body
|
|
const definition = {...settings, name, path, instructions} as Definition
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error(`${path}: invalid roster name`)
|
|
for (const key of ['description','model','instructions']) if (typeof definition[key] !== 'string' || !definition[key].trim()) throw new Error(`${path}: ${key} is required`)
|
|
if (definition.thinking !== undefined && !thinkingLevels.includes(definition.thinking)) throw new Error(`${path}: invalid thinking`)
|
|
for (const key of ['tools','addTools','denyTools']) if (definition[key] !== undefined && (!Array.isArray(definition[key]) || !definition[key].every((tool: any)=>typeof tool === 'string'))) throw new Error(`${path}: ${key} must be a list of tool names`)
|
|
if (inherited && frontmatter.addTools !== undefined) definition.tools = toolsFor(definition, inherited.tools ?? [])
|
|
definitionRuns(definition)
|
|
return definition
|
|
}
|
|
export function parseConfig(text: string) {
|
|
const value = parse(text)
|
|
if (typeof value?.prompt !== 'string') throw new Error(`${CONFIG}: prompt must be a string`)
|
|
if (typeof value.main_agent_name !== 'string' || !value.main_agent_name) throw new Error(`${CONFIG}: main_agent_name must name a roster file`)
|
|
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))
|
|
}
|