okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
7fd8cf5
main
1package daemon 2 3import ( 4"context" 5"encoding/json" 6"errors" 7"io" 8"net/http" 9"net/url" 10"slices" 11"strings" 12"unicode/utf8" 13 14"albedo/cli/internal/daemon/protocol" 15) 16 17type HistoryPage struct { 18Events []StreamEvent 19Before int64 20More bool 21} 22 23func (c * ChatClient )History (ctx context.Context ,before int64 ,rows int ) (* HistoryPage ,error ) { 24params := protocol.GetHistoryParams {Limit :new (int64 (min (200 ,max (1 ,rows ))))} 25if before > 0 { 26params .Before = & before 27 } 28wire ,err := readHistory (ctx ,c .conn ,c .agentID ,params ) 29if err != nil { 30return nil ,err 31 } 32page := & HistoryPage {More :wire .Older != nil } 33if len (wire .Items )> 0 { 34page .Before = wire .Items [0 ].Position 35 } 36wire .Items ,err = hydrateHistory (ctx ,c .conn ,c .agentID ,wire .Items ) 37if err != nil { 38return nil ,err 39 } 40page .Events ,err = historyEvents (wire .Items ) 41return page ,err 42} 43 44func readHistory (ctx context.Context ,conn * Connection ,session string ,params protocol.GetHistoryParams ) (protocol.HistoryPage ,error ) { 45var wire protocol.HistoryPage 46err := executeRead (ctx ,conn ,operation {Name :"read history" ,BuildRequest :func (base string ,body io.Reader ) (* http.Request ,error ) { 47return protocol .NewGetHistoryRequest (base ,session ,& params ) 48 },Policy :readRecovery },func (data []byte )error { 49if err := decodeRequired (data ,& wire );err != nil { 50return err 51 } 52if wire .Items == nil || len (wire .Items )> 200 || wire .HighWater < 0 { 53return fieldError ("history" ) 54 } 55return nil 56 }) 57return wire ,err 58} 59 60func historyEvents (entries []protocol.HistoryEntry ) ([]StreamEvent ,error ) { 61events := []StreamEvent {} 62arguments := map [string ][]json.RawMessage {} 63for _ ,entry := range entries { 64if entry .Kind == "tool_call" && entry .Tool != nil { 65arguments [entry .Tool .ToolCallID ]= entry .Content 66continue 67 } 68if entry .Kind == "tool_result" && entry .Tool != nil { 69entry .Content = append (arguments [entry .Tool .ToolCallID ],entry .Content ... ) 70 } 71converted ,err := historyEntryEvents (entry ) 72if err != nil { 73return nil ,err 74 } 75for i := range converted { 76converted [i ].Replayed = true 77 } 78events = append (events ,converted ... ) 79 } 80return events ,nil 81} 82func historyEntryEvents (entry protocol.HistoryEntry ) ([]StreamEvent ,error ) { 83if entry .Kind == "continuation" { 84return nil ,nil 85 } 86if entry .ID == "" || entry .Position < 1 || entry .Content == nil { 87return nil ,fieldError ("history entry" ) 88 } 89event := StreamEvent {EntryID :entry .ID ,Position :entry .Position ,TurnType :value (entry .TurnType ),Timestamp :timestampMilliseconds (value (entry .CreatedAt )),TurnID :value (entry .TurnID ),OperationID :value (entry .InputID ),Replayed :true } 90var text strings.Builder 91for _ ,raw := range entry .Content { 92var part struct { 93Kind string `json:"kind"` 94Text string `json:"text"` 95Field string `json:"field"` 96Value json.RawMessage `json:"value"` 97Image * protocol.ImageMetadata `json:"image"` 98Trace json.RawMessage `json:"trace"` 99Reference protocol.ContentReference `json:"reference"` 100 } 101if err := json .Unmarshal (raw ,& part );err != nil { 102return nil ,err 103 } 104switch part .Kind { 105case "text" : 106text .WriteString (part .Text ) 107case "json" : 108switch part .Field { 109case "arguments" : 110arguments ,_ := dynamicValue (part .Value ) 111event .ToolArgs ,_ = arguments .(map [string ]any ) 112case "result" : 113var str string 114if json .Unmarshal (part .Value ,& str )== nil { 115event .ToolResult = str 116 }else { 117event .ToolResult = string (part .Value ) 118 } 119case "elapsed_ms" : 120_ = json .Unmarshal (part .Value ,& event .ElapsedMs ) 121case "origin" : 122if err := json .Unmarshal (part .Value ,& event .Source );err != nil { 123return nil ,fieldError ("history origin" ) 124 } 125case "strategy" : 126_ = json .Unmarshal (part .Value ,& event .Strategy ) 127case "evicted_entries" : 128_ = json .Unmarshal (part .Value ,& event .Evicted ) 129 } 130case "image" : 131if part .Image != nil { 132event .Images = append (event .Images ,ImageMetadata {MimeType :ImageMimeType (part .Image .MimeType ),Width :int (part .Image .Width ),Height :int (part .Image .Height ),Bytes :int (part .Image .OriginalBytes )}) 133 } 134case "trace" : 135event .ToolTrace = ParseToolTrace (part .Trace ) 136case "reference" : 137if ! strings .HasPrefix (part .Reference .Field ,"image-" ) { 138return nil ,fieldError ("unresolved history content" ) 139 } 140default : 141return nil ,fieldError ("history content kind" ) 142 } 143 } 144event .Text = text .String () 145if entry .!= nil { 146event .Speaker = entry .SenderLabel 147event .MailKind = entry .Kind 148event .SenderSessionID = value (entry .SenderSessionID ) 149 } 150switch entry .Kind { 151case "user" : 152event .Type = EventUser 153switch event .TurnType { 154case "agent" : 155event .Source = "mail" 156case "webhook" : 157event .Source = "webhook" 158case "scheduled" : 159event .Source = "job" 160case "continue" : 161event .Source = "continue" 162default : 163event .Source = "chat" 164 } 165case "assistant" : 166event .Type = EventMessage 167case "thinking" : 168event .Type = EventThinking 169if entry .ThinkingDurationMs != nil { 170event .ElapsedMs = * entry .ThinkingDurationMs 171event .ElapsedObserved = true 172 } 173case "tool_call" : 174return nil ,nil 175case "tool_result" : 176event .Type = EventTool 177if entry .Tool == nil { 178return nil ,fieldError ("history tool" ) 179 } 180event .ToolName = entry .Tool .Name 181event .ProgressCallID = value (entry .Tool .ProgressCallID ) 182if event .ToolResult == "" { 183event .ToolResult = event .Text 184 } 185event .Text = "" 186case "note" ,"image_fit" : 187event .Type = EventNote 188case "continuation" : 189return nil ,nil 190case "compaction" : 191event .Type = EventCompacted 192event .Summary = event .Text 193default : 194return nil ,fieldError ("history entry kind" ) 195 } 196return []StreamEvent {event , {Type :EventCommitted ,Seq :entry .Position ,Replayed :true }},nil 197} 198 199// ReadEntryContent reconstructs complete stored fields with verified byte offsets. 200func ReadEntryContent (ctx context.Context ,conn * Connection ,session ,entry string ) (map [string ][]byte ,error ) { 201fields := map [string ][]byte {} 202complete := map [string ]bool {} 203params := protocol.GetHistoryContentParams {} 204total := 0 205err := walkPages (func (next * string ) (protocol.EntryContentPage ,* string ,error ) { 206params .Next = next 207var page protocol.EntryContentPage 208pageErr := executeRead (ctx ,conn ,operation {Name :"read history content" ,BuildRequest :func (base string ,body io.Reader ) (* http.Request ,error ) { 209return protocol .NewGetHistoryContentRequest (base ,session ,entry ,& params ) 210 },Policy :readRecovery },func (data []byte )error {return decodeRequired (data ,& page ) }) 211return page ,page .Next ,pageErr 212 },func (page protocol.EntryContentPage )error { 213if page .EntryID != entry || page .Parts == nil { 214return fieldError ("history content identity" ) 215 } 216for _ ,part := range page .Parts { 217if part .Field == "" || complete [part .Field ]|| part .OffsetBytes != int64 (len (fields [part .Field ])) { 218return fieldError ("content byte offset" ) 219 } 220if part .Encoding != "utf8" { 221return fieldError ("content encoding" ) 222 } 223data := []byte (part .Text ) 224if ! utf8 .Valid (data ) { 225return fieldError ("content UTF-8" ) 226 } 227total += len (data ) 228if total > 50 * 1024 * 1024 { 229return errors .New ("entry content exceeds the client display limit" ) 230 } 231fields [part .Field ]= append (fields [part .Field ],data ... ) 232complete [part .Field ]= part .Complete 233 } 234return nil 235 },"repeated content page" ) 236if err != nil { 237return nil ,err 238 } 239for name := range fields { 240if ! complete [name ] { 241return nil ,fieldError ("incomplete content field" ) 242 } 243 } 244return fields ,nil 245} 246 247func hydrateHistory (ctx context.Context ,conn * Connection ,session string ,entries []protocol.HistoryEntry ) ([]protocol.HistoryEntry ,error ) { 248result := entries 249cloned := false 250for i ,entry := range entries { 251if entry .ContentComplete { 252continue 253 } 254needsContent := false 255for _ ,raw := range entry .Content { 256var part struct { 257Kind string `json:"kind"` 258Reference protocol.ContentReference `json:"reference"` 259 } 260if err := json .Unmarshal (raw ,& part );err != nil { 261return nil ,err 262 } 263if part .Kind == "reference" && ! strings .HasPrefix (part .Reference .Field ,"image-" ) { 264needsContent = true 265 } 266 } 267if ! needsContent { 268continue 269 } 270fields ,err := ReadEntryContent (ctx ,conn ,session ,entry .ID ) 271if err != nil { 272return nil ,err 273 } 274if ! cloned { 275result = slices .Clone (entries ) 276cloned = true 277 } 278result [i ].Content = nil 279for _ ,raw := range entry .Content { 280var part struct { 281Kind string `json:"kind"` 282Reference protocol.ContentReference `json:"reference"` 283 } 284_ = json .Unmarshal (raw ,& part ) 285if part .Kind != "reference" { 286result [i ].Content = append (result [i ].Content ,raw ) 287continue 288 } 289field := part .Reference .Field 290if strings .HasPrefix (field ,"image-" ) { 291result [i ].Content = append (result [i ].Content ,raw ) 292continue 293 } 294content ,ok := fields [field ] 295if ! ok || int64 (len (content ))!= part .Reference .Bytes { 296return nil ,fieldError ("referenced content size" ) 297 } 298var expanded any 299switch field { 300case "text" : 301expanded = map [string ]any {"kind" :"text" ,"text" :string (content )} 302case "arguments" ,"result" : 303if ! json .Valid (content ) { 304return nil ,fieldError ("referenced JSON" ) 305 } 306expanded = map [string ]any {"kind" :"json" ,"field" :field ,"value" :json .RawMessage (content )} 307case "trace" : 308expanded = map [string ]any {"kind" :"trace" ,"trace" :json .RawMessage (content )} 309default : 310if ! json .Valid (content ) { 311return nil ,fieldError ("referenced JSON" ) 312 } 313expanded = map [string ]any {"kind" :"json" ,"field" :field ,"value" :json .RawMessage (content )} 314 } 315encoded ,err := json .Marshal (expanded ) 316if err != nil { 317return nil ,err 318 } 319result [i ].Content = append (result [i ].Content ,encoded ) 320 } 321 } 322return result ,nil 323} 324 325// Live summaries read the same durable content resources as history pages. 326type eventContentReader struct { 327ctx context.Context 328conn * Connection 329session string 330} 331 332func (reader * eventContentReader )hydrateEntry (entry protocol.HistoryEntry ) (protocol.HistoryEntry ,error ) { 333entries ,err := hydrateHistory (reader .ctx ,reader .conn ,reader .session , []protocol.HistoryEntry {entry }) 334if err != nil { 335return protocol.HistoryEntry {},err 336 } 337return entries [0 ],nil 338} 339 340func (reader * eventContentReader )hydrateTool (data * toolEventData )error { 341reference := data .Reference 342if reference == nil { 343return nil 344 } 345parsed ,err := url .Parse (reference .URL ) 346if err != nil { 347return err 348 } 349prefix := "/sessions/" + url .PathEscape (reader .session )+ "/history/" 350if parsed .IsAbs ()|| parsed .RawQuery != "" || ! strings .HasPrefix (parsed .Path ,prefix ) { 351return fieldError ("tool content reference" ) 352 } 353entry := strings .TrimPrefix (parsed .Path ,prefix ) 354if entry == "" || strings .Contains (entry ,"/" ) { 355return fieldError ("tool content reference" ) 356 } 357fields ,err := ReadEntryContent (reader .ctx ,reader .conn ,reader .session ,entry ) 358if err != nil { 359return err 360 } 361content ,ok := fields [reference .Field ] 362if ! ok || int64 (len (content ))!= reference .Bytes { 363return fieldError ("tool content size" ) 364 } 365if reference .Field != "arguments" && reference .Field != "result" && reference .Field != "trace" { 366return fieldError ("tool content field" ) 367 } 368if ! json .Valid (content ) { 369return fieldError ("tool content JSON" ) 370 } 371for _ ,field := range []string {"arguments" ,"result" ,"trace" } { 372if full ,present := fields [field ];present { 373if ! json .Valid (full ) { 374return fieldError ("tool content JSON" ) 375 } 376switch field { 377case "arguments" : 378data .Arguments = json .RawMessage (full ) 379case "result" : 380data .Result = json .RawMessage (full ) 381case "trace" : 382data .Trace = json .RawMessage (full ) 383 } 384 } 385 } 386data .Reference = nil 387data .ContentComplete = true 388return nil 389}