okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0a3eddd
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"slices" 7"strings" 8"time" 9) 10 11// transcriptState buffers streamed entries and tracks the turn in progress. 12type transcriptState struct { 13// Live thoughts are timed here; replayed thoughts carry daemon elapsed time. 14thinkingSince time.Time 15// Summarized thinking arrives after it is written, so its start is the last 16// live event other than thinking rather than its first received delta. 17lastEvent time.Time 18turn * openTurn 19activeKind ActiveStreamKind 20// Only the current update path appends to activeBuffer. Retained model 21// copies read activeText, whose bytes stay unchanged after each append. 22activeBuffer * strings.Builder 23activeText string 24thoughtMs int64 25activeMessageID string 26liveIDs map [string ]bool 27pendingCanonical * BoundedHistory 28} 29 30func newTranscriptState ()transcriptState { 31return transcriptState {} 32} 33 34func (t * transcriptState )resetStream () { 35t .activeKind ,t .activeText = StreamKindNone ,"" 36t .activeBuffer = nil 37t .activeMessageID = "" 38t .liveIDs = nil 39t .pendingCanonical = nil 40} 41 42func (t transcriptState )activeEntryKind ()EntryKind { 43if t .activeKind == StreamKindThinking { 44return EntryThinking 45 } 46return EntryAssistant 47} 48 49func (t * transcriptState )settle (agentName string ) []HistoryEntry { 50var entries []HistoryEntry 51if t .activeKind != StreamKindNone && t .activeText != "" { 52entry := HistoryEntry { 53MessageID :t .activeMessageID , 54Kind :t .activeEntryKind (), 55Speaker :agentName , 56Text :strings .Clone (t .activeText ), 57Timestamp :time .Now ().UnixMilli (), 58 } 59if entry .Kind == EntryThinking { 60entry .ElapsedMs = t .thoughtMs 61if ! t .thinkingSince .IsZero () { 62entry .ElapsedMs = time .Since (t .thinkingSince ).Milliseconds () 63 } 64 } 65entries = append (entries ,entry ) 66 } 67t .activeKind ,t .activeText = StreamKindNone ,"" 68t .activeBuffer = nil 69t .activeMessageID = "" 70t .thinkingSince ,t .thoughtMs = time.Time {},0 71return entries 72} 73 74func (t * transcriptState )streamDelta (kind ActiveStreamKind ,text ,agentName ,messageID string ) []HistoryEntry { 75if text == "" { 76return nil 77 } 78t .turnIsLive () 79t .turn .answered = true 80var entries []HistoryEntry 81if t .activeKind != kind || t .activeMessageID != messageID { 82entries = t .settle (agentName ) 83t .activeKind = kind 84t .activeMessageID = messageID 85 } 86if t .activeBuffer == nil { 87t .activeBuffer = new (strings.Builder ) 88 } 89if messageID != "" { 90if t .liveIDs == nil { 91t .liveIDs = map [string ]bool {} 92 } 93t .liveIDs [messageID ]= true 94 } 95t .activeBuffer .WriteString (text ) 96t .activeText = t .activeBuffer .String () 97if len (t .activeText )> MaxLiveStreamBytes { 98// One long thought splits into entries, each timed from its own start. 99watched := ! t .thinkingSince .IsZero () 100entries = append (entries ,t .settle (agentName )... ) 101t .activeKind = kind 102t .activeMessageID = messageID 103if watched { 104t .thinkingSince = time .Now () 105 } 106 } 107return entries 108} 109 110// apply returns newly settled entries. The history owner stamps committed rows. 111func (t * transcriptState )apply (evt daemon.StreamEvent ,agentName string ) []HistoryEntry { 112// Empty provider records and agent progress are observations, not replies. 113if evt .Type == daemon .EventMessage && evt .Text == "" || evt .Type == daemon .EventNote && evt .Source == "agent" { 114return nil 115 } 116if evt .EntryID != "" && ! evt .Replayed && len (t .liveIDs )> 0 && (evt .Type == daemon .EventMessage || evt .Type == daemon .EventThinking ) { 117if t .pendingCanonical == nil { 118t .pendingCanonical = NewBoundedHistory (500 ,2 * 1024 * 1024 ) 119 } 120kind := EntryAssistant 121if evt .Type == daemon .EventThinking { 122kind = EntryThinking 123 } 124entry := HistoryEntry {ID :evt .EntryID ,Seq :evt .Position ,Kind :kind ,Speaker :agentName ,Text :evt .Text ,ElapsedMs :evt .ElapsedMs } 125if evt .Timestamp != nil { 126entry .Timestamp = * evt .Timestamp 127 } 128if ! t .pendingCanonical .Replace (entry ) { 129t .pendingCanonical .Append (entry ) 130 } 131return nil 132 } 133thoughtStart := cmp .Or (t .lastEvent ,time .Now ()) 134if ! evt .Replayed && evt .Type != daemon .EventThinking { 135t .lastEvent = time .Now () 136 } 137var entries []HistoryEntry 138switch evt .Type { 139case daemon .EventReset : 140t .resetStream () 141t .thinkingSince ,t .thoughtMs = time.Time {},0 142t .turn = nil 143case daemon .EventRetry : 144t .resetStream () 145case daemon .EventCommitted : 146if evt .ReplacesAllLive || slices .Contains (evt .ReplacesLiveIDs ,t .activeMessageID ) { 147t .activeKind = StreamKindNone 148t .activeText = "" 149t .activeBuffer = nil 150t .activeMessageID = "" 151t .thinkingSince = time.Time {} 152 } 153if evt .ReplacesAllLive { 154t .liveIDs = nil 155 } 156for _ ,id := range evt .ReplacesLiveIDs { 157delete (t .liveIDs ,id ) 158 } 159if t .pendingCanonical != nil { 160entries = append (entries ,t .pendingCanonical .Entries ()... ) 161t .pendingCanonical = nil 162 } 163case daemon .EventUser : 164entries = t .settle (agentName ) 165speaker := inputSpeaker (evt ) 166var ts int64 167if evt .Timestamp != nil && * evt .Timestamp > 0 { 168ts = * evt .Timestamp 169 } 170// Your message closes the previous turn. Other sources open a turn 171// only when none is in flight. 172opens := t .turn == nil || evt .Source == "" || evt .Source == "chat" 173if opens { 174entries = append (entries ,t .closeTurn (false )... ) 175 } 176entries = append (entries ,HistoryEntry { 177Kind :EntryUser ,ID :evt .EntryID ,Seq :evt .Position ,Source :evt .Source ,MailKind :evt .MailKind ,SenderSessionID :evt .SenderSessionID ,Speaker :speaker ,Text :evt .Text , 178Timestamp :ts , 179 }) 180if opens { 181t .turn = newOpenTurn (ts ) 182 } 183case daemon .EventText : 184entries = t .streamDelta (StreamKindText ,evt .Text ,agentName ,evt .MessageID ) 185case daemon .EventThinking : 186if evt .EntryID != "" { 187entries = t .settle (agentName ) 188entries = append (entries ,HistoryEntry {ID :evt .EntryID ,Seq :evt .Position ,Kind :EntryThinking ,Speaker :agentName ,Text :evt .Text ,ElapsedMs :evt .ElapsedMs }) 189break 190 } 191if t .activeKind != StreamKindThinking || t .activeMessageID != evt .MessageID { 192entries = t .settle (agentName ) 193t .activeKind = StreamKindThinking 194t .activeMessageID = evt .MessageID 195if ! evt .Replayed { 196t .thinkingSince = thoughtStart 197 } 198 } 199if evt .ElapsedObserved && ! evt .Replayed { 200t .thinkingSince = time.Time {} 201t .thoughtMs = evt .ElapsedMs 202 }else { 203t .thoughtMs += evt .ElapsedMs 204 } 205entries = append (entries ,t .streamDelta (StreamKindThinking ,evt .Text ,agentName ,evt .MessageID )... ) 206case daemon .EventToolProgress : 207if ! evt .Replayed { 208t .turnIsLive () 209if evt .Progress != nil { 210entries = t .settle (agentName ) 211 } 212 } 213case daemon .EventTool : 214entries = t .settle (agentName ) 215entries = append (entries ,HistoryEntry { 216Kind :EntryTool ,ID :evt .EntryID ,Seq :evt .Position ,ToolName :evt .ToolName ,ToolArgs :evt .ToolArgs , 217ToolResult :evt .ToolResult ,ToolTrace :evt .ToolTrace , 218Timestamp :time .Now ().UnixMilli (), 219 }) 220if t .turn == nil { 221t .turn = newOpenTurn (0 ) 222 } 223t .turn .tools ++ 224t .turn .touch (evt .Timestamp ) 225case daemon .EventMessage : 226entries = t .settle (agentName ) 227var ts int64 228if evt .Timestamp != nil && * evt .Timestamp > 0 { 229ts = * evt .Timestamp 230 } 231entries = append (entries ,HistoryEntry {Kind :EntryAssistant ,ID :evt .EntryID ,Seq :evt .Position ,Speaker :agentName ,Text :evt .Text ,Timestamp :ts }) 232if t .turn == nil { 233t .turn = newOpenTurn (ts ) 234 } 235t .turn .answered = true 236t .turn .touch (evt .Timestamp ) 237case daemon .EventNote : 238entries = t .settle (agentName ) 239entries = append (entries ,HistoryEntry {Kind :EntryNote ,ID :evt .EntryID ,Seq :evt .Position ,Text :evt .Text ,Timestamp :time .Now ().UnixMilli ()}) 240case daemon .EventError : 241entries = t .settle (agentName ) 242entries = append (entries ,HistoryEntry {Kind :EntryError ,Text :evt .Text ,Timestamp :time .Now ().UnixMilli ()}) 243if t .turn != nil { 244t .turn .failed = true 245entries = append (entries ,t .closeTurn (false )... ) 246 } 247case daemon .EventCompacted : 248entries = t .settle (agentName ) 249entries = append (entries ,HistoryEntry { 250Kind :EntryCompacted ,ID :evt .EntryID ,Seq :evt .Position ,Text :evt .Summary ,Evicted :evt .Evicted , 251Strategy :evt .Strategy ,Timestamp :time .Now ().UnixMilli (), 252 }) 253case daemon .EventUsage : 254entries = t .settle (agentName ) 255if t .turn != nil { 256t .turn .measure (evt .Usage ) 257 } 258case daemon .EventTurnCompleted : 259entries = t .settle (agentName ) 260if t .turn != nil { 261t .turn .failed = t .turn .failed || evt .Source == "failed" 262entries = append (entries ,t .closeTurn (evt .Source == "interrupted" )... ) 263 } 264case daemon .EventInterrupted : 265entries = t .settle (agentName ) 266if t .turn != nil { 267entries = append (entries ,t .closeTurn (true )... ) 268 }else { 269entries = append (entries ,HistoryEntry {Kind :EntryNote ,Text :"stopped by you" ,Timestamp :time .Now ().UnixMilli ()}) 270 } 271 } 272return entries 273} 274 275// A page boundary settles buffered text but does not finish the turn. 276// History retention is applied by the caller. 277func replayTranscript (events []daemon.StreamEvent ,agentName string ) []HistoryEntry { 278state := newTranscriptState () 279var entries []HistoryEntry 280for _ ,evt := range events { 281entries = append (entries ,state .apply (evt ,agentName )... ) 282switch evt .Type { 283case daemon .EventReset : 284entries = nil 285case daemon .EventCommitted : 286for i := len (entries )- 1 ;i >= 0 && entries [i ].Seq == 0 ;i -- { 287if entries [i ].MessageID == "" { 288entries [i ].Seq = evt .Seq 289 } 290 } 291 } 292 } 293return append (entries ,state .settle (agentName )... ) 294} 295 296// openTurn follows the turn in flight until its signoff. 297type openTurn struct { 298// Unix timestamps in milliseconds. 299start ,last int64 300tools int 301// A live turn ends now, rather than at its last replayed event. 302live bool 303failed bool 304// answered is whether the agent wrote anything; a turn opened by a note 305// it never answered has nothing to sign off. 306answered bool 307// tokensPerSecond is the rate of the latest call that reported its timing. 308// A usage record can arrive again, so it replaces the rate, never adds to it. 309tokensPerSecond float64 310} 311 312func newOpenTurn (ts int64 )* openTurn { 313if ts == 0 { 314ts = time .Now ().UnixMilli () 315 } 316return & openTurn {start :ts ,last :ts } 317} 318 319func (t * openTurn )touch (ts * int64 ) { 320if ts != nil && * ts > t .last { 321t .last = * ts 322 } 323} 324 325// measure takes the rate of a usage record that carries one. 326func (t * openTurn )measure (usage * daemon.Usage ) { 327if usage != nil && usage .TokensPerSecond != nil && * usage .TokensPerSecond > 0 { 328t .tokensPerSecond = * usage .TokensPerSecond 329 } 330} 331 332// An idle status racing the first event must not sign a turn off early. 333func (t * openTurn )begun ()bool { 334return t .live || t .tools > 0 || t .last > t .start 335} 336 337func (t * transcriptState )turnIsLive () { 338if t .turn == nil { 339t .turn = newOpenTurn (0 ) 340 } 341t .turn .live = true 342} 343 344func (t * transcriptState )closeTurn (stopped bool ) []HistoryEntry { 345turn := t .turn 346if turn == nil { 347return nil 348 } 349t .turn ,t .lastEvent = nil , time.Time {} 350if ! turn .answered && turn .tools == 0 && ! turn .failed && ! stopped { 351return nil 352 } 353end := turn .last 354if turn .live { 355end = max (end ,time .Now ().UnixMilli ()) 356 } 357elapsed := end - turn .start 358return []HistoryEntry {{ 359Kind :EntryTurnEnd ,Mood :outcome (turn .failed ,stopped ,elapsed ), 360ElapsedMs :elapsed ,Tools :turn .tools ,Timestamp :end , 361TokensPerSecond :turn .tokensPerSecond , 362 }} 363} 364 365func inputSpeaker (evt daemon.StreamEvent )string { 366if evt .Source == "mail" { 367return cmp .Or (evt .Speaker ,"Agent" ) 368 } 369if evt .Source == "" || evt .Source == "chat" { 370return "You" 371 } 372return evt .Source 373}