orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "strings"
5 "sync"
6)
7
8// The record of what a turn actually did, kept so the page can show it.
9//
10// All of this already happened and none of it was ever visible, so an answer
11// written from the model's memory looked exactly like one read off a tool. A
12// step says what went in and what came out, and the page draws them as a list
13// under the answer.
14//
15// It is written next to the message, so it is bounded on purpose: a system
16// prompt is several thousand characters and a page of them per turn would grow
17// the database faster than the conversation does.
18
19// What one field of one step may store.
20const stepMax = 1200
21
22// How many steps a turn may record. Six tool rounds with a gate and a nudge
23// each is comfortably inside this, and a runaway turn cannot write a megabyte.
24const stepsMax = 60
25
26type Step struct {
27 // One of: prompt, memory, wikipedia, model, tool, gate, answer, title,
28 // compact. The page styles by this and shows the label as written.
29 Kind string `json:"kind"`
30 Label string `json:"label"`
31 In string `json:"in,omitempty"`
32 Out string `json:"out,omitempty"`
33 MS int64 `json:"ms,omitempty"`
34 // Something failed or was rejected, which the page colours rather than
35 // hides, since a refused tool is part of the work.
36 Bad bool `json:"bad,omitempty"`
37 // Free text for whatever the step counts, like "1,204 in / 86 out".
38 Meta string `json:"meta,omitempty"`
39}
40
41// Trace collects the steps of one turn and sends each one out as it happens,
42// so the list fills in while the answer is still being written.
43type Trace struct {
44 mu sync.Mutex
45 steps []Step
46 emit func(Event)
47}
48
49func NewTrace(emit func(Event)) *Trace {
50 if emit == nil {
51 emit = func(Event) {}
52 }
53 return &Trace{emit: emit}
54}
55
56// Add records a step. It is safe from any goroutine, since the memory pass and
57// the turn both write here.
58func (t *Trace) Add(s Step) {
59 if t == nil {
60 return
61 }
62 s.In, s.Out = clip(s.In), clip(s.Out)
63 t.mu.Lock()
64 if len(t.steps) >= stepsMax {
65 t.mu.Unlock()
66 return
67 }
68 t.steps = append(t.steps, s)
69 t.mu.Unlock()
70 t.emit(Event{Kind: "step", Step: &s})
71}
72
73func (t *Trace) Steps() []Step {
74 if t == nil {
75 return nil
76 }
77 t.mu.Lock()
78 defer t.mu.Unlock()
79 return append([]Step(nil), t.steps...)
80}
81
82// clip keeps a field inside the budget and says how much it dropped, since a
83// prompt cut off with no mark reads as a prompt that was that short.
84func clip(s string) string {
85 s = strings.TrimSpace(s)
86 if len(s) <= stepMax {
87 return s
88 }
89 return strings.TrimSpace(s[:stepMax]) + "\n… " + itoa(len(s)-stepMax) + " more characters"
90}
91
92func itoa(n int) string {
93 if n == 0 {
94 return "0"
95 }
96 var b [20]byte
97 i := len(b)
98 for n > 0 {
99 i--
100 b[i] = byte('0' + n%10)
101 n /= 10
102 }
103 return string(b[i:])
104}