char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
6ae9dd3
main
1import { Signal } from "@char/aftercare" ; 2import type { JsonValue } from "@berry/api" ; 3import type { ConfigurationContext , ConfigurationSchema , FieldSchema } from "@berry/api/web.ts" ; 4import { extensionRenderers } from "./renderers.ts" ; 5import { folderInput , type FolderOptions } from "./folders.tsx" ; 6import type { ProjectIndex } from "@berry/api/projects.ts" ; 7import { api , watch } from "./client.ts" ; 8import type { Shared } from "./conversation.tsx" ; 9import { modelKey } from "./settings.tsx" ; 10import { computed , type Scope } from "./ui.ts" ; 11 12export type { ConfigurationSchema , FieldSchema } from "@berry/api/web.ts" ; 13type Values = Record < string , JsonValue >; 14export type ConfigurationLocation = Pick < 15ConfigurationContext , 16"tid" | "model" | "request" | "command" 17>; 18 19/** Bind custom or generated controls to configuration values and the selected model. */ 20export function configurationControls ( 21scope :Scope , 22name :string , 23schema :ConfigurationSchema , 24location :Signal < ConfigurationLocation >, 25values :Signal < Values | null >, 26apply :( patch :Values ) => Values , 27generated :( save :( patch :Values ) => Promise < Values >) => HTMLElement , 28status ?:Signal <{ pending :number ; error :string | null }>, 29) :HTMLElement { 30const view = extensionRenderers ( name )?. configuration ?.(); 31let form :HTMLElement | undefined ; 32let disposed = false ; 33let error :string | null = null ; 34const renderDefault = () => form ??= generated ( save ); 35const draw = () => { 36if ( disposed ) return ; 37view ?. update ({ 38 ...location . get (), 39extension :name , 40 schema, 41values :values . get (), 42saveStatus :status ?. get (). pending 43 ?"saving" 44 :error || status ?. get (). error 45 ?"error" 46 :"idle" , 47error :error ?? status ?. get (). error ?? null , 48save :async ( patch ) => { 49await save ( patch ); 50}, 51 renderDefault, 52}); 53}; 54const save = ( patch :Values ) :Promise < Values > => { 55if ( disposed || values . get () === null ) { 56return Promise . reject ( new Error ( "Configuration is not available." )); 57} 58error = null ; 59try { 60// draft answers must be updated before a synchronous form submission can read them 61const next = apply ( patch ); 62values . set ( next ); 63draw (); 64return Promise . resolve ( next ); 65} catch ( cause ) { 66error = cause instanceof Error ?cause . message :String ( cause ); 67draw (); 68return Promise . reject ( cause ); 69} 70}; 71const element = view ?< div >{ view . element }</ div > as HTMLElement :renderDefault (); 72scope . add (() => { 73disposed = true ; 74view ?. dispose ?.(); 75}); 76scope . add ( location . subscribe ( draw ). unsubscribe ); 77scope . add ( values . subscribe ( draw ). unsubscribe ); 78if ( status ) scope . add ( status . subscribe ( draw ). unsubscribe ); 79draw (); 80return element ; 81} 82 83/** Choices a field's schema cannot list, as they are what the server knows now. */ 84export type LiveChoices = Readonly < Record < string , Signal < readonly string []>>>; 85 86/** The live choices of an extension's fields: the machines an environment can be, the models a subagent can run on. */ 87export function liveChoices ( 88scope :Scope , 89extension :string , 90shared :Pick < Shared , "machines" | "enabled" >, 91) :LiveChoices { 92if ( extension === "environment" ) return { environment :shared . machines }; 93if ( extension === "subagent" ) { 94return { models :computed ( scope , [ shared . enabled ], () => shared . enabled . get (). map ( modelKey )) }; 95} 96return {}; 97} 98 99/** One question of a configuration: a typebox property, of the few kinds a form draws. */ 100export function configurationField ( 101key :string , 102schema :FieldSchema , 103value :JsonValue | undefined , 104answer :( value :JsonValue ) => void , 105live ?:{ scope :Scope ; choices :Signal < readonly string []> }, 106folders ?:FolderOptions , 107) { 108if ( schema . type === "array" ) return listField ( key , schema , value , answer , live ); 109const choices = schema . enum ?? schema . anyOf ?. map (( option ) => option . const ?? null ); 110const boolean = schema . type === "boolean" ; 111const numeric = schema . type === "number" || schema . type === "integer" ; 112const folder = folders ?folderInput ( folders ) :undefined ; 113const input = ( live || choices ?. every (( choice ) => typeof choice === "string" ) 114 ?( 115< select > 116{(( choices ?? []) as string []). map (( choice ) => < option value = { choice }>{ choice }</ option >)} 117</ select > 118) 119 :folder ?. input ?? < input type = { boolean ?"checkbox" :numeric ?"number" :"text" } />) as 120| HTMLInputElement 121| HTMLSelectElement ; 122// the choices as they are, and the value even once it is not among them, so it still shows 123let selected = "" ; 124const draw = () => { 125if ( ! live ) return ; 126const names = live . choices . get (); 127const all = selected === "" || names . includes ( selected ) ?names :[ ...names , selected ]; 128input . replaceChildren ( ...all . map (( name ) => < option value = { name }>{ name }</ option >)); 129input . value = selected ; 130}; 131if ( live ) { 132live . scope . add ( live . choices . subscribe ( draw ). unsubscribe ); 133input . addEventListener ( "change" , () => void ( selected = input . value )); 134} 135if ( numeric ) ( input as HTMLInputElement ). step = schema . type === "integer" ?"1" :"any" ; 136const read = () :JsonValue => 137boolean 138 ?( input as HTMLInputElement ). checked 139 :numeric 140 ?( input as HTMLInputElement ). valueAsNumber 141 :input . value ; 142const update = ( value :JsonValue | undefined ) => { 143const next = value ?? schema . default ?? null ; 144if ( boolean ) ( input as HTMLInputElement ). checked = next === true ; 145else if ( live ) { 146selected = next === null ?"" :String ( next ); 147draw (); 148} else input . value = next === null ?"" :String ( next ); 149}; 150update ( value ); 151input . addEventListener ( boolean || choices || live ?"change" :"input" , () => answer ( read ())); 152const label = schema . title ?? key ; 153return { 154element :folder 155 ?( 156< div class = "field" title = { schema . description ?? "" }> 157< label htmlFor = { folder . input . id }>{ label }</ label > 158{ folder . element } 159</ div > 160) 161: ( 162< label class = { boolean ? "toggle" : "field" } title = { schema . description ?? "" }> 163{ boolean ? [] : < span >{ label }</ span >} 164{input} 165{ boolean ? label : []} 166</ label > 167), 168input , 169get value () { 170return read (); 171}, 172update, 173}; 174} 175 176/** A question with any number of answers among its choices, a box for each. */ 177function listField ( 178key : string , 179schema : FieldSchema , 180value : JsonValue | undefined , 181answer : ( value : JsonValue ) => void , 182live ?: { scope : Scope ; choices : Signal < readonly string []> }, 183) { 184const fixed = ( schema . items ?. enum ?? schema . items ?. anyOf ?. map (( option ) => option . const ) ?? []) 185. filter (( choice ) => typeof choice === "string" ); 186const input = < fieldset class = "choices" / > as HTMLFieldSetElement ; 187let selected : string [] = []; 188const read = () => 189[... input . querySelectorAll < HTMLInputElement >( "input:checked" )]. map (( box ) => box . value ); 190// the choices as they are, and the answers even once they are not among them, so they still show 191const draw = () => { 192const choices = live ?. choices . get () ?? fixed ; 193const all = [... choices , ... selected . filter (( choice ) => ! choices . includes ( choice ))]; 194input . replaceChildren (... all . map (( choice ) => ( 195< label class = "toggle" > 196< input type = "checkbox" value = { choice } checked = { selected . includes ( choice )} /> 197{choice} 198< / label> 199))); 200}; 201if ( live ) live . scope . add ( live . choices . subscribe ( draw ). unsubscribe ); 202const update = ( value : JsonValue | undefined ) => { 203const next = value ?? schema . default ; 204selected = Array . isArray ( next ) ? next . filter (( item ) => typeof item === "string" ) : []; 205draw (); 206}; 207update ( value ); 208input . addEventListener ( "change" , () => answer ( selected = read ())); 209return { 210element : ( 211< div class = "field" title = { schema . description ?? "" }> 212< span >{ schema . title ?? key }</ span > 213{ input } 214< / div > 215), 216input, 217get value(): JsonValue { 218return read (); 219} , 220update, 221}; 222} 223 224/** A live configuration draft whose edits become events when committed. */ 225export function configurationEditor ( 226scope : Scope , 227tid : string , 228name : string , 229schema : ConfigurationSchema , 230saving : ( save : Promise < unknown >) => void , 231live : LiveChoices = {}, 232projects ?: Signal < ProjectIndex >, 233location = new Signal < ConfigurationLocation >({ tid, model : null }), 234) { 235const values = new Signal < Values | null >( null ); 236const status = new Signal ({ pending : 0 , error : null as string | null }); 237let current : Values | null = null ; 238let tail : Promise < unknown > = Promise . resolve (); 239const edits = new Map < string , { value : JsonValue }>(); 240const submitted = new Map < string , { value : JsonValue }>(); 241const draw = () => 242values . set ( 243current && { 244... current , 245... Object . fromEntries ([... edits ]. map (([ key , edit ]) => [ key , edit . value ])), 246}, 247); 248scope . add ( watch < Values | null >( `configuration: ${ tid } : ${ name } ` , ( next ) => { 249current = next ; 250draw (); 251})); 252const element = configurationControls ( 253scope , 254name , 255schema , 256location , 257values , 258( patch ) => { 259for ( const [ key , value ] of Object . entries ( patch )) { 260if ( JSON . stringify ( value ) === JSON . stringify ( values . get ()?.[ key ])) continue ; 261if ( ! submitted . has ( key ) && JSON . stringify ( value ) === JSON . stringify ( current ?.[ key ])) { 262edits . delete ( key ); 263} else edits . set ( key , { value }); 264} 265return { ... values . get () ! , ... patch }; 266}, 267( save ) => 268generatedEditor ( scope , name , schema , values , save , live , status , () => current , projects ), 269status , 270); 271return { 272element, 273commit () { 274const batch = [... edits ]. filter (([ key , edit ]) => submitted . get ( key ) !== edit ); 275if ( ! batch . length ) return ; 276for ( const [ key , edit ] of batch ) submitted . set ( key , edit ); 277status . set ({ pending : status . get (). pending + 1 , error : null }); 278const operation = tail . catch (() => {}). then (() => 279api < Values >( 280"PATCH" , 281`/api/conversations/ ${ tid } /configuration/ ${ encodeURIComponent ( name )} ` , 282Object . fromEntries ( batch . map (([ key , edit ]) => [ key , edit . value ])), 283) 284). then (( next ) => { 285current = next ; 286for ( const [ key , edit ] of batch ) { 287if ( edits . get ( key ) === edit ) edits . delete ( key ); 288if ( submitted . get ( key ) === edit ) submitted . delete ( key ); 289} 290draw (); 291status . set ({ ... status . get (), error : null }); 292}). catch (( cause ) => { 293for ( const [ key , edit ] of batch ) { 294if ( submitted . get ( key ) === edit ) submitted . delete ( key ); 295} 296status . set ({ 297... status . get (), 298error : cause instanceof Error ? cause . message : String ( cause ), 299}); 300throw cause ; 301}). finally (() => status . set ({ ... status . get (), pending : status . get (). pending - 1 })); 302tail = operation ; 303saving ( operation ); 304return operation ; 305}, 306}; 307} 308 309function generatedEditor ( 310scope : Scope , 311name : string , 312schema : ConfigurationSchema , 313values : Signal < Values | null >, 314save : ( patch : Values ) => Promise < Values >, 315live : LiveChoices , 316remote : Signal <{ pending : number ; error : string | null }>, 317baseline : () => Values | null , 318projects ?: Signal < ProjectIndex >, 319) { 320let current : Values | null = null ; 321const environment = new Signal ( "local" ); 322const status = < p class = "configuration-status" role = "status" aria - live = "polite" / > as HTMLElement ; 323const fields = Object . entries ( schema . properties ). map (([ key , schema ]) => { 324const choices = live [ key ]; 325const field = configurationField ( 326key , 327schema , 328undefined , 329( value ) => { 330const field = fields . find (( field ) => field . key === key ) ! . field ; 331if ( 332! field . input . checkValidity () || 333( typeof value === "number" && ! Number . isFinite ( value )) 334) { 335status . classList . add ( "error" ); 336status . textContent = field . input . validationMessage || "enter a valid number" ; 337return ; 338} 339status . classList . remove ( "error" ); 340status . textContent = "" ; 341const patch : Values = { [ key ]: value }; 342if ( name === "environment" && key === "environment" ) { 343environment . set ( String ( value )); 344fields . find (({ key }) => key === "cwd" )?. field . update ( "" ); 345patch . cwd = "" ; 346} 347void save ( patch ). catch (() => {}); 348}, 349choices && { scope, choices }, 350name === "environment" && key === "cwd" 351? { 352scope, 353environment, 354projects, 355base : () => { 356const saved = baseline (); 357return environment . get () === saved ?. environment 358? String ( saved ?. cwd || "" ) || null 359: null ; 360}, 361} 362: undefined , 363); 364field . input . addEventListener ( "keydown" , ( event ) => { 365if (( event as KeyboardEvent ). key === "Enter" ) { 366event . preventDefault (); 367( document . activeElement as HTMLElement | null )?. blur (); 368} 369}); 370return { key, field }; 371}); 372const element = ( 373< fieldset class = "configuration" disabled > 374{ fields . map (({ field }) => field . element )} 375{status} 376< / fieldset> 377) as HTMLFieldSetElement ; 378const draw = () => { 379if ( ! current ) return ; 380for ( const { key , field } of fields ) { 381if ( ! field . input . contains ( document . activeElement )) { 382field . update ( current [ key ]); 383} 384} 385}; 386scope . add ( 387values . subscribeImmediate (( values ) => { 388current = values ; 389if ( name === "environment" && values ) { 390environment . set ( String ( values . environment )); 391} 392element . disabled = current === null ; 393draw (); 394}). unsubscribe , 395); 396scope . add ( 397remote . subscribeImmediate (({ pending , error }) => { 398status . classList . toggle ( "error" , error !== null ); 399status . textContent = error ?? 400( pending ? "applying configuration (pending if agent is busy)" : "" ); 401}). unsubscribe , 402); 403return element ; 404}