char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
c823fd4
main
1/** Announcing a finished turn, so the reader hears about it from the next tab over. */ 2 3const KEY = "berry.notifications" ; 4 5/** Whether this browser announces finished turns; permission belongs to the browser, so the choice does too. */ 6export function notificationsOn () :boolean { 7return localStorage . getItem ( KEY ) === "on" ; 8} 9 10/** Whether this page can announce at all: browsers keep notifications to secure contexts. */ 11export const notificationsAvailable = () => typeof Notification !== "undefined" ; 12 13/** Switch announcements on or off, asking for permission first; resolves whether they are on. */ 14export async function setNotifications ( on :boolean ) :Promise < boolean > { 15if ( ! on ) { 16localStorage . removeItem ( KEY ); 17return false ; 18} 19if ( ! notificationsAvailable ()) return false ; 20// browsers only grant permission from a gesture, so the settings switch has to be the ask 21const permission = Notification . permission === "default" 22 ?await Notification . requestPermission () 23 :Notification . permission ; 24if ( permission !== "granted" ) return false ; 25localStorage . setItem ( KEY , "on" ); 26return true ; 27} 28 29export type Notice = { tid :string ; title :string ; body :string }; 30 31/** What announcing needs of a conversation's summary. */ 32type Settled = { busy :boolean ; unread :boolean ; reply ?:string }; 33 34/** 35* Follow a summaries feed and announce the conversations that stopped answering since the last one, except whichever 36* one is on screen in a window someone is looking at. 37*/ 38export function announcements ( options :{ 39/** The conversation in view. */ 40active :() => string ; 41/** What to call a conversation. */ 42name :( tid :string ) => string ; 43/** Where announcements go: the browser, unless a test says otherwise. */ 44speak ?:( notice :Notice ) => void ; 45}) :( summaries :Record < string , Settled >) => void { 46const speak = options . speak ?? speakThroughBrowser ; 47let previous :Record < string , Settled > = {}; 48return ( next ) => { 49const before = previous ; 50previous = next ; 51if ( ! notificationsOn ()) return ; 52for ( const [ tid , summary ] of Object . entries ( next )) { 53if ( before [ tid ]?. busy !== true || summary . busy || ! summary . unread ) continue ; 54// an open conversation still deserves a notice once the reader looks elsewhere 55if ( 56document . visibilityState === "visible" && document . hasFocus () && options . active () === tid 57) { 58continue ; 59} 60speak ({ tid, title :options . name ( tid ), body :summary . reply || "finished answering" }); 61} 62}; 63} 64 65function speakThroughBrowser ({ tid, title, body} :Notice ) :void { 66if ( ! notificationsAvailable () || Notification . permission !== "granted" ) return ; 67const notice = new Notification ( title , { body, tag :tid }); 68notice . onclick = () => { 69document . defaultView ?. focus (); 70location . hash = `#/ ${ tid } ` ; 71notice . close (); 72}; 73}