v0.51.0 a9d674e

context_budget.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"time"
6	"unicode/utf8"
7
8	"github.com/bnema/zut/packages/provider"
9)
10
11const (
12	maxToolResultTextBytes   = 32 * 1024
13	toolResultOmissionMarker = "[tool result content omitted]"
14	staleToolResultMarker    = "[stale tool result omitted; a newer workspace view supersedes it]"
15)
16
17// projectToolResultMessages returns a copied provider-input view of msgs.
18// The byte cap is independent per result. Provenance pruning is the deliberate
19// exception: a newer workspace view can replace an older result, trading the
20// provider cache prefix from that point for removal of context known to be
21// stale. The durable transcript is never rewritten. Compaction owns the
22// aggregate transcript budget.
23func projectToolResultMessages(msgs []provider.Message) []provider.Message {
24	projected := copyToolResultMessages(msgs)
25	stale := staleToolResultIDs(projected)
26
27	for i := range projected {
28		for j, content := range projected[i].Content {
29			result, ok := content.(provider.ToolResultBlock)
30			if !ok {
31				continue
32			}
33			if stale[result.CallID] {
34				result.Content = []provider.Content{provider.TextBlock{Text: staleToolResultMarker}}
35				projected[i].Content[j] = result
36				continue
37			}
38
39			if toolResultTextBytes(result.Content) > 0 {
40				result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
41			}
42			if result.Timing != nil {
43				result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
44			}
45			projected[i].Content[j] = result
46		}
47	}
48
49	return projected
50}
51
52// staleToolResultIDs computes a causal projection over successful tool
53// results. Re-reading an equivalent resource view supersedes the old read;
54// reading a discovered file supersedes the search result that found it; and a
55// mutation supersedes every earlier view of the affected resource.
56func staleToolResultIDs(msgs []provider.Message) map[string]bool {
57	stale := make(map[string]bool)
58	reads := make(map[provider.ResourceRef]string)
59	discoveries := make(map[string]map[string]bool)
60	remainingDiscoveries := make(map[string]int)
61	for _, message := range msgs {
62		for _, content := range message.Content {
63			result, ok := content.(provider.ToolResultBlock)
64			if !ok || result.IsError || result.CallID == "" {
65				continue
66			}
67			for _, resource := range result.Context.Reads {
68				if previous := reads[resource]; previous != "" {
69					stale[previous] = true
70				}
71				reads[resource] = result.CallID
72				for discovery := range discoveries[resource.Key] {
73					remainingDiscoveries[discovery]--
74					if remainingDiscoveries[discovery] == 0 {
75						stale[discovery] = true
76					}
77				}
78				delete(discoveries, resource.Key)
79			}
80			for _, resource := range result.Context.Mutates {
81				for view, previous := range reads {
82					if view.Key == resource {
83						if previous != result.CallID {
84							stale[previous] = true
85						}
86						delete(reads, view)
87					}
88				}
89				for discovery := range discoveries[resource] {
90					remainingDiscoveries[discovery]--
91					if remainingDiscoveries[discovery] == 0 {
92						stale[discovery] = true
93					}
94				}
95				delete(discoveries, resource)
96			}
97			for _, resource := range result.Context.Discovers {
98				if discoveries[resource] == nil {
99					discoveries[resource] = make(map[string]bool)
100				}
101				if !discoveries[resource][result.CallID] {
102					discoveries[resource][result.CallID] = true
103					remainingDiscoveries[result.CallID]++
104				}
105			}
106		}
107	}
108	return stale
109}
110
111// projectProviderMessages creates the provider-only transcript view. The
112// timestamp and timing annotations are deliberately added to this copy so
113// the visible transcript and its persisted historical content remain intact.
114func projectProviderMessages(msgs []provider.Message) []provider.Message {
115	projected := projectToolResultMessages(msgs)
116	for i, message := range projected {
117		if message.Role != provider.RoleUser || message.Time.IsZero() {
118			continue
119		}
120		content := make([]provider.Content, 0, len(message.Content)+1)
121		content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
122		content = append(content, message.Content...)
123		projected[i].Content = content
124	}
125	return projected
126}
127
128func formatToolTiming(timing *provider.ToolTiming) string {
129	return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
130		timing.StartedAt.Format(time.RFC3339),
131		timing.CompletedAt.Format(time.RFC3339),
132		timing.Duration,
133	)
134}
135
136// copyToolResultMessages copies the slices that the projection can replace.
137// Content blocks are values, so sharing their immutable fields is safe; the
138// nested content slice of every ToolResultBlock is copied before projection.
139func copyToolResultMessages(msgs []provider.Message) []provider.Message {
140	if msgs == nil {
141		return nil
142	}
143	out := make([]provider.Message, len(msgs))
144	for i, message := range msgs {
145		out[i] = message
146		if message.Content != nil {
147			out[i].Content = append([]provider.Content(nil), message.Content...)
148		}
149		if message.AddedToolNames != nil {
150			out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
151		}
152		if message.Meta != nil {
153			out[i].Meta = make(map[string]string, len(message.Meta))
154			for key, value := range message.Meta {
155				out[i].Meta[key] = value
156			}
157		}
158		for j, content := range out[i].Content {
159			if result, ok := content.(provider.ToolResultBlock); ok {
160				result.Content = append([]provider.Content(nil), result.Content...)
161				out[i].Content[j] = result
162			}
163		}
164	}
165	return out
166}
167
168func toolResultTextBytes(content []provider.Content) int {
169	total := 0
170	for _, block := range content {
171		if text, ok := block.(provider.TextBlock); ok {
172			total += len(text.Text)
173		}
174	}
175	return total
176}
177
178// projectToolResultContent projects one result's textual content to limit
179// bytes. Every truncated result receives an omission marker, even after the
180// aggregate content budget is exhausted. The returned count excludes that
181// marker so the budget covers retained tool output rather than its notices.
182// Non-text blocks are retained in their original order.
183func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
184	textBytes := toolResultTextBytes(content)
185	if textBytes <= limit {
186		return append([]provider.Content(nil), content...), textBytes
187	}
188
189	out := make([]provider.Content, 0, len(content)+1)
190	prefixRemaining := limit
191	prefixUsed := 0
192	truncated := false
193	for _, block := range content {
194		text, ok := block.(provider.TextBlock)
195		if !ok {
196			out = append(out, block)
197			continue
198		}
199		if truncated {
200			continue
201		}
202		if len(text.Text) <= prefixRemaining {
203			out = append(out, text)
204			prefixRemaining -= len(text.Text)
205			prefixUsed += len(text.Text)
206			continue
207		}
208
209		text.Text = utf8Prefix(text.Text, prefixRemaining)
210		if text.Text != "" {
211			out = append(out, text)
212		}
213		prefixUsed += len(text.Text)
214		truncated = true
215	}
216	out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
217	return out, prefixUsed
218}
219
220func utf8Prefix(text string, limit int) string {
221	if limit <= 0 {
222		return ""
223	}
224	if limit >= len(text) {
225		return text
226	}
227	for limit > 0 && !utf8.RuneStart(text[limit]) {
228		limit--
229	}
230	return text[:limit]
231}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close