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