okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfeat(usage): time provider calls and show tok/s on the turn signoff0a3eddd

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