okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0a3eddd
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"encoding/json" 6"fmt" 7"slices" 8) 9 10type EntryKind string 11 12const ( 13EntryUser EntryKind = "user" 14EntryAssistant EntryKind = "assistant" 15EntryThinking EntryKind = "thinking" 16EntryTool EntryKind = "tool" 17EntryNote EntryKind = "note" 18EntryError EntryKind = "error" 19EntryCompacted EntryKind = "compacted" 20// EntryTurnEnd closes a turn with its outcome, length, and tool count. 21EntryTurnEnd EntryKind = "turn_end" 22) 23 24type HistoryEntry struct { 25MessageID string 26ID string 27Source string 28MailKind string 29SenderSessionID string 30ToolArgs map [string ]any 31// facts is what this entry contributes to a grouped row, computed once 32// when the entry settles. Targets stay as recorded; naming is at render. 33facts * entryFacts 34ToolTrace * daemon.ToolTrace 35Speaker string 36Mood mood 37Text string 38ToolName string 39ToolResult string 40// Strategy is the compaction strategy that produced a compacted entry. 41Strategy string 42Kind EntryKind 43Timestamp int64 44SizeBytes int64 45Evicted int 46Tools int 47// Pending is a message of yours the daemon has not echoed back yet. 48Pending pending 49ElapsedMs int64 50// TokensPerSecond is the output rate of a turn's latest timed call; 0 when 51// none was timed. 52TokensPerSecond float64 53// Seq is the newest transcript row this entry came from; 0 until a 54// `committed` event says which rows cover it. 55Seq int64 56// Live is a reply still streaming in. It renders on every token, so it 57// skips the second rendering that marks wraps for a copy until it settles. 58Live bool 59} 60 61// pending records whether a user message is awaiting admission, queued for 62// delivery, or unresolved after its operation receipt expires. 63type pending int 64 65const ( 66settled pending = iota 67sending 68queued 69unresolved 70) 71 72func (e * HistoryEntry )ComputeSize ()int64 { 73size := int64 (len (e .ID )+ len (e .MessageID )+ len (e .Source )+ len (e .MailKind )+ len (e .SenderSessionID )+ len (e .Speaker )+ len (e .Text )+ len (e .ToolName )+ len (e .ToolResult )+ 64 ) 74for k ,v := range e .ToolArgs { 75size += int64 (len (k )+ 16 ) 76switch val := v .(type ) { 77case string : 78size += int64 (len (val )) 79default : 80data ,_ := json .Marshal (val ) 81size += int64 (len (data )) 82 } 83 } 84if e .ToolTrace != nil { 85for _ ,act := range e .ToolTrace .Activities { 86size += int64 (len (act .Kind )+ len (act .Target )+ len (act .Detail )+ 16 ) 87 } 88for _ ,ch := range e .ToolTrace .Changes { 89size += int64 (len (ch .Path )+ len (ch .Kind )+ len (ch .Diff )+ len (ch .Reason )+ 32 ) 90 } 91 } 92e .SizeBytes = size 93return size 94} 95 96type BoundedHistory struct { 97entries []HistoryEntry 98MaxEntries int 99MaxBytes int64 100totalBytes int64 101evictedEntries int 102evictedBytes int64 103// evictedThrough is the newest transcript row among evicted entries: the 104// daemon holds everything up to it, and older pages resume after it. 105evictedThrough int64 106} 107 108func NewBoundedHistory (maxEntries int ,maxBytes int64 )* BoundedHistory { 109if maxEntries <= 0 { 110maxEntries = 500 111 } 112if maxBytes <= 0 { 113maxBytes = 2 * 1024 * 1024 114 } 115return & BoundedHistory { 116MaxEntries :maxEntries , 117MaxBytes :maxBytes , 118entries :make ([]HistoryEntry ,0 ,128 ), 119 } 120} 121 122func (h * BoundedHistory )Len ()int {return len (h .entries ) } 123func (h * BoundedHistory )TotalBytes ()int64 {return h .totalBytes } 124func (h * BoundedHistory )EvictedCount ()int {return h .evictedEntries } 125func (h * BoundedHistory )EvictedBytes ()int64 {return h .evictedBytes } 126func (h * BoundedHistory )Entries () []HistoryEntry {return h .entries } 127 128func (h * BoundedHistory )TruncationNotice ()string { 129if h .evictedEntries > 0 { 130return fmt .Sprintf ("[%d older messages hidden (%d KB); full history is saved by Albedo]" ,h .evictedEntries ,h .evictedBytes / 1024 ) 131 } 132return "" 133} 134 135// EvictedThrough is the newest transcript row among evicted entries, or 0. 136func (h * BoundedHistory )EvictedThrough ()int64 { 137return h .evictedThrough 138} 139 140// Stamp marks the trailing entries no row covered yet as covered by seq. 141func (h * BoundedHistory )Stamp (seq int64 ) { 142for i := len (h .entries )- 1 ;i >= 0 && h .entries [i ].Seq == 0 ;i -- { 143if h .entries [i ].MessageID == "" { 144h .entries [i ].Seq = seq 145 } 146 } 147} 148 149func (h * BoundedHistory )Find (id string )int { 150if id == "" { 151return - 1 152 } 153return slices .IndexFunc (h .entries ,func (entry HistoryEntry )bool {return entry .ID == id }) 154} 155 156func (h * BoundedHistory )Replace (entry HistoryEntry )bool { 157index := h .Find (entry .ID ) 158if index < 0 { 159return false 160 } 161entry .ComputeSize () 162h .totalBytes += entry .SizeBytes - h .entries [index ].SizeBytes 163h .entries [index ]= entry 164h .enforceBounds () 165return true 166} 167 168// Prepend puts older entries before the retained ones. It does not evict: 169// they were asked for, and Trim or the next Append restores the bounds. 170func (h * BoundedHistory )Prepend (older []HistoryEntry ) { 171if len (older )== 0 { 172return 173 } 174unique := make ([]HistoryEntry ,0 ,len (older )) 175seen := map [string ]bool {} 176for _ ,entry := range h .entries { 177if entry .ID != "" { 178seen [entry .ID ]= true 179 } 180 } 181for _ ,entry := range older { 182if entry .ID != "" && seen [entry .ID ] { 183continue 184 } 185if entry .ID != "" { 186seen [entry .ID ]= true 187 } 188h .totalBytes += entry .ComputeSize () 189unique = append (unique ,entry ) 190 } 191h .entries = slices .Concat (unique ,h .entries ) 192h .evictedThrough = 0 193} 194 195// Trim evicts the oldest entries past the bounds Prepend stretched. 196func (h * BoundedHistory )Trim () { 197h .enforceBounds () 198} 199 200func (h * BoundedHistory )enforceBounds () { 201for len (h .entries )> 0 && (len (h .entries )> h .MaxEntries || h .totalBytes > h .MaxBytes ) { 202h .evictFirst () 203// A row's entries leave together, so re-reading from the next row 204// never repeats what is still shown. 205for seq := h .evictedThrough ;seq > 0 && len (h .entries )> 0 && h .entries [0 ].Seq == seq ; { 206h .evictFirst () 207 } 208 } 209} 210 211func (h * BoundedHistory )evictFirst () { 212removed := h .entries [0 ] 213h .entries [0 ]= HistoryEntry {}// Explicitly zero out evicted slot to drop GC references 214h .entries = h .entries [1 :] 215h .totalBytes = max (0 ,h .totalBytes - removed .SizeBytes ) 216h .evictedEntries ++ 217h .evictedBytes += removed .SizeBytes 218h .evictedThrough = max (h .evictedThrough ,removed .Seq ) 219} 220 221func (h * BoundedHistory )Append (entry HistoryEntry ) { 222entry .ComputeSize () 223h .entries = append (h .entries ,entry ) 224h .totalBytes += entry .SizeBytes 225h .enforceBounds () 226} 227 228func (h * BoundedHistory )Clear () { 229clear (h .entries ) 230* h = BoundedHistory {MaxEntries :h .MaxEntries ,MaxBytes :h .MaxBytes ,entries :h .entries [:0 ]} 231} 232 233// Retire provisional chunks without counting them as hidden durable history. 234func (h * BoundedHistory )retireLive (ids []string ,all bool )bool { 235removed := false 236h .entries = slices .DeleteFunc (h .entries ,func (entry HistoryEntry )bool { 237retire := entry .MessageID != "" && (all || slices .Contains (ids ,entry .MessageID )) 238if retire { 239h .totalBytes -= entry .SizeBytes 240removed = true 241 } 242return retire 243 }) 244return removed 245}