char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
c582293
main
1import { Signal } from "@char/aftercare" ; 2import { api } from "./client.ts" ; 3import type { CommandInfo } from "./conversation.tsx" ; 4import { Scope , swap , when } from "./ui.ts" ; 5 6export type TreeEntry = { 7id :number ; 8parent :number | null ; 9branch :number ; 10kind :string ; 11text :string ; 12time ?:number ; 13imageCount ?:number ; 14tools ?:string []; 15error ?:true ; 16}; 17export type Branch = { 18id :number ; 19created :number ; 20tip :number | null ; 21}; 22export type ConversationTree = { head :number ; branches :Branch []; entries :TreeEntry [] }; 23 24/** 25* A conversation, or a draft that continues it from an entry, or with `fork` a new conversation from one; and what its 26* composer starts with. 27*/ 28export type Place = { tid :string ; at ?:number ; fork ?:boolean ; text ?:string }; 29 30type Filter = "messages" | "user" | "all" ; 31const FILTERS :Record < Filter , { label :string ; shows :( entry :TreeEntry ) => boolean }> = { 32messages :{ 33label :"messages" , 34shows :( entry ) => 35entry . kind === "pi.user" || entry . kind === "pi.compaction" || entry . kind === "pi.reset" || 36( entry . kind === "pi.assistant" && ( entry . text !== "" || entry . error === true )), 37}, 38user :{ label :"yours" , shows :( entry ) => entry . kind === "pi.user" }, 39all :{ label :"everything" , shows :() => true }, 40}; 41 42type Row = { entry :TreeEntry ; lead :string ; branches :Branch []; onPath :boolean }; 43 44/** 45* The rows of a tree as tree(1) draws one, except that a run without branch points keeps its indent, as pi's does. 46* An entry `shows` leaves out hands its children to its nearest shown ancestor; a branch's end always shows. 47*/ 48function layout ( 49tree :ConversationTree , 50shows :( entry :TreeEntry ) => boolean , 51path :Set < number >, 52) :Row [] { 53const byId = new Map ( tree . entries . map (( entry ) => [ entry . id , entry ])); 54const ends = Map . groupBy ( tree . branches , ( branch ) => branch . tip ); 55const shown = ( id :number ) => { 56const entry = byId . get ( id ); 57return entry !== undefined && ( shows ( entry ) || ends . has ( id )); 58}; 59const children = new Map < number | null , TreeEntry []>(); 60for ( const entry of tree . entries ) { 61if ( ! shown ( entry . id )) continue ; 62let parent = entry . parent ; 63while ( parent !== null && ! shown ( parent )) parent = byId . get ( parent )?. parent ?? null ; 64children . set ( parent , [ ...children . get ( parent ) ?? [], entry ]); 65} 66 67const rows :Row [] = []; 68const stack :{ entry :TreeEntry ; lead :string ; indent :string }[] = []; 69const descend = ( list :readonly TreeEntry [], indent :string ) => { 70// the way to where we are comes first 71const kids = [ ...list . filter (( e ) => path . has ( e . id )), ...list . filter (( e ) => ! path . has ( e . id ))]; 72if ( kids . length === 1 ) return void stack . push ({ entry :kids [ 0 ], lead :indent , indent}); 73for ( let i = kids . length - 1 ; i >= 0 ; i -- ) { 74const last = i === kids . length - 1 ; 75stack . push ({ 76entry :kids [ i ], 77lead :indent + ( last ?"└─ " :"├─ " ), 78indent :indent + ( last ?" " :"│ " ), 79}); 80} 81}; 82descend ( children . get ( null ) ?? [], "" ); 83while ( stack . length > 0 ) { 84const { entry, lead, indent} = stack . pop () ! ; 85rows . push ({ entry, lead, branches :ends . get ( entry . id ) ?? [], onPath :path . has ( entry . id ) }); 86descend ( children . get ( entry . id ) ?? [], indent ); 87} 88return rows ; 89} 90 91/** A row's label, by kind, and its text. */ 92export function describe ( entry :TreeEntry ) :[ label :string , text :string ] { 93switch ( entry . kind ) { 94case "pi.user" : 95return [ 96"user" , 97[ 98entry . text , 99entry . imageCount ?`[ ${ entry . imageCount } image ${ entry . imageCount === 1 ? "" : "s" } ]` :"" , 100]. filter ( Boolean ). join ( " " ), 101]; 102case "pi.assistant" : 103return [ "assistant" , entry . text || `→ ${ entry . tools ?. join ( ", " ) ?? "" } ` ]; 104case "pi.tool-result" : 105return [ entry . tools ?.[ 0 ] ?? "result" , entry . text ]; 106case "pi.system" : 107return [ "prompt" , "" ]; 108case "pi.reset" : 109return [ "context reset" , entry . text ]; 110case "pi.compaction" : 111return [ "compacted" , entry . text ]; 112case "berry.event" : 113return [ "event" , entry . text ]; 114case "berry.start" : 115return [ "start" , "" ]; 116case "berry.subagent-report" : 117return [ "report" , entry . text ]; 118default : 119return [ entry . kind , entry . text ]; 120} 121} 122 123/** A move along a conversation's own tree, which its view shows once its transcript has caught up. */ 124export type Move = { 125how :"continue" | "edit" | "regenerate" ; 126/** The entry chosen: one to continue after, a message of the user's to edit, or the agent's to write afresh. */ 127entry :TreeEntry ; 128/** Where the conversation continues from, and where it was. */ 129at :number ; 130previous :number | null ; 131/** Whether a transcript ending at `latest` shows it, which a regenerating run may already have gone past. */ 132landed :( latest :number | null ) => boolean ; 133}; 134 135/** A picker for continuing a conversation or starting a separate one from any entry. */ 136function open ( 137tree :ConversationTree , 138place :Place , 139mode :"tree" | "fork" , 140go :( place :Place ) => void , 141moved ?:( move :Move ) => Promise < void >, 142) { 143const scope = new Scope (); 144const dialog = < dialog class = "dialog tree-dialog" / > as HTMLDialogElement ; 145dialog . addEventListener ( "close" , () => { 146scope . dispose (); 147dialog . remove (); 148}); 149// a tap outside closes it too, for screens without escape 150dialog . addEventListener ( "click" , ( event ) => { 151const box = dialog . getBoundingClientRect (); 152const outside = event . clientX < box . left || event . clientX > box . right || 153event . clientY < box . top || event . clientY > box . bottom ; 154if ( event . target === dialog && outside ) dialog . close (); 155}); 156 157const byId = new Map ( tree . entries . map (( entry ) => [ entry . id , entry ])); 158const here = place . at ?? tree . branches . find (( b ) => b . id === tree . head )?. tip ?? null ; 159const path = new Set < number >(); 160for ( let id = here ; id !== null ; id = byId . get ( id )?. parent ?? null ) path . add ( id ); 161const newest = tree . entries . at ( - 1 )?. id ?? 0 ; 162 163const failure = < p class = "tree-error" role = "alert" hidden / > as HTMLParagraphElement; 164const fail = ( message :string ) => { 165failure . textContent = message ; 166failure . hidden = false ; 167}; 168let choosing = false ; 169// the picker stays open until the transcript shows the choice, so it never closes onto the old one 170const choose = async ( entry :TreeEntry , pending :Element , regenerate = false ) => { 171if ( choosing ) return ; 172failure . hidden = true ; 173const how = regenerate ?"regenerate" :entry . kind === "pi.user" ?"edit" :"continue" ; 174const at = how === "continue" ?entry . id :entry . parent ; 175if ( at === null ) return fail ( "nothing comes before this message to branch from" ); 176const text = how === "edit" ?{ text :entry . text } :{}; 177choosing = true ; 178pending . setAttribute ( "aria-busy" , "true" ); 179try { 180if ( mode === "fork" ) { 181const { tid} = await api <{ tid :string }>( 182"POST" , 183`/api/conversations/ ${ place . tid } /fork` , 184{ entryId :at }, 185); 186dialog . close (); 187return go ({ tid, ...text }); 188} 189if ( how === "regenerate" ) { 190await api ( "POST" , `/api/conversations/ ${ place . tid } /regenerate` , { entryId :entry . id }); 191} else { 192await api ( "POST" , `/api/conversations/ ${ place . tid } /branch` , { entryId :at }); 193} 194await moved ?.({ 195 how, 196 entry, 197 at, 198previous :here , 199landed :( latest ) => latest === at || ( latest !== null && latest > newest ), 200}); 201dialog . close (); 202go ({ tid :place . tid , ...text }); 203} catch ( error ) { 204fail ( error instanceof Error ?error . message :String ( error )); 205} finally { 206choosing = false ; 207pending . removeAttribute ( "aria-busy" ); 208} 209}; 210const row = ({ entry, lead, branches, onPath} :Row ) => { 211const [ label , text ] = describe ( entry ); 212const element = ( 213< div class = "tree-entry" > 214< button 215type = "button" 216class = { entry . id === here ?"tree-row here" :"tree-row" } 217dataset = {{ id :String ( entry . id ), kind :entry . kind , error :String ( entry . error ?? false ) }} 218_onclick = {() => choose ( entry , element )} 219> 220< span class = "lead" >{ lead}{ onPath ?"• " :"" }</ span > 221< span class = "label" >{ label}< / span> 222< span class = "text" >{ text }< /span> 223{ branches . map (( branch ) => ( 224< time class = { branch . id === tree . head ? "branch current" : "branch" }> 225{ when ( new Date ( branch . created ))} 226< / time> 227))} 228</ button > 229{ mode === "tree" && entry . kind === "pi.assistant" 230? ( 231< button 232type = "button" 233class = "tree-regenerate" 234dataset = {{ id : String ( entry . id ) }} 235disabled = { entry . parent === null } 236aria-label = { `regenerate: ${ text } ` } 237_onclick = {() => choose ( entry , element , true )} 238> 239regenerate 240</ button > 241) 242: "" } 243{ entry . time === undefined 244? "" 245: ( 246< time class = "time" title = { new Date ( entry . time ). toLocaleString ()}> 247{ when ( new Date ( entry . time ), { seconds : true })} 248</ time > 249)} 250</ div > 251); 252return element ; 253}; 254 255// focus follows the reader: a filter that hides their row moves them to its nearest shown ancestor 256let selected = here ; 257const list = < div class = "tree-rows" / > as HTMLDivElement ; 258list . addEventListener ( "focusin" , ( event ) => { 259const id = ( event . target as HTMLElement ). dataset . id ; 260if ( id !== undefined ) selected = Number ( id ); 261}); 262list . addEventListener ( "keydown" , ( event ) => { 263const rows = [ ...list . querySelectorAll < HTMLElement >( ".tree-row" )]; 264const step = ({ 265ArrowUp : - 1 , 266ArrowDown : 1 , 267PageUp : - 15 , 268PageDown : 15 , 269Home : - Infinity , 270End : Infinity , 271} as Record < string , number >)[ event . key ]; 272if ( step === undefined ) return ; 273event . preventDefault (); 274const at = rows . findIndex (( row ) => Number ( row . dataset . id ) === selected ); 275rows [ Math . max ( 0 , Math . min ( rows . length - 1 , at + step ))]?. focus (); 276}); 277const refocus = () => { 278let id = selected ; 279while ( id !== null && ! list . querySelector ( `.tree-row[data-id=" ${ id } "]` )) { 280id = byId . get ( id )?. parent ?? null ; 281} 282const target = id === null 283? list . querySelector < HTMLElement >( ".tree-row" ) 284: list . querySelector < HTMLElement >( `.tree-row[data-id=" ${ id } "]` ); 285target ?. focus ({ preventScroll : true }); 286target ?. scrollIntoView ({ block : "center" }); 287}; 288 289const filter = new Signal < Filter >( "messages" ); 290swap ( scope , list , filter , ( chosen ) => layout ( tree , FILTERS [ chosen ]. shows , path ). map ( row )); 291scope . add ( filter . subscribe (() => queueMicrotask ( refocus )). unsubscribe ); 292 293dialog . append ( 294< h2 >{ mode }</ h2 >, 295< p class = "desc" > 296{ mode === "tree" ? "continue from any point" : "fork this conversation from any point" } 297</ p >, 298< div class = "tree-filters" > 299{( Object . keys ( FILTERS ) as Filter []). map (( key ) => ( 300< label > 301< input 302type = "radio" 303name = "tree-filter" 304checked = { key === filter . get ()} 305_onchange = {() => filter . set ( key )} 306/> 307{ FILTERS [ key ]. label } 308</ label > 309))} 310</ div >, 311list , 312failure , 313< div class = "hints" > 314< span > 315< kbd > ↑ </ kbd > 316< kbd > ↓ </ kbd > move 317</ span > 318< span > 319< kbd > enter < /kbd> choose 320</ span > 321< span > 322< kbd > esc </ kbd > close 323</ span > 324< /div>, 325); 326document . body . append ( dialog ); 327dialog . showModal (); 328refocus (); 329} 330 331/** pi's `/tree`, `/fork` and `/clone`, for the view at `place`. */ 332export function branchCommands ( 333place : Place , 334go : ( place : Place ) => void , 335moved ?: ( move : Move ) => Promise < void >, 336) : CommandInfo [] { 337const show = ( mode : "tree" | "fork" ) => async () => { 338const tree = await api < ConversationTree >( "GET" , `/api/conversations/ ${ place . tid } /tree` ); 339open ( tree , place , mode , go , moved ); 340}; 341return [ 342{ 343name : "tree" , 344extension : "berry" , 345description : "continue from any point of conversation history" , 346run : show ( "tree" ), 347}, 348{ 349name : "fork" , 350extension : "berry" , 351description : "fork this conversation from any point of history" , 352run : show ( "fork" ), 353}, 354{ 355name : "clone" , 356extension : "berry" , 357description : "copy this conversation into a new one" , 358run : async () => { 359const { tid } = await api <{ tid : string }>( 360"POST" , 361`/api/conversations/ ${ place . tid } /fork` , 362place . at === undefined ? {} : { entryId : place . at }, 363); 364go ({ tid }); 365}, 366}, 367]; 368}