okami.mom/albedo

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

nekomimi.petapi: zstd responses, streams and request bodies; images served as raw bytes; drop redundant re-encodes7fd8cf5

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