import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { z } from "zod/v4"; import { isValidRegistryRef } from "./skills.ts"; const LOCAL_SKILL_RE = /^local:([a-z0-9][a-z0-9_-]*)$/; export function isLocalSkillRef(ref: string): boolean { return LOCAL_SKILL_RE.test(ref); } export const IfThenRuleSchema = z.object({ condition: z.string(), action: z.string(), }); export const ScheduleRuleSchema = z.object({ timing: z.string(), task: z.string(), }); export const SanctumSchema = z.object({ path: z.string(), readonly: z.boolean(), }); export const AgentConfigSchema = z.object({ model: z.string(), identity: z.string().min(1), name: z.string(), ifThen: z.array(IfThenRuleSchema), schedule: z.array(ScheduleRuleSchema), skills: z.array(z.string()), sanctums: z.array(SanctumSchema), sourcePath: z.string(), maxIterations: z.number().optional(), maxHistory: z.number().optional(), memory: z.string().optional(), cpus: z.string().optional(), }); export type IfThenRule = z.infer; export type ScheduleRule = z.infer; export type Sanctum = z.infer; export type AgentConfig = z.infer; const IF_THEN_RE = /^IF\s+"([^"]+)"\s+THEN\s+"([^"]+)"$/; const SCHEDULE_RE = /^SCHEDULE\s+"([^"]+)"\s+"([^"]+)"$/; const HEREDOC_START_RE = /^<<(\w+)$/; function resolveSanctumPath(raw: string): string { if (raw.startsWith("~/") || raw === "~") { return resolve(homedir(), raw.slice(2)); } return resolve(raw); } export function parseAgentContent(content: string, name: string, sourcePath = ""): AgentConfig { const lines = content.split("\n"); let model: string | undefined; let identity: string | undefined; let maxIterations: number | undefined; let maxHistory: number | undefined; let memory: string | undefined; let cpus: string | undefined; const ifThen: IfThenRule[] = []; const schedule: ScheduleRule[] = []; const skills: string[] = []; const sanctums: { path: string; readonly: boolean }[] = []; let i = 0; while (i < lines.length) { const line = lines[i]!.trim(); i++; if (line === "" || line.startsWith("#")) continue; if (line.startsWith("MODEL ")) { if (model !== undefined) throw new ParseError("Duplicate MODEL instruction", i); model = line.slice(6).trim(); if (!model) throw new ParseError("MODEL requires a value", i); continue; } if (line.startsWith("SOUL ")) { if (identity !== undefined) throw new ParseError("Duplicate SOUL instruction", i); const value = line.slice(5).trim(); const heredocMatch = value.match(HEREDOC_START_RE); if (heredocMatch) { const delimiter = heredocMatch[1]!; const bodyLines: string[] = []; while (i < lines.length) { const hline = lines[i]!; if (hline.trimEnd() === delimiter) { i++; break; } bodyLines.push(hline); i++; } identity = bodyLines.join("\n").trim(); } else { identity = value; } if (!identity) throw new ParseError("SOUL requires a value", i); continue; } if (line === "SKILL" || line.startsWith("SKILL ")) { const ref = line.slice(5).trim(); if (!ref) throw new ParseError("SKILL requires a value", i); if (!isValidRegistryRef(ref) && !isLocalSkillRef(ref)) { throw new ParseError( `Invalid SKILL reference '${ref}'. Must be owner/repo@skill-name or local: format`, i, ); } skills.push(ref); continue; } if (line === "SANCTUM" || line.startsWith("SANCTUM ")) { const value = line.slice(7).trim(); if (!value) throw new ParseError("SANCTUM requires a path", i); const isReadonly = value.endsWith(":ro"); const rawPath = isReadonly ? value.slice(0, -3) : value.replace(/:rw$/, ""); if (!rawPath) throw new ParseError("SANCTUM requires a path", i); const resolved = resolveSanctumPath(rawPath); sanctums.push({ path: resolved, readonly: isReadonly }); continue; } const ifThenMatch = line.match(IF_THEN_RE); if (ifThenMatch) { ifThen.push({ condition: ifThenMatch[1]!, action: ifThenMatch[2]! }); continue; } const scheduleMatch = line.match(SCHEDULE_RE); if (scheduleMatch) { schedule.push({ timing: scheduleMatch[1]!, task: scheduleMatch[2]! }); continue; } if (line.startsWith("MAX_ITERATIONS ")) { const val = parseInt(line.slice(15).trim(), 10); if (Number.isNaN(val) || val < 1) throw new ParseError("MAX_ITERATIONS must be a positive integer", i); maxIterations = val; continue; } if (line.startsWith("MAX_HISTORY ")) { const val = parseInt(line.slice(12).trim(), 10); if (Number.isNaN(val) || val < 1) throw new ParseError("MAX_HISTORY must be a positive integer", i); maxHistory = val; continue; } if (line.startsWith("MEMORY ")) { memory = line.slice(7).trim(); if (!memory) throw new ParseError("MEMORY requires a value (e.g. '512m', '1g')", i); continue; } if (line.startsWith("CPUS ")) { cpus = line.slice(5).trim(); if (!cpus) throw new ParseError("CPUS requires a value (e.g. '0.5', '2')", i); continue; } throw new ParseError(`Unknown instruction: ${line}`, i); } if (model === undefined) throw new ParseError("Missing required MODEL instruction", 0); if (identity === undefined) throw new ParseError("Missing required SOUL instruction", 0); return AgentConfigSchema.parse({ model, identity, name, ifThen, schedule, skills, sanctums, sourcePath, maxIterations, maxHistory, memory, cpus, }); } export function parseSoulfile(filePath: string): AgentConfig { const absolutePath = resolve(filePath); if (!absolutePath.endsWith(".soul")) { throw new ParseError("Soul files must use the .soul extension", 0); } const content = readFileSync(absolutePath, "utf-8"); const filename = basename(absolutePath); const name = filename === "agent.soul" ? basename(dirname(absolutePath)) : filename.replace(/\.soul$/, ""); return parseAgentContent(content, name, absolutePath); } export class ParseError extends Error { line: number; constructor(message: string, line: number) { super(`Parse error (line ${line}): ${message}`); this.name = "ParseError"; this.line = line; } } export function findSoulfiles(path: string): string[] { const resolved = resolve(path); if (resolved.endsWith(".soul")) { return existsSync(resolved) ? [resolved] : []; } if (!existsSync(resolved)) return []; const glob = new Bun.Glob("*.soul"); return Array.from(glob.scanSync({ cwd: resolved, absolute: true })).sort(); }