char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
c582293
main
1import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context" ; 2import type { ConversationId , Cursor , EntryId , EntryRecord } from "@earendil-works/pi-durable" ; 3import { type Berry , BerryError , Conversations , EventEntry , headOf } from "./berry.ts" ; 4 5/** One entry of a conversation's branches, as a tree of them shows it. */ 6export type TreeEntry = { 7id :number ; 8/** 9* The entry it follows: the one before it in its branch, or for a branch's first entry, the entry the branch 10* continues from. 11*/ 12parent :number | null ; 13/** The branch it was written in, or for what a fork inherited, the branch it began as. */ 14branch :number ; 15kind :string ; 16/** 17* A user's message in full, to be edited again; another message's text, clipped to a line's worth; an event's 18* extension. 19*/ 20text :string ; 21/** When its message was written; entries without one, like events, have none. */ 22time ?:number ; 23imageCount ?:number ; 24/** The tools an assistant message called, or the tool a result answers. */ 25tools ?:string []; 26error ?:true ; 27}; 28 29export type Branch = { 30id :number ; 31created :number ; 32/** Its newest entry, which may be its parent's when it has none of its own. */ 33tip :number | null ; 34}; 35 36/** A conversation's branches, and which one it continues on. */ 37export type ConversationTree = { head :number ; branches :Branch []; entries :TreeEntry [] }; 38 39const PREVIEW = 240 ; 40 41/** `text` on one line, cut to `length` characters. */ 42export function clip ( length :number , text :string ) :string { 43const flat = text . replace ( / \s+ / g , " " ). trim (); 44return flat . length > length ?` ${ flat . slice ( 0 , length - 1 )} …` :flat ; 45} 46 47export const textOf = ( content :string | readonly { type :string ; text ?:string }[]) => 48typeof content === "string" 49 ?content 50 :content . flatMap (( block ) => 51block . type === "text" ?[ block . text ?? "" ] :block . type === "image" ?[ "[image]" ] :[] 52). join ( " " ); 53 54/** A user's message as they wrote it, without its images. */ 55export const userText = ( content :string | readonly { type :string ; text ?:string }[]) => 56typeof content === "string" 57 ?content 58 :content . flatMap (( block ) => ( block . type === "text" ?[ block . text ?? "" ] :[])). join ( "\n" ); 59 60function preview ( 61entry :EntryRecord , 62) :Pick < TreeEntry , "text" | "tools" | "error" | "imageCount" | "time" > { 63const message = entry . model ?.[ 0 ]; 64const written = message ?. timestamp === undefined ?{} :{ time :message . timestamp }; 65if ( message ?. role === "assistant" ) { 66const tools = message . content . flatMap (( 67block , 68) => ( block . type === "toolCall" ?[ block . name ] :[])); 69const text = clip ( PREVIEW , textOf ( message . content )) || 70( message . stopReason === "aborted" ?"(aborted)" :clip ( PREVIEW , message . errorMessage ?? "" )); 71return { 72 ...written , 73 text, 74 ...( tools . length > 0 ?{ tools} :{}), 75 ...( message . stopReason === "error" ?{ error :true } :{}), 76}; 77} 78if ( message ?. role === "toolResult" ) { 79return { 80 ...written , 81text :clip ( PREVIEW , textOf ( message . content )), 82tools :[ message . toolName ], 83 ...( message . isError ?{ error :true } :{}), 84}; 85} 86if ( message ?. role === "user" ) { 87const imageCount = typeof message . content === "string" 88 ?0 89 :message . content . filter (( block ) => block . type === "image" ). length ; 90return { 91 ...written , 92text :entry . kind === "pi.user" 93 ?userText ( message . content ) 94 :clip ( PREVIEW , textOf ( message . content )), 95 ...( imageCount > 0 ?{ imageCount} :{}), 96}; 97} 98if ( EventEntry . is ( entry )) return { ...written , text :entry . data . ext }; 99return { ...written , text :"" }; 100} 101 102/** 103* Every branch of `tid` as one tree of entries. Entry IDs are session-wide and a branch continues from one entry of 104* another, so the branches' own entries join into one. 105*/ 106export async function conversationTree ( berry :Berry , tid :string ) :Promise < ConversationTree > { 107const info = ( await berry . harness . snapshot ( Conversations , context ))?.[ tid ]; 108if ( info === undefined ) throw new BerryError ( `No conversation ${ tid } ` ); 109const all = [{ id :info . id , at :null , created :info . created }, ...( info . branches ?? [])]; 110 111const branches = await Promise . all ( all . map ( async ({ id, at, created}) => { 112const conversation = ( await berry . harness . conversation ( id as ConversationId , context )) ! ; 113// a branch's own entries are those after the entry it continues from; the first has all of its history, what 114// a fork inherited included 115const query = { order :"ascending" , minEntryId :(( at ?? 0 ) + 1 ) as EntryId } as const ; 116const entries :TreeEntry [] = []; 117let previous :number | null = at ; 118let cursor :Cursor | undefined ; 119do { 120const page = await conversation . entries ( query , 256 , cursor , context ); 121for ( const entry of page . items ) { 122entries . push ({ 123id :entry . id , 124parent :previous , 125branch :id , 126kind :entry . kind , 127 ...preview ( entry ), 128}); 129previous = entry . id ; 130} 131cursor = page . next ; 132} while ( cursor !== undefined ); 133return { branch :{ id, created, tip :previous }, entries}; 134})); 135return { 136head :headOf ( info ), 137branches :branches . map (({ branch}) => branch ), 138entries :branches . flatMap (({ entries}) => entries ). sort (( a , b ) => a . id - b . id ), 139}; 140}