char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
2d39747
main
1// What the list of conversations shows of each beyond its index entry: whether it is answering, and how it began. 2 3import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context" ; 4import { homedir } from "node:os" ; 5import { type Binding , ENVIRONMENT } from "./environment.ts" ; 6import { EventEntry } from "./entries.ts" ; 7import { imageSummary } from "@berry/api/images.ts" ; 8import { 9type ConversationId , 10type Cursor , 11type EntryId , 12InboxDoc , 13LiveDoc , 14} from "@earendil-works/pi-durable" ; 15import { type Berry , type ConversationInfo , Conversations , headOf } from "./berry.ts" ; 16import { type Feed , published } from "./pubsub.ts" ; 17import { clip , textOf , userText } from "./tree.ts" ; 18import { SettledDoc , settledThrough } from "./settled.ts" ; 19 20export type Summary = { 21/** Whether it is generating or running tools. */ 22busy :boolean ; 23/** Its first message, on one line; empty before there is one. */ 24preview :string ; 25/** Its newest answer, on one line; empty before one has arrived. */ 26reply :string ; 27/** A settled turn whose newest message has not been viewed. */ 28unread :boolean ; 29/** Its newest user message or return of control to the user, or creation time before either. */ 30updated :number ; 31environment :string ; 32cwd :string ; 33}; 34 35/** Every listed conversation's summary as one feed, kept up as commits land. */ 36export async function summaries ( berry :Berry ) :Promise <{ feed () :Feed ; stop () :void }> { 37const { harness} = berry ; 38const value = published < Record < string , Summary >>({}); 39// a branch's first message never changes once it has one 40const previews = new Map < ConversationId , string >(); 41 42async function previewOf ( id :ConversationId ) :Promise < string > { 43const known = previews . get ( id ); 44if ( known !== undefined ) return known ; 45const conversation = await harness . conversation ( id , context ); 46let cursor :Cursor | undefined ; 47do { 48const page = await conversation ! . entries ({ order :"ascending" }, 16 , cursor , context ); 49const first = page . items . find (( entry ) => entry . kind === "pi.user" )?. model ?.[ 0 ]; 50if ( first ?. role === "user" ) { 51const text = clip ( 52160 , 53[ userText ( first . content ), imageSummary ( first . content )]. filter ( Boolean ). join ( " " ), 54); 55previews . set ( id , text ); 56return text ; 57} 58cursor = page . next ; 59} while ( cursor !== undefined ); 60return "" ; 61} 62 63async function busyOf ( head :ConversationId ) { 64return ( await harness . snapshot ( LiveDoc , head , context ))?. run !== undefined || 65(( await harness . snapshot ( InboxDoc , head , context ))?. items . length ?? 0 ) > 0 ; 66} 67 68async function summarize ( info :ConversationInfo ) :Promise < Summary > { 69await settledThrough ( harness ); 70const head = headOf ( info ); 71const settled = await harness . snapshot ( SettledDoc , head , context ); 72const busy = await busyOf ( head ); 73let unread = false ; 74let updated = settled ?. updated ?? info . created ; 75const conversation = ( await harness . conversation ( head , context )) ! ; 76// scanned on its own, since the loop below stops as soon as it has what it needs of the branch 77const replyOf = async () => { 78const page = await conversation . entries ({ order :"descending" }, 16 , undefined , context ); 79for ( const entry of page . items ) { 80const message = entry . model ?.[ 0 ]; 81if ( 82entry . kind === "pi.assistant" && message ?. role === "assistant" && 83message . stopReason !== "toolUse" 84) { 85return clip ( 160 , textOf ( message . content )); 86} 87} 88return "" ; 89}; 90let cursor :Cursor | undefined ; 91let boundary :EntryId | undefined ; 92let found = false ; 93let timeFound = settled !== undefined && head === info . id && info . parent === undefined ; 94let userTime :number | undefined ; 95let userFound = false ; 96do { 97const page = await conversation . entries ( 98{ order :"descending" }, 9916 , 100cursor , 101context , 102); 103for ( const entry of page . items ) { 104// match the active transcript: a reset must not resurrect unread older messages 105boundary ??= entry . head ; 106const message = entry . model ?.[ 0 ]; 107if ( 108! timeFound && entry . kind === "pi.assistant" && message ?. role === "assistant" && 109message . stopReason !== "toolUse" 110) { 111if ( settled === undefined || entry . conversationId !== head ) { 112// a fork at the answer precedes the separate commit that records its completion 113const task = entry . byTaskId === undefined 114 ?undefined 115 :await harness . getTask ( entry . byTaskId , context ); 116updated = Math . max ( settled ?. updated ?? 0 , task ?. endedAt ?? message . timestamp ); 117} 118timeFound = true ; 119} 120if ( ! userFound && entry . kind === "pi.user" && message ?. role === "user" ) { 121userTime = message . timestamp ; 122userFound = true ; 123} 124if ( ! found && ( entry . kind === "pi.assistant" || entry . kind === "pi.user" )) { 125unread = ! busy && ( boundary === undefined || entry . id >= boundary ) && 126entry . id > ( info . readThrough ?? 0 ); 127found = true ; 128} 129if ( found && timeFound && userFound ) break ; 130} 131cursor = page . next ; 132} while (( ! found || ! timeFound || ! userFound ) && cursor !== undefined ); 133if ( userTime !== undefined ) updated = Math . max ( updated , userTime ); 134const binding = await berry . state ( await berry . tidOf ( head ), ENVIRONMENT ) as Binding ; 135return { 136 busy, 137preview :await previewOf ( head ), 138reply :await replyOf (), 139 unread, 140 updated, 141environment :binding . environment , 142cwd :binding . cwd ?? homedir (), 143}; 144} 145 146// what changed since the last refresh: some conversations' ids, or everything 147let dirty :Set < ConversationId > | "all" = "all" ; 148// each summary's index entry as last summarized; an index commit restales only those it changed, as a read receipt 149const summarized = new Map < string , string >(); 150let reindexed = false ; 151let refreshing = false ; 152let stopped = false ; 153async function refresh () { 154if ( refreshing || stopped ) return ; 155refreshing = true ; 156try { 157while ( ! stopped && ( reindexed || dirty === "all" || dirty . size > 0 )) { 158const changed = dirty ; 159dirty = new Set (); 160reindexed = false ; 161const index = ( await harness . snapshot ( Conversations , context )) ?? {}; 162// an owned conversation shows while it is busy, then leaves the feed: check only 163// whether it is working before doing its full summarize, so idle history cannot pile up 164const listed :[ string , ConversationInfo ][] = []; 165for ( const [ tid , info ] of Object . entries ( index )) { 166const head = headOf ( info ); 167const touched = value . value [ tid ] !== undefined || changed === "all" || reindexed || 168changed . has ( head ); 169if ( info . owner === undefined || ( touched && await busyOf ( head ))) listed . push ([ tid , info ]); 170} 171const next :Record < string , Summary > = {}; 172for ( const [ tid , info ] of listed ) { 173const previous = value . value [ tid ]; 174const entry = JSON . stringify ( info ); 175const stale = changed === "all" || previous === undefined || 176summarized . get ( tid ) !== entry || changed . has ( headOf ( info )); 177next [ tid ] = stale ?await summarize ( info ) :previous ; 178summarized . set ( tid , entry ); 179} 180for ( const tid of summarized . keys ()) if ( ! ( tid in next )) summarized . delete ( tid ); 181value . set ( next ); 182} 183} catch ( error ) { 184if ( ! stopped ) console . error ( "[berry] summaries:" , error ); 185} finally { 186refreshing = false ; 187} 188} 189 190const live = LiveDoc . definition . kind ; 191const inbox = InboxDoc . definition . kind ; 192const settled = SettledDoc . definition . kind ; 193const index = Conversations . definition . kind ; 194// commit listeners must not call Session APIs inline 195const unsubscribe = harness . subscribeCommits (( publication ) => { 196let touched = false ; 197for ( const change of publication . changes ) { 198if ( change . type === "document" && change . record . kind === index ) reindexed = true ; 199else if ( 200change . type === "document" && 201( change . record . kind === live || change . record . kind === inbox || 202change . record . kind === settled ) && 203change . conversationId 204) { 205if ( dirty !== "all" ) dirty . add ( change . conversationId ); 206} else if ( 207change . type === "entry" && 208( change . value . kind === "pi.user" || change . value . kind === "pi.assistant" || 209change . value . head !== undefined || 210( EventEntry . is ( change . value ) && 211( change . value . data . ext === ENVIRONMENT || 212(( change . value . data . ext === "code" || change . value . data . ext === "coding" ) && 213typeof change . value . data . event === "object" && change . value . data . event !== null && 214! Array . isArray ( change . value . data . event ) && change . value . data . event . type === "cd" )))) 215) { 216if ( dirty !== "all" ) dirty . add ( change . value . conversationId ); 217} else continue ; 218touched = true ; 219} 220if ( touched ) queueMicrotask (() => void refresh ()); 221}); 222await refresh (); 223return { 224feed :() => value . feed (), 225stop () { 226stopped = true ; 227unsubscribe (); 228}, 229}; 230}