char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
32b51b2
main
1import { Signal } from "@char/aftercare" ; 2import type { JsonValue } from "@berry/api" ; 3import { userContent } from "@berry/api/images.ts" ; 4import { api } from "./client.ts" ; 5import { composer , go } from "./composer.tsx" ; 6import { 7configurationControls , 8configurationField , 9type ConfigurationLocation , 10liveChoices , 11} from "./configuration.tsx" ; 12import { 13type CommandInfo , 14type ExtensionInfo , 15notifier , 16type Shared , 17thinkingSelect , 18} from "./conversation.tsx" ; 19import { projectPicker } from "./projects.tsx" ; 20import { modelKey , modelSelect } from "./settings.tsx" ; 21import { computed , type Scope , swap } from "./ui.ts" ; 22 23type Values = Record < string , JsonValue >; 24 25const EMPTY :readonly never [] = []; 26const EXTENSIONS = "berry.new.extensions" ; 27const MODEL = "berry.new.model" ; 28const THINKING = "berry.new.thinking" ; 29const SEEDS = "berry.new.seeds" ; 30 31/** 32* The page a conversation starts from: where it works, how it thinks, and with which extensions, above a composer 33* whose first message starts it. It starts in `project`, or in none for `""`, or where the last one did for `null`. 34*/ 35export function newConversation ( scope :Scope , shared :Shared , project :string | null ) :Element [] { 36const { extensions, models, enabled} = shared ; 37const { element :notice , notify} = notifier (); 38const optional = extensions . derive (( list ) => 39list . filter (( extension ) => extension . running && ! extension . alwaysOn ) 40); 41const remembered = JSON . parse ( localStorage . getItem ( EXTENSIONS ) ?? "null" ) as string [] | null ; 42const chosen = new Signal < ReadonlySet < string > | null >( remembered && new Set ( remembered )); 43// what the extensions ask for, rather than every one installed: delegation, the browser, and mcp are opt-in 44const defaults = extensions . derive (( list ) => 45new Set ( list . filter (( extension ) => extension . defaultEnabled ). map (( extension ) => extension . name )) 46); 47const isChosen = ( name :string ) => chosen . get ()?. has ( name ) ?? defaults . get (). has ( name ); 48// each extension's configuration, as last given; what is not answered takes its default on the server 49const answers = JSON . parse ( localStorage . getItem ( SEEDS ) ?? "{}" ) as Record < string , Values >; 50const model = new Signal ( localStorage . getItem ( MODEL ) ?? "" ); 51const thinking = new Signal ( localStorage . getItem ( THINKING ) ?? "high" ); 52// the last model used, while it stays enabled; otherwise the first enabled one 53const settle = () => { 54const keys = enabled . get (). map ( modelKey ); 55if ( ! keys . includes ( model . get ())) model . set ( keys [ 0 ] ?? "" ); 56}; 57scope . add ( enabled . subscribe ( settle ). unsubscribe ); 58settle (); 59const location = computed < ConfigurationLocation >( scope , [ model ], () => { 60const key = model . get (); 61const slash = key . indexOf ( "/" ); 62return { 63tid :null , 64model :slash < 0 ?null :{ provider :key . slice ( 0 , slash ), modelId :key . slice ( slash + 1 ) }, 65}; 66}); 67 68// where it works: a project's workspace, or a machine and folder chosen by hand, which show their project 69const environment = new Signal ( String ( answers . environment ?. environment ?? "local" )); 70const environmentValues = new Signal < Values | null >( null ); 71const picker = projectPicker ( scope , shared . projects , ( root ) => { 72answers . environment = { environment :root ?. environment ?? "local" , cwd :root ?. cwd ?? "" }; 73environment . set ( String ( answers . environment . environment )); 74environmentValues . set ({ ...environmentValues . get (), ...answers . environment }); 75}); 76const showProject = () => 77picker . show ({ environment :environment . get (), cwd :String ( answers . environment ?. cwd ?? "" ) }); 78 79/** An extension's questions, with its answers so far and its defaults; `fields` draws them when it has no view. */ 80const controls = ( 81own :Scope , 82extension :ExtensionInfo , 83values :Signal < Values | null >, 84fields :( save :( patch :Values ) => Promise < Values >) => HTMLElement , 85answered :( patch :Values ) => void = () => {}, 86) => { 87const defaults = Object . fromEntries ( 88Object . entries ( extension . configuration ! . properties ) 89. filter (([, field ]) => field . default !== undefined ) 90. map (([ key , field ]) => [ key , field . default ! ]), 91); 92values . set ({ ...defaults , ...answers [ extension . name ] }); 93return configurationControls ( 94own , 95extension . name , 96extension . configuration ! , 97location , 98values , 99( patch ) => { 100answers [ extension . name ] = { ...answers [ extension . name ], ...patch }; 101answered ( patch ); 102return { ...defaults , ...answers [ extension . name ] }; 103}, 104fields , 105); 106}; 107/** The generated form of an extension's questions, each answered as it changes. */ 108const generated = ( 109own :Scope , 110extension :ExtensionInfo , 111values :Signal < Values | null >, 112save :( patch :Values ) => Promise < Values >, 113folders ?:( key :string ) => Parameters < typeof configurationField >[ 5 ], 114) => { 115const live = liveChoices ( own , extension . name , shared ); 116const fields = Object . entries ( extension . configuration ! . properties ). map (([ key , schema ]) => ({ 117 key, 118field :configurationField ( 119key , 120schema , 121values . get ()?.[ key ], 122( value ) => void save ({ [ key ] :value }). catch (() => {}), 123live [ key ] && { scope :own , choices :live [ key ] }, 124folders ?.( key ), 125), 126})); 127own . add ( 128values . subscribe (( next ) => { 129for ( const { key, field} of fields ) { 130if ( ! field . input . contains ( document . activeElement )) field . update ( next ?.[ key ]); 131} 132}). unsubscribe , 133); 134return fields ; 135}; 136 137const where = < div class = "start-environment" / >; 138swap ( 139scope , 140where , 141extensions . derive (( list ) => 142list . find (( extension ) => 143extension . name === "environment" && extension . running && extension . configuration 144) ?? null 145), 146( extension , own ) => { 147if ( ! extension ) return []; 148const folders = { scope :own , environment, projects :shared . projects , base :() => null }; 149const fields = ( save :( patch :Values ) => Promise < Values >) => 150( 151< fieldset class = "seed" > 152{ generated ( 153own , 154extension , 155environmentValues , 156save , 157( key ) => key === "cwd" ?folders :undefined , 158). map (({ field }) => field . element )} 159< / fieldset> 160) as HTMLElement ; 161return [ controls ( own , extension , environmentValues , fields , ( patch ) => { 162// another machine has other folders 163if ( Object . hasOwn ( patch , "environment" )) { 164environment . set ( String ( patch . environment )); 165if ( ! Object . hasOwn ( patch , "cwd" )) answers . environment . cwd = "" ; 166} 167showProject (); 168})]; 169}, 170); 171 172const toggles = < div class = "toggles" role = "group" aria - label = "extensions" / >; 173swap ( 174scope , 175toggles , 176optional , 177( list ) => 178list . map (( extension ) => ( 179< label title = { extension . description ?? "" }> 180< input 181type = "checkbox" 182checked = { isChosen ( extension . name )} 183_onchange = { function () { 184const next = new Set ( list . map (( e ) => e . name ). filter ( isChosen )); 185if ( this . checked ) next . add ( extension . name ); 186else next . delete ( extension . name ); 187chosen . set ( next ); 188}} 189/> 190{ extension . name } 191</ label > 192)), 193); 194// what the chosen extensions, and those always on, ask of a new conversation, in installed order 195const options = < div class = "start-options" / >; 196swap ( 197scope , 198options , 199computed ( 200scope , 201[ extensions , chosen ], 202() => 203extensions . get (). filter (( extension ) => 204extension . running && extension . configuration && extension . name !== "environment" && 205( extension . alwaysOn || isChosen ( extension . name )) 206), 207), 208( list , own ) => 209list . map (( extension ) => { 210const values = new Signal < Values | null >( null ); 211return controls ( own , extension , values , ( save ) => 212( 213< fieldset class = "seed" > 214< legend >{ extension . name }</ legend > 215{ generated ( own , extension , values , save ). map (({ field }) => field . element )} 216</ fieldset > 217) as HTMLElement ); 218}), 219); 220 221const unconfigured = ( 222< p class = "start-hint" hidden = { enabled . derive (( list ) => list . length > 0 )}> 223no models are enabled yet : < a href = "#/settings" > enable one in settings < /a> 224</ p > 225); 226 227const create = async () => { 228const slash = model . get (). indexOf ( "/" ); 229const { tid } = await api <{ tid : string }>( "POST" , "/api/conversations" , { 230extensions : Object . fromEntries ( 231extensions . get (). filter (( extension ) => 232extension . running && 233( extension . alwaysOn ? extension . configuration : isChosen ( extension . name )) 234). map (({ name }) => [ name , answers [ name ] ?? {}]), 235), 236model : { provider : model . get (). slice ( 0 , slash ), modelId : model . get (). slice ( slash + 1 ) }, 237thinkingLevel : thinking . get (), 238}); 239// installed order, so a selection reads the same every time 240const names = optional . get (). map (( e ) => e . name ). filter ( isChosen ); 241localStorage . setItem ( EXTENSIONS , JSON . stringify ( names )); 242localStorage . setItem ( MODEL , model . get ()); 243localStorage . setItem ( THINKING , thinking . get ()); 244localStorage . setItem ( SEEDS , JSON . stringify ( answers )); 245return tid ; 246}; 247// as in a conversation, references and the shell come with code 248const coding = computed ( 249scope , 250[ extensions , chosen ], 251() => 252extensions . get (). some (( extension ) => 253extension . name === "code" && extension . running && 254( extension . alwaysOn || isChosen ( extension . name )) 255), 256); 257const { element : input , setText } = composer ( 258scope , 259"new" , 260new Signal ( false ), 261new Signal < readonly CommandInfo []>( EMPTY ), 262async ( text , _followUp , images ) => { 263const command = text . match ( / ^\/(\S+) / )?.[ 1 ]; 264if ( command ) { 265throw new Error ( `/ ${ command } needs a conversation: send a message to start this one` ); 266} 267const shell = coding . get () && text . startsWith ( "!" ); 268if ( shell && images . length ) throw new Error ( "Shell commands cannot include images" ); 269if ( model . get () === "" ) throw new Error ( "enable a model in settings first" ); 270const tid = await create (); 271try { 272if ( shell ) { 273const excludeFromContext = text . startsWith ( "!!" ); 274await api ( "POST" , `/api/conversations/ ${ tid } /shell` , { 275command : text . slice ( excludeFromContext ? 2 : 1 ). trim (), 276excludeFromContext , 277}); 278} else { 279await api ( "POST" , `/api/conversations/ ${ tid } /messages` , { 280content : userContent ( text , images ), 281}); 282} 283go ({ tid }, setText ); 284} catch { 285// the conversation exists now, so the message waits in its composer rather than starting another 286go ({ tid , text }, setText ); 287} 288}, 289notify , 290undefined , 291( prefix ) => { 292if ( ! coding . get ()) return Promise . resolve ([]); 293const query = new URLSearchParams ({ environment : environment . get (), prefix }); 294const cwd = String ( answers . environment ?. cwd ?? "" ); 295if ( cwd ) query . set ( "cwd" , cwd ); 296return api < string []>( "GET" , `/api/references? ${ query } ` ); 297}, 298coding , 299); 300 301// a conversation with nothing in it yet, for its views, or for a draft finished there 302const blank = ( 303< button 304type = "button" 305class = "primary start-blank" 306_onclick = { async function () { 307this . disabled = true ; 308try { 309if ( model . get () === "" ) throw new Error ( "enable a model in settings first" ); 310const tid = await create (); 311const text = input . querySelector ( "textarea" ) ! . value ; 312setText ( "" ); 313go ({ tid , text }, setText ); 314} catch ( error ) { 315notify (( error as Error ). message , true ); 316} finally { 317this . disabled = false ; 318} 319}} 320> 321start blank 322< /button> 323); 324 325// a project's own page waits for its project to arrive; a place shows the project it is in 326let pending = project ; 327const place = () = > { 328if ( pending === "" || ( pending && shared . projects . get ()[ pending ])) { 329picker . pick ( pending ); 330pending = null ; 331} 332showProject (); 333}; 334scope . add ( shared . projects . subscribe ( place ). unsubscribe ); 335place (); 336if ( ! matchMedia ( "(pointer: coarse)" ). matches ) { 337requestAnimationFrame (() => input . querySelector ( "textarea" )?. focus ()); 338} 339 340return [ 341< header class = "bar" > 342< span class = "bar-title" > new conversation < /span> 343{ blank } 344< / header>, 345< div class = "pane start-pane" > 346< div class = "scroller" > 347< div class = "start" > 348< section class = "start-row start-where" > 349< h3 > environment </ h3 > 350< div class = "start-fields" > 351{ picker . element } 352{ where } 353< /div> 354</ section > 355< section class = "start-row" > 356< h3 > model </ h3 > 357< div class = "start-fields" > 358{ modelSelect ( scope , models , enabled , model , ( next ) => model . set ( modelKey ( next )))} 359{ thinkingSelect ( scope , models , model , thinking , ( level ) => thinking . set ( level ))} 360{ unconfigured } 361</ div > 362< / section > 363< section class =" start-row start-with " > 364< h3 > extensions </ h3 > 365< div class =" start-fields " > 366{ toggles } 367{ options } 368</ div > 369</ section > 370</ div > 371< /div> 372{ notice } 373{ input } 374< /div>, 375]; 376}