char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
b2cc381
main
1import { Buffer } from "node:buffer" ; 2import { extname } from "node:path" ; 3import type { Context } from "@earendil-works/chord" ; 4import { BACKGROUND_CONTEXT , withAbortSignal } from "@earendil-works/chord/context" ; 5import { Type } from "@earendil-works/pi-ai" ; 6import { type FileError , getOrThrow } from "@earendil-works/pi-durable/env" ; 7import ignore , { type Ignore } from "ignore" ; 8import { defineExtension , type Environment , type Tool } from "@berry/api" ; 9import { 10decodeText , 11fitting , 12MAX_BYTES , 13MAX_LINES , 14MAX_TEXT_BYTES , 15planEdit , 16renderRead , 17type TextFile , 18} from "./hashline.ts" ; 19import { hasWorkspace , workspace } from "./workspace.ts" ; 20import { hasCommand , powershellCommand } from "./commands.ts" ; 21 22type EditMode = "naive" | "hashline" | "both" ; 23type State = { editMode :EditMode }; 24 25/** Only old history has these: the harness keeps the working directory now. */ 26type Cd = { type :"cd" ; cwd :string }; 27export type Event = Cd | { type :"editMode" ; editMode :EditMode }; 28 29const IMAGE_TYPES :Record < string , string > = { 30".png" :"image/png" , 31".jpg" :"image/jpeg" , 32".jpeg" :"image/jpeg" , 33".gif" :"image/gif" , 34".webp" :"image/webp" , 35".bmp" :"image/bmp" , 36}; 37const MAX_IMAGE_BYTES = 5 * 1024 * 1024 ; 38 39class TooLarge extends Error {} 40 41/** The whole of the regular file at `path`, which must be no larger than `limit`. */ 42async function readBytes ( 43env :Environment , 44path :string , 45limit :number , 46context :Context , 47) :Promise < Uint8Array > { 48const reader = getOrThrow ( await env . openBinaryReader ( path , undefined , context )); 49try { 50const { size} = getOrThrow ( await reader . info ( context )); 51if ( size > limit ) throw new TooLarge ( ` ${ path } is larger than ${ limit } bytes` ); 52return getOrThrow ( await reader . read ( 0 , size , context )); 53} finally { 54await reader . close ( context ); 55} 56} 57 58async function readText ( env :Environment , path :string , context :Context ) :Promise < TextFile > { 59try { 60return decodeText ( await readBytes ( env , path , MAX_TEXT_BYTES , context )); 61} catch ( error ) { 62if ( error instanceof TooLarge ) throw new Error ( "File exceeds the 50 MiB edit/read limit." ); 63throw error ; 64} 65} 66 67const base64 = ( bytes :Uint8Array ) => 68Buffer . from ( bytes . buffer , bytes . byteOffset , bytes . byteLength ). toString ( "base64" ); 69 70const mutations = new Map < string , Promise < unknown >>(); 71 72/** Serialize changes to one file, whatever path (or symlink) names it on whichever machine. */ 73async function mutating < T > ( env :Environment , path :string , change :() => Promise < T >) :Promise < T > { 74const canonical = await env . canonicalPath ( path , BACKGROUND_CONTEXT ); 75const key = ` ${ env . id } \0 ${ canonical . ok ? canonical . value : path } ` ; 76const result = ( mutations . get ( key ) ?? Promise . resolve ()). then ( change ); 77const settled = result . catch (() => {}); 78mutations . set ( key , settled ); 79void settled . then (() => mutations . get ( key ) === settled && mutations . delete ( key )); 80return await result ; 81} 82 83/** The last `MAX_LINES` lines and `MAX_BYTES` of output, keeping at least the final line. */ 84function tail ( output :string ) :string { 85const ending = output . endsWith ( "\n" ) ?"\n" :"" ; 86const lines = output . slice ( 0 , output . length - ending . length ). split ( "\n" ). slice ( - MAX_LINES ); 87const kept = Math . max ( 1 , fitting ( lines . toReversed ())); 88return lines . slice ( - kept ). join ( "\n" ) + ending ; 89} 90 91const LIST_LIMIT = 1000 ; 92const OPAQUE_DIRS = new Set ([ ".git" , ".jj" , "node_modules" ]); 93 94function unavailable ( error :FileError ) :boolean { 95return [ "not_found" , "not_directory" , "permission_denied" , "invalid" ]. includes ( error . code ); 96} 97 98async function list ( env :Environment , path :string , depth :number , context :Context ) { 99const found :string [] = []; 100type Rule = { prefix :string ; matcher :Ignore }; 101async function visit ( 102dir :string , 103prefix :string , 104level :number , 105inherited :Rule [], 106) :Promise < void > { 107const entries = getOrThrow ( await env . listDir ( dir , context )). sort (( a , b ) => 108a . name . localeCompare ( b . name ) 109); 110let rules = inherited ; 111const gitignore = entries . find (( entry ) => entry . name === ".gitignore" ); 112if ( gitignore ) { 113const source = await env . readTextFile ( gitignore . path , context ); 114if ( source . ok ) rules = [ ...rules , { prefix, matcher :ignore (). add ( source . value ) }]; 115else if ( ! unavailable ( source . error )) throw source . error ; 116} 117for ( const entry of entries ) { 118if ( found . length >= LIST_LIMIT ) return ; 119const directory = entry . kind === "directory" ; 120const shown = prefix + entry . name ; 121const candidate = directory ?` ${ shown } /` :shown ; 122let ignored = false ; 123for ( const rule of rules ) { 124const result = rule . matcher . test ( candidate . slice ( rule . prefix . length )); 125if ( result . ignored ) ignored = true ; 126else if ( result . unignored ) ignored = false ; 127} 128if ( ignored ) continue ; 129found . push ( directory ?` ${ shown } /` :entry . kind === "symlink" ?` ${ shown } @` :shown ); 130if ( directory && level < depth && ! OPAQUE_DIRS . has ( entry . name )) { 131await visit ( entry . path , ` ${ shown } /` , level + 1 , rules ); 132} 133} 134} 135await visit ( getOrThrow ( await env . absolutePath ( path , context )), "" , 1 , []); 136if ( found . length >= LIST_LIMIT ) { 137found . push ( `[Stopped at ${ LIST_LIMIT } entries; list a subdirectory or lower depth.]` ); 138} 139return found . join ( "\n" ); 140} 141 142/** `AGENTS.md` of the working directory and its ancestors, outermost first. */ 143async function projectContext ( env :Environment , context :Context ) { 144const files :{ path :string ; content :string }[] = []; 145for ( let dir = env . cwd ;;) { 146for ( const parts of [[ dir , ".berry" , "AGENTS.md" ], [ dir , "AGENTS.md" ]]) { 147const path = getOrThrow ( await env . joinPath ( parts , context )); 148const content = await env . readTextFile ( path , context ); 149if ( content . ok ) { 150files . unshift ({ path, content :content . value . trim () }); 151break ; 152} 153} 154const parent = getOrThrow ( await env . joinPath ([ dir , ".." ], context )); 155if ( parent === dir ) return files ; 156dir = parent ; 157} 158} 159 160const hashes = Type . Array ( Type . String ({ pattern :"^[A-Za-z]{4}$" })); 161 162const modes = Type . Union ([ 163Type . Literal ( "naive" ), 164Type . Literal ( "hashline" ), 165Type . Literal ( "both" ), 166], { 167title :"Editing mode" , 168description : 169"Naive uses exact text replacements; hashline uses line anchors; both offers either." , 170default :"both" , 171}); 172const hashEdit = Type . Object ({ 173before :Type . Optional ( hashes ), 174old :hashes , 175after :Type . Optional ( hashes ), 176new :Type . Array ( Type . String ({ pattern :"^[^\\r\\n\\u0000]*$" })), 177}, { additionalProperties :false }); 178const naiveEdit = Type . Object ({ 179oldText :Type . String ({ pattern :"^[^\\u0000]*$" }), 180newText :Type . String ({ pattern :"^[^\\u0000]*$" }), 181}, { additionalProperties :false }); 182const editForms = { naive :naiveEdit , hashline :hashEdit , both :Type . Union ([ hashEdit , naiveEdit ]) }; 183 184export default defineExtension < State , Event >({ 185name :"code" , 186sortOrder :10 , 187defaultEnabled :true , 188formerly :[ "coding" ], 189description :"edit files and run shell commands" , 190initial :() => ({ editMode :"both" }), 191apply :( state , event ) => event . type === "editMode" ?{ editMode :event . editMode } :state , 192 193setup ( ext ) { 194ext . promptProse ({ 195default :"guidelines:\n" + [ 196"when changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls" , 197"if you need to start a background process, use tmux" , 198"when a command is missing, you can get it via `nix shell` or `nix run`" , 199"when producing source code, avoid comments that explain the plain mechanics of the code, " + 200"but preserve terse comments that explain subtle motivation. " + 201"(e.g. '// work around a layout bug in firefox' and not '// set the style X to Y')" , 202"we will prioritise elegance, simplicity, and long-term maintainability of code. " + 203"we have license to _work slower_ in order to do it right the first time" , 204"additionally wrt simplicity: don't outline simple functions if they're only going to be used in one place" , 205"avoid tautological tests: tests should not need to change when details of their implementation changes" , 206"$USER is likely using jj instead of git. pass --git flags to jj diff to work around the lack of terminal color" , 207]. map (( g ) => `- ${ g } ` ). join ( "\n" ), 208}); 209ext . configuration ({ 210parameters :Type . Object ({ editMode :modes }), 211values :( state ) => state , 212events :({ editMode}, state ) => 213editMode === state . editMode ?[] :[{ type :"editMode" , editMode}], 214}); 215ext . prompt ( "project" , async ({ env}) => { 216if ( env === undefined ) return undefined ; 217const context = await projectContext ( env , BACKGROUND_CONTEXT ); 218if ( context . length === 0 ) return undefined ; 219return "# project context\n\nproject-specific instructions and guidelines:" + 220context . map (({ path, content}) => `\n\n## ${ path } \n\n ${ content } ` ). join ( "" ); 221}); 222 223ext . tool ({ 224name :"write" , 225expose :"both" , 226description :"Create or overwrite a file, creating parent directories." , 227parameters :Type . Object ({ path :Type . String (), content :Type . String () }), 228returns :Type . String (), 229async execute ({ path, content}, ctx ) { 230const { env} = ctx ; 231const context = withAbortSignal ( ctx . signal , BACKGROUND_CONTEXT ); 232const absolute = getOrThrow ( await env . absolutePath ( path , context )); 233return await mutating ( env , absolute , async () => { 234getOrThrow ( await env . writeFile ( absolute , content , context )); 235return `Wrote ${ content . split ( "\n" ). length } lines to ${ absolute } .` ; 236}); 237}, 238}); 239 240ext . tool ({ 241name :"list" , 242expose :"both" , 243description : 244"List a directory (default: the working directory) as relative paths, one per line, with / after " + 245"directories and @ after symlinks. Skips paths ignored by .gitignore. depth (default 1) descends " + 246"except into .git, .jj, and " + 247`node_modules. Stops at ${ LIST_LIMIT } entries.` , 248parameters :Type . Object ({ 249path :Type . Optional ( Type . String ()), 250depth :Type . Optional ( Type . Integer ({ minimum :1 , maximum :10 })), 251}), 252returns :Type . String (), 253execute :({ path, depth}, ctx ) => 254list ( ctx . env , path ?? "." , depth ?? 1 , withAbortSignal ( ctx . signal , BACKGROUND_CONTEXT )), 255}); 256 257const shellParameters = Type . Object ({ 258command :Type . String (), 259timeout :Type . Number ({ exclusiveMinimum :0 , description :"Seconds." }), 260}); 261const shell = ( name :"bash" | "powershell" ) :Tool < State , Event , typeof shellParameters , { 262output :string ; 263exitCode :number ; 264fullOutputPath ?:string ; 265}> => ({ 266 name, 267expose :"both" , 268description : 269`Run a ${ name === "bash" ? "bash" : "PowerShell" } command in the working directory. ` + 270( name === "powershell" 271 ?"Uses PS7 when available, otherwise PS5; no profile or interactive input. " 272 :"" ) + 273`Returns the exit code and the last ${ MAX_LINES } lines / ${ 274MAX_BYTES / 1024 275} KiB of combined ` + 276"stdout and stderr, with fullOutputPath holding all of a longer output. A nonzero exit code " + 277"does not reject; a timeout does." , 278parameters :shellParameters , 279returns :Type . Object ({ 280output :Type . String (), 281exitCode :Type . Integer (), 282fullOutputPath :Type . Optional ( Type . String ()), 283}), 284async execute ({ command, timeout}, ctx ) { 285let output = "" ; 286const invocation = name === "powershell" 287 ?await powershellCommand ( ctx . env , command ) 288 :command ; 289const result = await ctx . env . exec ( invocation , { 290 timeout, 291onOutput :( text ) => { 292ctx . output ( text ); 293output += text ; 294if ( output . length > 4 * MAX_BYTES ) output = output . slice ( - 2 * MAX_BYTES ); 295// direct-call output loses escape characters in the harness's display sanitization 296ctx . progress ({ rawOutput :tail ( output ) }); 297}, 298spill :{ afterBytes :MAX_BYTES , afterLines :MAX_LINES }, 299}, withAbortSignal ( ctx . signal , BACKGROUND_CONTEXT )); 300if ( ! result . ok ) { 301if ( result . error . code === "timeout" ) { 302throw new Error ( `Command timed out after ${ timeout } seconds.` ); 303} 304if ( result . error . code === "aborted" ) throw new Error ( "Command aborted." ); 305throw result . error ; 306} 307const { exitCode, spillPath} = result . value ; 308return { 309output :tail ( output ), 310 exitCode, 311 ...( spillPath === undefined ?{} :{ fullOutputPath :spillPath }), 312}; 313}, 314text :({ output, exitCode, fullOutputPath}) => 315[ 316output . trimEnd () || "(no output)" , 317`[exit code ${ exitCode } ${ fullOutputPath ? `; full output: ${ fullOutputPath } ` : "" } ]` , 318]. join ( "\n\n" ), 319}); 320ext . tool ( shell ( "bash" )); 321 322return ( conv ) => { 323const { editMode} = conv . state ; 324const anchored = editMode !== "naive" ; 325 326conv . tool ({ 327name :"read" , 328expose :"both" , 329description :( anchored 330 ?"Read UTF-8 text as hash│content, each hash four letters of the exact line content, " + 331"independent of position; equal lines share a hash. " 332 :"Read UTF-8 text. " ) + 333`At most ${ MAX_LINES } lines / ${ 334MAX_BYTES / 1024 335} KiB; page with offset (1-indexed line) and ` + 336"limit (line count). Images (png, jpeg, gif, webp, bmp) resolve to an image for image()." , 337parameters :Type . Object ({ 338path :Type . String (), 339offset :Type . Optional ( Type . Integer ({ minimum :1 })), 340limit :Type . Optional ( Type . Integer ({ minimum :1 })), 341}), 342returns :Type . Union ([ 343Type . String (), 344Type . Object ({ 345type :Type . Literal ( "image" ), 346data :Type . String (), 347mimeType :Type . String (), 348}), 349]), 350async execute ({ path, offset, limit}, ctx ) { 351ctx . progress ({ editMode}); 352const context = withAbortSignal ( ctx . signal , BACKGROUND_CONTEXT ); 353const absolute = getOrThrow ( await ctx . env . absolutePath ( path , context )); 354const mimeType = IMAGE_TYPES [ extname ( absolute ). toLowerCase ()]; 355if ( mimeType !== undefined ) { 356const bytes = await readBytes ( ctx . env , absolute , MAX_IMAGE_BYTES , context ). catch ( 357( error ) => { 358if ( error instanceof TooLarge ) { 359throw new Error ( "Image exceeds 5 MiB; scale it down with bash first." ); 360} 361throw error ; 362}, 363); 364return { type :"image" , data :base64 ( bytes ), mimeType}; 365} 366return renderRead ( 367await readText ( ctx . env , absolute , context ), 368offset , 369limit , 370anchored , 371); 372}, 373}); 374 375conv . tool ({ 376name :"edit" , 377expose :"both" , 378description :"Edit a file. Each of edits[] is " + 379( editMode === "both" ?"either " :"" ) + 380( editMode !== "hashline" 381 ?"{ oldText, newText }, replacing literal text that must occur exactly once" 382 :"" ) + 383( editMode === "both" ?", or " :"" ) + 384( anchored 385 ?"{ before?, old, after?, new } with line hashes from read, where before + old + after " + 386"must match exactly once and only old is replaced (old: [] inserts, new: [] deletes; " + 387"list every removed line's hash; new lines can't contain CR/LF/NUL)" 388 :"" ) + 389". Add adjacent context to disambiguate; an empty match is allowed only for an empty file. " + 390"All edits match the original file and are validated before writing; ambiguous, stale, or " + 391"overlapping ones fail. Batch a file's edits in one call. Returns a diff" + 392( anchored ?" with hashes on changed rows." :"." ), 393parameters :Type . Object ({ 394path :Type . String (), 395edits :Type . Array ( editForms [ editMode ], { minItems :1 }), 396}, { additionalProperties :false }), 397returns :Type . String (), 398async execute ({ path, edits}, ctx ) { 399ctx . progress ({ editMode}); 400const { env} = ctx ; 401const context = withAbortSignal ( ctx . signal , BACKGROUND_CONTEXT ); 402const absolute = getOrThrow ( await env . absolutePath ( path , context )); 403return await mutating ( env , absolute , async () => { 404const file = await readText ( env , absolute , context ); 405const { content, diff} = planEdit ( file , edits , anchored ); 406if ( content === file . raw ) return "No changes made." ; 407ctx . signal . throwIfAborted (); 408const current = await readBytes ( env , absolute , MAX_TEXT_BYTES , context ); 409if ( ! Buffer . from ( current ). equals ( Buffer . from ( file . raw ))) { 410throw new Error ( "File changed while preparing the edit; read it again." ); 411} 412ctx . signal . throwIfAborted (); 413getOrThrow ( await env . writeFile ( absolute , content , context )); 414const shown = diff . slice ( 0 , Math . min ( MAX_LINES , fitting ( diff ))); 415const truncated = shown . length < diff . length 416 ?"\n[Diff truncated; read for more context.]" 417 :"" ; 418return `Applied ${ edits . length } edit(s).\n\n ${ shown . join ( "\n" )} ${ truncated } ` ; 419}); 420}, 421}); 422 423if ( conv . env ?. platform === "win32" ) { 424conv . tool ( shell ( "powershell" )); 425} 426 427conv . command ({ 428name :"workspace" , 429enabled :({ env}) => env ?hasWorkspace ( env ) :false , 430description :"create and switch to a jj workspace (under $BERRY_WORKSPACES_DIR)" , 431async execute ( args , ctx ) { 432if ( args . trim ()) throw new Error ( "Usage: /workspace" ); 433return await ctx . cd ( await workspace ( ctx . env , ctx . tid , BACKGROUND_CONTEXT ), { 434inheritProject :true , 435}); 436}, 437}); 438 439conv . prompt ( async ({ env}) => { 440const commands = env 441 ?( await Promise . all ( 442[ "tmux" , "nix" , "jj" ]. map ( async ( name ) => 443await hasCommand ( env , name ) ?name :undefined 444), 445)). filter (( name ) => name !== undefined ) 446 :[]; 447return [ 448 ...( env ?[ `current working directory: ${ env . cwd } ` ] :[]), 449 ...( env ?. platform === "win32" 450 ?[ "use powershell for commands in this Windows environment" ] 451 :[]), 452"@-references to files (e.g. @path/to/file.ts) are relative to the working directory unless " + 453"absolute: read them directly, without the @, rather than searching for them." , 454 ...( env ?[ `available workflow commands: ${ commands . join ( ", " ) || "none" } ` ] :[]), 455]. join ( "\n\n" ); 456}); 457}; 458}, 459});