char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
6183bc3
main
1import type { Environment } from "@berry/api" ; 2import { observe , resolveElement } from "./page.ts" ; 3import { launch , type Session } from "./transport.ts" ; 4import type { Action , Crop , Image , Snapshot } from "./types.ts" ; 5 6type Tab = { 7context :string ; 8key :string ; 9prefix :string ; 10document ?:string ; 11snapshot :Snapshot ; 12image ?:Image ; 13}; 14type Evaluation = 15| { type :"success" ; result :{ type :string ; value ?:string ; sharedId ?:string } } 16| { type :"exception" ; exceptionDetails :{ text :string } }; 17 18const KEYS :Record < string , string > = { 19Enter :"\uE007" , 20Tab :"\uE004" , 21Escape :"\uE00C" , 22Backspace :"\uE003" , 23Delete :"\uE017" , 24ArrowLeft :"\uE012" , 25ArrowUp :"\uE013" , 26ArrowRight :"\uE014" , 27ArrowDown :"\uE015" , 28Home :"\uE011" , 29End :"\uE010" , 30PageUp :"\uE00E" , 31PageDown :"\uE00F" , 32Space :" " , 33Control :"\uE009" , 34Shift :"\uE008" , 35Alt :"\uE00A" , 36Meta :"\uE03D" , 37}; 38 39/** How long a session may go without a call before it closes. */ 40export const IDLE_MS = 15 * 60_000 ; 41 42/** One conversation's temporary session; operations are serialized to keep references meaningful. */ 43export class Browser { 44 #session?:Promise < Session >; 45 #tabs= new Map < string , Tab >(); 46 #bootstrap= new Set < string >(); 47 #diagnostics= new Map < string , string []>(); 48 #navigation= new Map < string , { id :unknown ; url :string ; document ?:string }>(); 49 #nextTab= 0 ; 50 #queue:Promise < unknown > = Promise . resolve (); 51 #signal?:AbortSignal ; 52 #disposed= false ; 53 #lifetime= new AbortController (); 54 #stopped?:AbortController ; 55 #idleMs:number ; 56 #idle?:ReturnType < typeof setTimeout >; 57 #calls= 0 ; 58 59constructor ( readonly env :Environment , { idleMs= IDLE_MS } = {}) { 60this . #idleMs= idleMs ; 61env . lifetime ?. addEventListener ( "abort" , this . #disconnected, { once :true }); 62} 63 64 #disconnected= () => void this . dispose (); 65 66get active () :boolean { 67return this . #session!== undefined ; 68} 69get snapshots () :Snapshot [] { 70return [ ...this . #tabs. values ()]. map (( tab ) => tab . snapshot ); 71} 72get screenshots () :Record < string , Image > { 73return Object . fromEntries ( 74[ ...this . #tabs]. flatMap (([ id , tab ]) => tab . image ?[[ id , tab . image ]] :[]), 75); 76} 77 78async exclusive < T > ( work :() => Promise < T >, signal :AbortSignal ) :Promise < T > { 79this . #calls++ ; 80clearTimeout ( this . #idle); 81const result = this . #queue. catch (() => {}). then ( async () => { 82const stopped = this . #stopped= new AbortController (); 83signal = AbortSignal . any ([ 84signal , 85this . #lifetime. signal , 86stopped . signal , 87 ...( this . env . lifetime ?[ this . env . lifetime ] :[]), 88]); 89signal . throwIfAborted (); 90if ( this . #disposed) throw new Error ( "Browser extension has been unloaded." ); 91this . #signal= signal ; 92let closing :Promise < void > | undefined ; 93let abort ! :() => void ; 94const cancelled = new Promise < never >(( _ , reject ) => { 95abort = () => { 96// stop abandoned input/navigation before another call uses the session 97closing = this . close (); 98reject ( signal . reason ); 99}; 100signal . addEventListener ( "abort" , abort , { once :true }); 101}); 102const running = work (); 103try { 104return await Promise . race ([ running , cancelled ]); 105} finally { 106signal . removeEventListener ( "abort" , abort ); 107if ( signal . aborted ) { 108await running . catch (() => {}); 109await closing ; 110} 111this . #signal= undefined ; 112this . #stopped= undefined ; 113} 114}); 115this . #queue= result ; 116try { 117return await result ; 118} finally { 119if ( -- this . #calls=== 0 && this . #session) { 120this . #idle= setTimeout (() => void this . close (), this . #idleMs); 121Deno . unrefTimer ( this . #idle); 122} 123} 124} 125 126/** End the session now, interrupting the call using it. */ 127async stop () :Promise < void > { 128this . #stopped?. abort ( new Error ( "The user closed the browser session." )); 129await this . close (); 130} 131 132async dispose () :Promise < void > { 133this . env . lifetime ?. removeEventListener ( "abort" , this . #disconnected); 134this . #disposed= true ; 135this . #lifetime. abort ( new Error ( "Browser extension has been unloaded." )); 136await this . close (); 137await this . #queue. catch (() => {}); 138} 139 140async close () :Promise < void > { 141clearTimeout ( this . #idle); 142const session = this . #session; 143this . #session= undefined ; 144this . #tabs. clear (); 145this . #bootstrap. clear (); 146this . #diagnostics. clear (); 147this . #navigation. clear (); 148await ( await session ?.catch (() => undefined ))?. close (); 149} 150 151 #event= ( method :string , params :Record < string , unknown >) => { 152const source = params . source as { context ?:string } | undefined ; 153const context = String ( params . context ?? source ?. context ?? "" ); 154if ( method === "browsingContext.navigationStarted" ) { 155const tab = [ ...this . #tabs. values ()]. find (( tab ) => tab . context === context ); 156this . #navigation. set ( context , { 157id :params . navigation , 158url :String ( params . url ), 159document :tab ?. document , 160}); 161} else if ( 162method . startsWith ( "browsingContext." ) && 163this . #navigation. get ( context )?. id === params . navigation 164) { 165this . #navigation. delete ( context ); 166} 167let message = "" ; 168if ( method === "log.entryAdded" && ( params . level === "error" || params . level === "warn" )) { 169message = ` ${ params . level } : ${ params . text ?? "Console error" } ` ; 170} else if ( method === "network.fetchError" ) { 171const request = params . request as { url ?:string } | undefined ; 172message = `Network: ${ params . errorText } ${ request ?. url ?? "" } ` ; 173} else if ( method === "network.responseCompleted" ) { 174const response = params . response as { status ?:number ; url ?:string } | undefined ; 175if (( response ?. status ?? 0 ) >= 400 ) message = `HTTP ${ response ! . status } : ${ response ! . url } ` ; 176} 177if ( message && context ) { 178this . #diagnostics. set ( 179context , 180[ ...( this . #diagnostics. get ( context ) ?? []), message . slice ( 0 , 1_000 )]. slice ( - 20 ), 181); 182} 183}; 184 185async #getSession() :Promise < Session > { 186this . #signal?. throwIfAborted (); 187if ( this . #disposed) throw new Error ( "Browser extension has been unloaded." ); 188if ( ! this . #session) { 189const started = launch ( this . env , this . #event, this . #signal). then ( async ( session ) => { 190try { 191const { contexts} = await session . request <{ contexts :{ context :string }[] }>( 192"browsingContext.getTree" , 193{}, 194); 195this . #bootstrap= new Set ( contexts . map (( context ) => context . context )); 196session . closed . then (() => { 197if ( this . #session=== started ) { 198this . #session= undefined ; 199this . #tabs. clear (); 200this . #diagnostics. clear (); 201} 202}); 203return session ; 204} catch ( error ) { 205await session . close (); 206throw error ; 207} 208}); 209this . #session= started ; 210started . catch (() => { 211if ( this . #session=== started ) this . #session= undefined ; 212}); 213} 214const session = await this . #session; 215this . #signal?. throwIfAborted (); 216if ( this . #disposed) throw new Error ( "Browser extension has been unloaded." ); 217return session ; 218} 219 220 #adopt( context :string , url = "about:blank" ) :string { 221const id = `t ${ ++ this . #nextTab } ` ; 222this . #tabs. set ( id , { 223 context, 224key :`berry_ ${ crypto . randomUUID (). replaceAll ( "-" , "" )} ` , 225prefix :"" , 226snapshot :{ 227tab :id , 228 url, 229title :"" , 230text :"" , 231elements :[], 232truncated :false , 233frames :[], 234diagnostics :[], 235}, 236}); 237return id ; 238} 239 240async tabs () :Promise <{ tab :string ; url :string ; title :string }[]> { 241if ( ! this . #session) return []; 242const session = await this . #getSession(); 243const { contexts} = await session . request <{ contexts :{ context :string ; url :string }[] }>( 244"browsingContext.getTree" , 245{}, 246); 247for ( const [ id , tab ] of this . #tabs) { 248if ( ! contexts . some (( context ) => context . context === tab . context )) this . #tabs. delete ( id ); 249} 250for ( const context of contexts ) { 251if ( this . #bootstrap. has ( context . context )) continue ; 252const tab = [ ...this . #tabs. values ()]. find (( tab ) => tab . context === context . context ); 253if ( tab ) tab . snapshot = { ...tab . snapshot , url :context . url }; 254else this . #adopt( context . context , context . url ); 255} 256return this . snapshots . map (({ tab, url, title}) => ({ tab, url, title})); 257} 258 259 #tab( id :string ) :Tab { 260const tab = this . #tabs. get ( id ); 261if ( ! tab ) { 262throw new Error ( 263`No live tab ${ id } . Use browser with action "tabs"; sessions end when idle, closed, reloaded, or reset.` , 264); 265} 266return tab ; 267} 268 269async #evaluate( tab :Tab , expression :string ) :Promise < Evaluation & { type :"success" }> { 270const session = await this . #getSession(); 271const result = await session . request < Evaluation >( "script.evaluate" , { 272 expression, 273target :{ context :tab . context , sandbox :"berry" }, 274awaitPromise :true , 275resultOwnership :"none" , 276}); 277if ( result . type === "exception" ) throw new Error ( result . exceptionDetails . text ); 278return result ; 279} 280 281 #resolve( tab :Tab , ref :string ) :string { 282if ( ! tab . prefix || ! tab . snapshot . elements . some (( element ) => element . ref === ref )) { 283throw new Error ( "Stale or unknown element reference; take a new snapshot." ); 284} 285return `( ${ resolveElement . toString ()} )( ${ JSON . stringify ( tab . key )} , ${ JSON . stringify ( ref )} )` ; 286} 287 288async #keys( tab :Tab , actions :{ type :string ; value :string }[]) :Promise < void > { 289const session = await this . #getSession(); 290try { 291await session . request ( "input.performActions" , { 292context :tab . context , 293actions :[{ type :"key" , id :"keyboard" , actions}], 294}); 295} finally { 296await session . request ( "input.releaseActions" , { context :tab . context }). catch (() => {}); 297} 298} 299 300async snapshot ( id :string ) :Promise < Snapshot > { 301const tab = this . #tab( id ); 302const prefix = ` ${ crypto . randomUUID (). slice ( 0 , 8 )} -` ; 303const expression = `( ${ observe . toString ()} )( ${ JSON . stringify ( tab . key )} , ${ 304JSON . stringify ( prefix ) 305} ).then(JSON.stringify)` ; 306const deadline = Date . now () + 3_000 ; 307while ( true ) { 308await this . #waitForNavigation( tab . context ); 309try { 310const { result} = await this . #evaluate( tab , expression ); 311if ( result . type !== "string" ) throw new Error ( "Browser observation was not text." ); 312const { documentId, ...observation } = JSON . parse ( result . value ! ) as 313& Omit < Snapshot , "tab" | "diagnostics" > 314& { documentId :string }; 315tab . document = documentId ; 316tab . snapshot = { 317 ...observation , 318tab :id , 319diagnostics :this . #diagnostics. get ( tab . context ) ?? [], 320}; 321tab . prefix = prefix ; 322tab . image = undefined ; 323return tab . snapshot ; 324} catch ( error ) { 325if ( 326Date . now () >= deadline || ! /realm|document.*unload|execution context / i . test ( String ( error )) 327) throw error ; 328await this . #pause( 100 ); 329} 330} 331} 332 333async screenshot ( id :string , crop ?:Crop ) :Promise < Image > { 334const tab = this . #tab( id ); 335const session = await this . #getSession(); 336const { data} = await session . request <{ data :string }>( "browsingContext.captureScreenshot" , { 337context :tab . context , 338origin :"viewport" , 339 ...( crop ?{ clip :{ type :"box" , ...crop } } :{}), 340}); 341return tab . image = { type :"image" , mimeType :"image/png" , data}; 342} 343 344async act ( action :Action ) :Promise < Snapshot | null > { 345if ( action . action === "reset" ) { 346await this . close (); 347return null ; 348} 349if ( action . action === "tabs" ) { 350await this . tabs (); 351return null ; 352} 353if ( action . action === "open" ) { 354this . #url( action . url ); 355if ( this . #tabs. size >= 12 ) throw new Error ( "Close a tab before opening more (limit: 12)." ); 356const session = await this . #getSession(); 357const { context} = await session . request <{ context :string }>( "browsingContext.create" , { 358type :"tab" , 359}); 360const id = this . #adopt( context ); 361await session . request ( "browsingContext.setViewport" , { 362 context, 363viewport :{ width :1280 , height :900 }, 364devicePixelRatio :1 , 365}); 366await session . request ( "browsingContext.navigate" , { 367 context, 368url :action . url , 369wait :"interactive" , 370}); 371return await this . snapshot ( id ); 372} 373const tab = this . #tab( action . tab ); 374const session = await this . #getSession(); 375const context = tab . context ; 376switch ( action . action ) { 377case "snapshot" : 378break ; 379case "close" : 380await session . request ( "browsingContext.close" , { context}); 381this . #tabs. delete ( action . tab ); 382this . #diagnostics. delete ( context ); 383return null ; 384case "navigate" : 385this . #url( action . url ); 386await session . request ( "browsingContext.navigate" , { 387 context, 388url :action . url , 389wait :"interactive" , 390}); 391break ; 392case "reload" : 393await session . request ( "browsingContext.reload" , { context, wait :"interactive" }); 394break ; 395case "back" : 396case "forward" : 397await session . request ( "browsingContext.traverseHistory" , { 398 context, 399delta :action . action === "back" ?- 1 :1 , 400}); 401break ; 402case "click" :{ 403const { result} = await this . #evaluate( 404tab , 405`(() => { 406const element = ${ this . #resolve ( tab , action . ref )} ; 407const rect = element.getBoundingClientRect(); 408const x = (Math.max(0, rect.left) + Math.min(innerWidth, rect.right)) / 2; 409const y = (Math.max(0, rect.top) + Math.min(innerHeight, rect.bottom)) / 2; 410let hit = document.elementFromPoint(x, y); 411while (hit?.shadowRoot?.elementFromPoint(x, y)) { 412const next = hit.shadowRoot.elementFromPoint(x, y); 413if (next === hit) break; 414hit = next; 415} 416for (let current = hit; current; current = current.parentElement || current.getRootNode().host) { 417if (current === element) return element; 418} 419throw new Error("Element is obscured; take a new snapshot or screenshot."); 420})()` , 421); 422if ( ! result . sharedId ) throw new Error ( "Could not resolve the element." ); 423try { 424await session . request ( "input.performActions" , { 425 context, 426actions :[{ 427type :"pointer" , 428id :"mouse" , 429parameters :{ pointerType :"mouse" }, 430actions :[ 431{ 432type :"pointerMove" , 433x :0 , 434y :0 , 435origin :{ type :"element" , element :{ sharedId :result . sharedId } }, 436}, 437{ type :"pointerDown" , button :0 }, 438{ type :"pointerUp" , button :0 }, 439], 440}], 441}); 442} finally { 443await session . request ( "input.releaseActions" , { context}). catch (() => {}); 444} 445break ; 446} 447case "fill" :{ 448await this . #evaluate( 449tab , 450`(() => { 451const element = ${ this . #resolve ( tab , action . ref )} ; 452if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable) || element.readOnly) throw new Error("Element is not an editable text field."); 453element.focus(); 454if (document.activeElement !== element && element.getRootNode().activeElement !== element) throw new Error("Could not focus the field."); 455})()` , 456); 457const control = KEYS . Control ; 458await this . #keys( tab , [ 459{ type :"keyDown" , value :control }, 460{ type :"keyDown" , value :"a" }, 461{ type :"keyUp" , value :"a" }, 462{ type :"keyUp" , value :control }, 463{ type :"keyDown" , value :KEYS . Backspace }, 464{ type :"keyUp" , value :KEYS . Backspace }, 465 ...[ ...action . text ]. flatMap (( 466value , 467) => [{ type :"keyDown" , value}, { type :"keyUp" , value}]), 468]); 469break ; 470} 471case "select" : 472await this . #evaluate( 473tab , 474`(() => { 475const element = ${ this . #resolve ( tab , action . ref )} ; 476if (!(element instanceof HTMLSelectElement)) throw new Error("Element is not a select."); 477const value = ${ JSON . stringify ( action . value )} ; 478const option = [...element.options].find(option => option.value === value); 479if (!option || option.disabled) throw new Error("No enabled option with that value."); 480element.value = value; 481element.dispatchEvent(new Event("input", { bubbles: true })); 482element.dispatchEvent(new Event("change", { bubbles: true })); 483})()` , 484); 485break ; 486case "press" :{ 487if ( action . ref ) await this . #evaluate( tab , `( ${ this . #resolve ( tab , action . ref )} ).focus()` ); 488const parts = action . key . split ( "+" ); 489const key = parts . pop () ! ; 490if ( 491parts . some (( part ) => ! [ "Control" , "Shift" , "Alt" , "Meta" ]. includes ( part )) || 492( ! KEYS [ key ] && [ ...key ]. length !== 1 ) 493) { 494throw new Error ( 495"Use a named key or character, optionally prefixed with Control+, Shift+, Alt+, or Meta+." , 496); 497} 498const values = [ ...parts . map (( part ) => KEYS [ part ]), KEYS [ key ] ?? key ]; 499await this . #keys( tab , [ 500 ...values . map (( value ) => ({ type :"keyDown" , value})), 501 ...values . toReversed (). map (( value ) => ({ type :"keyUp" , value})), 502]); 503break ; 504} 505case "scroll" : 506await this . #evaluate( tab , `window.scrollBy( ${ action . x } , ${ action . y } )` ); 507break ; 508case "wait" : 509await this . #pause( action . ms ); 510break ; 511} 512return await this . snapshot ( action . tab ); 513} 514 515async #waitForNavigation( context :string ) :Promise < void > { 516const tab = [ ...this . #tabs. values ()]. find (( tab ) => tab . context === context ) ! ; 517const deadline = Date . now () + 25_000 ; 518while ( this . #navigation. has ( context )) { 519const navigation = this . #navigation. get ( context ) ! ; 520try { 521// firefox does not emit domContentLoaded when restoring from the back/forward cache 522const { result} = await this . #evaluate( 523tab , 524`JSON.stringify(document.readyState !== "loading" && location.href === ${ 525JSON . stringify ( navigation . url ) 526} && ( ${ JSON . stringify ( navigation . document ?? null )} === null || globalThis[ ${ 527JSON . stringify ( tab . key + "_document" ) 528} ] !== ${ JSON . stringify ( navigation . document )} || location.href !== ${ 529JSON . stringify ( tab . snapshot . url ) 530} ))` , 531); 532if ( result . value === "true" ) { 533if ( this . #navigation. get ( context ) === navigation ) this . #navigation. delete ( context ); 534continue ; 535} 536} catch ( error ) { 537if ( ! /realm|document.*unload|execution context / i . test ( String ( error ))) throw error ; 538} 539if ( Date . now () >= deadline ) { 540throw new Error ( "Navigation did not finish; inspect the tab again." ); 541} 542await this . #pause( 50 ); 543} 544} 545 546 #pause( ms :number ) :Promise < void > { 547const signal = this . #signal; 548signal ?. throwIfAborted (); 549return new Promise (( resolve , reject ) => { 550const finish = () => { 551clearTimeout ( timer ); 552signal ?. removeEventListener ( "abort" , abort ); 553resolve (); 554}; 555const abort = () => { 556clearTimeout ( timer ); 557reject ( signal ! . reason ); 558}; 559const timer = setTimeout ( finish , ms ); 560signal ?. addEventListener ( "abort" , abort , { once :true }); 561}); 562} 563 564 #url( url :string ) { 565if ( url !== "about:blank" && ! [ "http:" , "https:" ]. includes ( new URL ( url ). protocol )) { 566throw new Error ( "Only HTTP/HTTPS URLs and about:blank are supported." ); 567} 568} 569}