char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
a4e772a
main
1import type { Context } from "@earendil-works/chord" ; 2import { getOrThrow } from "@earendil-works/pi-durable/env" ; 3import type { Environment } from "@berry/api" ; 4import { parse } from "@std/yaml" ; 5import { userDirectory } from "./paths.ts" ; 6 7export const MAX_SKILL_BYTES = 64 * 1024 ; 8const MAX_SKILLS = 128 ; 9const MAX_COLLECTION_ENTRIES = 256 ; 10 11export type Skill = { 12name :string ; 13description :string ; 14directory :string ; 15location :string ; 16scope :"project" | "user" | "host" ; 17}; 18 19export type Catalog = { 20projectRoot :string ; 21skills :Skill []; 22diagnostics :string []; 23}; 24 25export function parseSkill ( content :string , directoryName :string ) { 26const frontmatter = content . match ( / ^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$) / m ); 27if ( ! frontmatter || frontmatter . index !== 0 ) throw new Error ( "Missing YAML frontmatter" ); 28const metadata = parse ( frontmatter [ 1 ], { schema :"core" }); 29if ( metadata === null || typeof metadata !== "object" || Array . isArray ( metadata )) { 30throw new Error ( "Frontmatter must be a mapping" ); 31} 32const { name, description} = metadata as Record < string , unknown >; 33if ( typeof name !== "string" || name . length > 64 || ! /^[a-z0-9]+(?:-[a-z0-9]+)*$ / . test ( name )) { 34throw new Error ( "name must be at most 64 lowercase letters, numbers, and single hyphens" ); 35} 36if ( typeof description !== "string" || ! description . trim () || description . length > 1024 ) { 37throw new Error ( "description must be a nonempty string of at most 1024 characters" ); 38} 39const warnings :string [] = []; 40if ( name !== directoryName ) warnings . push ( `name ${ name } differs from directory ${ directoryName } ` ); 41return { name, description, warnings}; 42} 43 44/** A bounded, complete UTF-8 read of a regular skill file. */ 45export async function readSkill ( env :Environment , location :string , context :Context ) { 46const reader = getOrThrow ( await env . openBinaryReader ( location , undefined , context )); 47try { 48const { size} = getOrThrow ( await reader . info ( context )); 49if ( size > MAX_SKILL_BYTES ) throw new Error ( `SKILL.md exceeds ${ MAX_SKILL_BYTES / 1024 } KiB` ); 50const bytes = getOrThrow ( await reader . read ( 0 , MAX_SKILL_BYTES + 1 , context )); 51if ( bytes . length > MAX_SKILL_BYTES ) { 52throw new Error ( `SKILL.md exceeds ${ MAX_SKILL_BYTES / 1024 } KiB` ); 53} 54return new TextDecoder ( "utf-8" , { fatal :true }). decode ( bytes ); 55} finally { 56await reader . close ( context ); 57} 58} 59 60/** The cwd's ancestors through its nearest repository root, or just the cwd outside a repository. */ 61export async function projectDirectories ( env :Environment , context :Context ) :Promise < string []> { 62const cwd = getOrThrow ( await env . canonicalPath ( env . cwd , context )); 63const ancestors :string [] = []; 64for ( let dir = cwd ; ancestors . length < 128 ;) { 65ancestors . push ( dir ); 66for ( const marker of [ ".git" , ".jj" ]) { 67const path = getOrThrow ( await env . joinPath ([ dir , marker ], context )); 68const found = await env . fileInfo ( path , context ); 69if ( found . ok ) return ancestors ; 70if ( found . error . code !== "not_found" && found . error . code !== "not_directory" ) { 71throw found . error ; 72} 73} 74const parent = getOrThrow ( await env . joinPath ([ dir , ".." ], context )); 75if ( parent === dir ) break ; 76dir = parent ; 77} 78return [ cwd ]; 79} 80 81/** Discover only known collections, with project roots omitted unless explicitly allowed. */ 82export async function discover ( 83env :Environment , 84host :Environment , 85context :Context , 86trustedRoots ?:readonly string [], 87) :Promise < Catalog > { 88const directories = await projectDirectories ( env , context ); 89const projectRoot = directories . at ( - 1 ) ! ; 90const sources :{ env :Environment ; path :string ; scope :Skill [ "scope" ] }[] = []; 91if ( trustedRoots === undefined || trustedRoots . includes ( projectRoot )) { 92for ( const dir of directories ) { 93sources . push ({ 94 env, 95path :getOrThrow ( await env . joinPath ([ dir , ".agents" , "skills" ], context )), 96scope :"project" , 97}); 98} 99} 100const config = await userDirectory ( host , "XDG_CONFIG_HOME" , [ ".config" ], context ); 101sources . push ( 102{ 103env :host , 104path :getOrThrow ( await host . joinPath ([ config , "berry" , "skills" ], context )), 105scope :"host" , 106}, 107{ 108 env, 109path :getOrThrow ( await env . joinPath ([ env . home , ".agents" , "skills" ], context )), 110scope :"user" , 111}, 112); 113 114const skills = new Map < string , Skill >(); 115const diagnostics :string [] = []; 116const seen = new Set < string >(); 117for ( const source of sources ) { 118const env = source . env ; 119const opened = await env . openDirReader ( source . path , context ); 120if ( ! opened . ok ) { 121if ( opened . error . code === "aborted" ) throw opened . error ; 122if ( opened . error . code !== "not_found" ) { 123diagnostics . push ( ` ${ source . path } : ${ opened . error . message } ` ); 124} 125continue ; 126} 127const reader = opened . value ; 128let page ; 129try { 130page = getOrThrow ( await reader . next ( MAX_COLLECTION_ENTRIES + 1 , context )); 131} finally { 132await reader . close ( context ); 133} 134if ( ! page . done || page . entries . length > MAX_COLLECTION_ENTRIES ) { 135diagnostics . push ( ` ${ source . path } : stopped after ${ MAX_COLLECTION_ENTRIES } entries` ); 136} 137const entries = page . entries . slice ( 0 , MAX_COLLECTION_ENTRIES ). sort (( a , b ) => 138a . name . localeCompare ( b . name ) 139); 140for ( const entry of entries ) { 141if ( entry . kind === "file" || entry . name . startsWith ( "." )) continue ; 142const location = getOrThrow ( await env . joinPath ([ entry . path , "SKILL.md" ], context )); 143const canonical = await env . canonicalPath ( location , context ); 144if ( ! canonical . ok ) { 145if ( canonical . error . code === "aborted" ) throw canonical . error ; 146if ( canonical . error . code !== "not_found" ) { 147diagnostics . push ( ` ${ location } : ${ canonical . error . message } ` ); 148} 149continue ; 150} 151const identity = ` ${ env . id } \0 ${ canonical . value } ` ; 152if ( seen . has ( identity )) continue ; 153seen . add ( identity ); 154try { 155const content = await readSkill ( env , canonical . value , context ); 156const { name, description, warnings} = parseSkill ( content , entry . name ); 157for ( const warning of warnings ) diagnostics . push ( ` ${ location } : ${ warning } ` ); 158const previous = skills . get ( name ); 159if ( previous ) { 160diagnostics . push ( ` ${ location } : ${ name } is shadowed by ${ previous . location } ` ); 161continue ; 162} 163if ( skills . size === MAX_SKILLS ) { 164diagnostics . push ( `Stopped after ${ MAX_SKILLS } skills` ); 165return { projectRoot, skills :[ ...skills . values ()], diagnostics}; 166} 167skills . set ( name , { 168 name, 169 description, 170directory :getOrThrow ( await env . canonicalPath ( entry . path , context )), 171location :canonical . value , 172scope :source . scope , 173}); 174} catch ( error ) { 175context . abortSignal ?. throwIfAborted (); 176if ( error instanceof Error && "code" in error && error . code === "aborted" ) throw error ; 177diagnostics . push ( ` ${ location } : ${ error instanceof Error ? error . message : String ( error )} ` ); 178} 179} 180} 181return { projectRoot, skills :[ ...skills . values ()], diagnostics}; 182}