char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
d1e4a76
main
1import { Signal } from "@char/aftercare" ; 2import { applyImmutable , type Op } from "@earendil-works/chord/delta" ; 3 4const TOKEN_KEY = "berry.token" ; 5 6export const token = new Signal < string | null >( localStorage . getItem ( TOKEN_KEY )); 7export const connected = new Signal ( false ); 8 9export function setToken ( value :string | null ) { 10if ( value === null ) localStorage . removeItem ( TOKEN_KEY ); 11else localStorage . setItem ( TOKEN_KEY , value ); 12token . set ( value ); 13} 14 15export class ApiError extends Error { 16constructor ( readonly status :number , message :string ) { 17super ( message ); 18} 19} 20 21export async function api < T = unknown > ( 22method :string , 23path :string , 24body ?:unknown , 25signal ?:AbortSignal , 26) :Promise < T > { 27const response = await fetch ( path , { 28 method, 29 signal, 30headers :{ 31authorization :`Bearer ${ token . get ()} ` , 32 ...( body === undefined ?{} :{ "content-type" :"application/json" }), 33}, 34 ...( body === undefined ?{} :{ body :JSON . stringify ( body ) }), 35}); 36const json = await response . json (). catch (() => null ); 37if ( response . status === 401 ) setToken ( null ); 38if ( ! response . ok ) throw new ApiError ( response . status , json ?. error ?? response . statusText ); 39return json as T ; 40} 41 42type Topic = { 43value ?:{ current :unknown }; 44listeners :Set <( value :unknown ) => void >; 45/** A snapshot still taking its older entries, and whether its listeners see it meanwhile. */ 46filling ?:{ value :{ entries :unknown [] }; older :number ; shown :boolean }; 47}; 48 49const topics = new Map < string , Topic >(); 50let socket :WebSocket | null = null ; 51let retry = 0 ; 52 53const send = ( message :object ) => { 54if ( socket ?. readyState === WebSocket . OPEN ) socket . send ( JSON . stringify ( message )); 55}; 56 57function connect () { 58const current = new WebSocket ( 59` ${ location . protocol === "https:" ? "wss" : "ws" } :// ${ location . host } /ws` , 60); 61socket = current ; 62current . onopen = () => current . send ( JSON . stringify ({ t :"auth" , token :token . get () })); 63current . onmessage = ( event ) => { 64const message = JSON . parse ( event . data ); 65if ( message . t === "ready" ) { 66retry = 0 ; 67connected . set ( true ); 68for ( const name of topics . keys ()) send ({ t :"sub" , topic :name }); 69} else if ( message . t === "snapshot" ) { 70const topic = topics . get ( message . topic ); 71if ( topic === undefined ) return ; 72// a first snapshot shows as it arrives, newest first; a later one, such as a reconnect's, once it is whole 73topic . filling = message . older 74 ?{ value :message . value , older :message . older , shown :topic . value === undefined } 75 :undefined ; 76if ( topic . filling ?. shown !== false ) publish ( topic , message . value ); 77} else if ( message . t === "older" ) { 78const topic = topics . get ( message . topic ); 79const filling = topic ?. filling ; 80if ( filling === undefined ) return ; 81const { value} = filling ; 82filling . value = { ...value , entries :[ ...message . entries , ...value . entries ] }; 83filling . older -= message . entries . length ; 84if ( filling . older <= 0 ) topic ! . filling = undefined ; 85if ( filling . shown || filling . older <= 0 ) publish ( topic , filling . value ); 86} else if ( message . t === "ops" ) { 87const topic = topics . get ( message . topic ); 88if ( topic ?. value ) publish ( topic , applyImmutable ( topic . value . current , message . ops as Op [])); 89} else if ( message . t === "error" ) { 90console . error ( `[berry] ${ message . topic ?? "socket" } : ${ message . message } ` ); 91} 92}; 93current . onclose = ( event ) => { 94if ( socket !== current ) return ; 95socket = null ; 96connected . set ( false ); 97if ( event . code === 4001 ) setToken ( null ); 98else if ( token . get () !== null ) setTimeout ( connect , Math . min ( 10_000 , 250 * 2 ** retry ++ )); 99}; 100} 101 102token . subscribeImmediate (( value ) => { 103if ( value !== null && socket === null ) connect (); 104if ( value === null ) socket ?. close (); 105}); 106 107function publish ( topic :Topic | undefined , value :unknown ) { 108if ( topic === undefined ) return ; 109topic . value = { current :value }; 110for ( const listener of topic . listeners ) listener ( value ); 111} 112 113/** 114* Follow a replicated json value: `listener` gets its snapshot once there is one, and then each revision, whose 115* unchanged parts keep their identity. Returns the release, for when its view goes away. 116*/ 117export function watch < T > ( name :string , listener :( value :T ) => void ) :() => void { 118if ( ! topics . has ( name )) { 119topics . set ( name , { listeners :new Set () }); 120send ({ t :"sub" , topic :name }); 121} 122const topic = topics . get ( name ) ! ; 123const own = ( value :unknown ) => listener ( value as T ); 124topic . listeners . add ( own ); 125if ( topic . value ) own ( topic . value . current ); 126return () => { 127if ( ! topic . listeners . delete ( own ) || topic . listeners . size > 0 ) return ; 128topics . delete ( name ); 129send ({ t :"unsub" , topic :name }); 130}; 131}