char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ba8ed3c
main
1// Extensions come from configured directories: loaded at startup, and again whenever their files change. 2 3import { existsSync , mkdirSync } from "node:fs" ; 4import { copy } from "@std/fs/copy" ; 5import { fromFileUrl , join , relative , resolve , toFileUrl } from "@std/path" ; 6import type { TObject } from "typebox" ; 7import type { ExtensionStartup , FrontendSource } from "@berry/api/extension-startup.ts" ; 8import type { Berry , ExtensionStatus } from "./berry.ts" ; 9import { denoExecutable } from "./deno.ts" ; 10import { published } from "./pubsub.ts" ; 11import type { ExtensionWeb , WebEntry } from "./web.ts" ; 12 13/** An extension as the ui sees it: how its last load went, whether a version of it runs, and its frontend. */ 14export type ExtensionEntry = ExtensionStatus & { 15readonly running :boolean ; 16/** The source directory, or null for a builtin or an unresolved name collision. */ 17readonly directory :string | null ; 18/** The harness's own: always there, nothing on disk to reload. */ 19readonly builtin :boolean ; 20readonly alwaysOn :boolean ; 21/** Whether a new conversation starts with it, until one is chosen from before. */ 22readonly defaultEnabled :boolean ; 23/** Where its configuration sorts among the others': lower first. */ 24readonly sortOrder :number | null ; 25readonly web :WebEntry | null ; 26/** Names it went by before, which old history still calls it. */ 27readonly formerly :readonly string []; 28/** What a new conversation with it is asked, and what an existing one may change, as a json schema. */ 29readonly configuration :TObject | null ; 30/** Its global editable prose default, when it registers any. */ 31readonly promptProse :{ default :string } | null ; 32readonly startup :ExtensionStartup | null ; 33}; 34 35const SETTLE_MS = 300 ; 36 37/** 38* What `deno check` says of `files`, or `null` when they pass; a signal kills the check and rejects with its reason. 39*/ 40export async function denoCheck ( 41root :string , 42files :readonly string [], 43signal ?:AbortSignal , 44) :Promise < string | null > { 45const { success, stderr} = await new Deno . Command ( denoExecutable (), { 46args :[ 47"check" , 48"--quiet" , 49"--config" , 50fromFileUrl ( new URL ( "../deno.json" , import . meta. url )), 51 ...files , 52], 53cwd :root , 54env :{ NO_COLOR :"1" }, 55stdout :"null" , 56stderr :"piped" , 57 signal, 58}). output (); 59// a killed check exits by signal and writes nothing, which is not a verdict on the code 60signal ?. throwIfAborted (); 61return success ?null :new TextDecoder (). decode ( stderr ). trim (); 62} 63 64/** The extensions as the ui lists them: by `sortOrder`, lower first, and those without one after them by name. */ 65function ranked ( entries :readonly ExtensionEntry []) :readonly ExtensionEntry [] { 66const at = ( entry :ExtensionEntry ) => entry . sortOrder ?? Number . MAX_SAFE_INTEGER ; 67return [ ...entries ]. sort (( a , b ) => at ( a ) - at ( b ) || a . name . localeCompare ( b . name )); 68} 69 70export function extensionLoader ( 71{ berry, web, root, directories= [ join ( root , "extensions" )] } :{ 72berry :Berry ; 73web :ExtensionWeb ; 74root :string ; 75directories ?:readonly string []; 76}, 77) { 78const roots = [ ...new Set ( directories . map (( directory ) => resolve ( directory )))]; 79const entries = published < readonly ExtensionEntry []>([]); 80const builtins :readonly ExtensionEntry [] = berry . builtins (). map (( extension ) => ({ 81name :extension . name , 82description :extension . description ?? null , 83status :"loaded" , 84error :null , 85running :true , 86directory :null , 87builtin :true , 88alwaysOn :extension . alwaysOn ?? false , 89defaultEnabled :extension . defaultEnabled ?? false , 90sortOrder :extension . sortOrder ?? null , 91web :null , 92formerly :extension . formerly ?? [], 93configuration :berry . configurationParameters ( extension . name ), 94promptProse :berry . promptProseDefault ( extension . name ) === null 95 ?null 96 :{ default :berry . promptProseDefault ( extension . name ) ! }, 97startup :null , 98})); 99/** What the ui lists: the extensions by their configuration order. */ 100const listed = published < readonly ExtensionEntry []>( ranked ( builtins )); 101const copies = new Map < string , string >(); 102const queues = new Map < string , Promise < unknown >>(); 103// a restart loads every extension again, so a reload still being checked is only holding the process up 104const checks = new AbortController (); 105let watcher :Deno . FsWatcher | undefined ; 106let watching :Promise < void > | undefined ; 107 108function sources () { 109const found = new Map < string , string []>(); 110for ( const directory of roots ) { 111if ( ! existsSync ( directory )) continue ; 112for ( const entry of Deno . readDirSync ( directory )) { 113if ( ! entry . isDirectory || entry . name . startsWith ( "." )) continue ; 114const paths = found . get ( entry . name ) ?? []; 115paths . push ( join ( directory , entry . name )); 116found . set ( entry . name , paths ); 117} 118} 119return found ; 120} 121 122function record ( entry :ExtensionEntry ) { 123const rest = entries . value . filter (( e ) => e . name !== entry . name ); 124entries . set ([ ...rest , entry ]. sort (( a , b ) => a . name . localeCompare ( b . name ))); 125listed . set ( ranked ([ ...builtins , ...entries . value ])); 126return entry ; 127} 128 129/** 130* Load an extension's files. A reload checks them first, and imports a fresh copy of the directory, so that its 131* relative imports load anew too; deno would otherwise reuse what it has for their unchanged urls. 132*/ 133async function load ( name :string , reload :boolean ) :Promise < ExtensionStatus > { 134const builtin = builtins . find (( extension ) => extension . name === name ); 135if ( builtin ) { 136return { 137 name, 138description :builtin . description , 139status :"failed" , 140error :` ${ name } is the harness's own; restart berry to reload it` , 141}; 142} 143const started = performance . now (); 144const phases :ExtensionStartup [ "phases" ][ number ][] = []; 145let frontendSource :FrontendSource | undefined ; 146const candidates = sources (). get ( name ) ?? []; 147const previous = entries . value . find (( e ) => e . name === name ); 148 149async function trace < T > ( 150phase :ExtensionStartup [ "phases" ][ number ][ "name" ], 151run :() => Promise < T >, 152) { 153if ( reload ) return await run (); 154const started = performance . now (); 155try { 156return await run (); 157} finally { 158const durationMs = performance . now () - started ; 159phases . push ({ 160name :phase , 161 durationMs, 162 ...( phase === "frontend" && frontendSource ?{ source :frontendSource } :{}), 163}); 164} 165} 166 167function finish ( entry :Omit < ExtensionEntry , "startup" >) { 168return record ({ 169 ...entry , 170startup :reload ?previous ?. startup ?? null :{ 171status :entry . status , 172totalMs :performance . now () - started , 173 phases, 174}, 175}); 176} 177const source = candidates . length === 1 ?candidates [ 0 ] :undefined ; 178let copied :string | undefined ; 179// its directory gone: so is the extension 180if ( candidates . length === 0 ) { 181berry . uninstall ( name ); 182entries . set ( entries . value . filter (( e ) => e . name !== name )); 183listed . set ( ranked ([ ...builtins , ...entries . value ])); 184return { name, description :null , status :"failed" , error :"removed" }; 185} 186try { 187if ( source === undefined ) { 188throw new Error ( `duplicate extension ${ name } :\n ${ candidates . join ( "\n" )} ` ); 189} 190const frontend = join ( source , "web.tsx" ); 191if ( ! existsSync ( join ( source , "mod.ts" ))) throw new Error ( ` ${ source } has no mod.ts` ); 192const files = [ join ( source , "mod.ts" ), ...( existsSync ( frontend ) ?[ frontend ] :[])]; 193if ( reload ) { 194const failures = await denoCheck ( 195root , 196files . map (( file ) => relative ( root , file )), 197checks . signal , 198); 199if ( failures !== null ) throw new Error ( failures ); 200copied = await Deno . makeTempDir ({ prefix :`berry- ${ name } -` }); 201await copy ( source , copied , { overwrite :true }); 202} 203const module = await trace ( 204"import" , 205() => import ( toFileUrl ( join ( copied ?? source , "mod.ts" )). href ), 206); 207const extension = module . default ; 208// its frontend is served, and its state kept, under one name 209if ( extension ?. name !== name ) { 210throw new Error ( ` ${ source } names itself ${ extension ?. name } ` ); 211} 212// the frontend first: a build that fails leaves both halves as they were 213const built = existsSync ( frontend ) 214 ?await trace ( "frontend" , () => 215web . build ( name , frontend , ( source ) => { 216frontendSource = source ; 217})) 218 :null ; 219await trace ( "install" , () => berry . install ( extension )); 220if ( copied !== undefined ) { 221const old = copies . get ( name ); 222copies . set ( name , copied ); 223if ( old ) await Deno . remove ( old , { recursive :true }). catch (() => {}); 224} 225return finish ({ 226 name, 227description :extension . description ?? null , 228status :"loaded" , 229error :null , 230running :true , 231directory :source , 232builtin :false , 233alwaysOn :extension . alwaysOn ?? false , 234defaultEnabled :extension . defaultEnabled ?? false , 235sortOrder :extension . sortOrder ?? null , 236web :built , 237formerly :extension . formerly ?? [], 238configuration :berry . configurationParameters ( name ), 239promptProse :berry . promptProseDefault ( name ) === null 240 ?null 241 :{ default :berry . promptProseDefault ( name ) ! }, 242}); 243} catch ( error ) { 244if ( copied !== undefined ) await Deno . remove ( copied , { recursive :true }). catch (() => {}); 245// a cancelled reload says nothing about the extension, which the restart loads anew 246if ( checks . signal . aborted ) { 247return previous ?? { 248 name, 249description :null , 250status :"failed" , 251error :` ${ name } did not finish loading` , 252}; 253} 254const message = error instanceof Error ?error . message :String ( error ); 255console . error ( `[berry] ${ name } did not ${ reload ? "reload" : "load" } :\n ${ message } ` ); 256return finish ({ 257 name, 258description :previous ?. description ?? null , 259status :"failed" , 260error :message , 261running :previous ?. running ?? false , 262directory :previous ?. running ?previous . directory :source ?? null , 263builtin :false , 264alwaysOn :previous ?. alwaysOn ?? false , 265defaultEnabled :previous ?. defaultEnabled ?? false , 266sortOrder :previous ?. sortOrder ?? null , 267web :previous ?. web ?? null , 268formerly :previous ?. formerly ?? [], 269configuration :previous ?. configuration ?? null , 270promptProse :previous ?. promptProse ?? null , 271}); 272} 273} 274 275/** One load of an extension at a time; a change while one runs loads again after it. */ 276function queue ( name :string , reload :boolean ) :Promise < ExtensionStatus > { 277const next = ( queues . get ( name ) ?? Promise . resolve ()). then (() => load ( name , reload )); 278queues . set ( name , next . catch (() => {})); 279return next ; 280} 281 282return { 283directories :roots as readonly string [], 284 285files :() => 286[ ...sources (). values ()]. flat (). flatMap (( directory ) => [ 287join ( directory , "mod.ts" ), 288 ...( existsSync ( join ( directory , "web.tsx" )) ?[ join ( directory , "web.tsx" )] :[]), 289]), 290 291async loadAll () { 292for ( const name of sources (). keys ()) await queue ( name , false ); 293}, 294 295reload :( name :string ) => queue ( name , true ), 296 297entries :() :readonly ExtensionEntry [] => entries . value , 298 299feed :() => listed . feed (), 300 301/** Reload each extension whose files change, once they settle. */ 302watch () { 303if ( watcher || roots . length === 0 ) return ; 304for ( const directory of roots ) mkdirSync ( directory , { recursive :true , mode :0o700 }); 305watcher = Deno . watchFs ( roots , { recursive :true }); 306const timers = new Map < string , ReturnType < typeof setTimeout >>(); 307watching = ( async () => { 308for await ( const event of watcher ! ) { 309const affected = new Set < string >(); 310for ( const path of event . paths ) { 311for ( const directory of roots ) { 312const name = relative ( directory , path ). split ( / [/\\] / )[ 0 ]; 313// dot directories, and anything outside, which relative() starts with ".." 314if ( name . startsWith ( "." )) continue ; 315if ( name ) affected . add ( name ); 316else { 317for ( const name of sources (). keys ()) affected . add ( name ); 318for ( const entry of entries . value ) affected . add ( entry . name ); 319} 320} 321} 322for ( const name of affected ) { 323clearTimeout ( timers . get ( name )); 324timers . set ( name , setTimeout (() => void queue ( name , true ), SETTLE_MS )); 325} 326} 327for ( const timer of timers . values ()) clearTimeout ( timer ); 328})(); 329}, 330 331async close () { 332checks . abort ( new Error ( "berry is stopping" )); 333watcher ?. close (); 334await watching ; 335await Promise . all ( queues . values ()); 336for ( const copied of copies . values ()) { 337await Deno . remove ( copied , { recursive :true }). catch (() => {}); 338} 339}, 340}; 341} 342 343export type Loader = ReturnType < typeof extensionLoader >;