v0.27.1 131edba

resident_history.go RawHistoryFolder

1package subagents
2
3import (
4	"bytes"
5	"crypto/sha256"
6	"encoding/base64"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"os"
12	"path/filepath"
13	"reflect"
14	"strings"
15	"time"
16
17	"github.com/bnema/zut/packages/core"
18	"github.com/bnema/zut/packages/provider"
19)
20
21const (
22	residentHistoryDefaultLimit = 200
23	residentHistoryMaximumLimit = 1000
24	residentHistoryPageBytes    = residentMaxRecordBytes + 1 // largest JSON record plus newline
25	residentHistoryReadChunk    = 64 << 10
26)
27
28// ResidentHistoryItem is a provider-neutral finalized transcript item. The
29// raw message and tool payloads are preserved so callers can render complete
30// arguments/results without relying on a lossy text projection.
31type ResidentHistoryItem struct {
32	Type       string          `json:"type"`
33	Time       time.Time       `json:"time"`
34	Message    json.RawMessage `json:"message,omitempty"`
35	ToolID     string          `json:"tool_id,omitempty"`
36	ToolName   string          `json:"tool_name,omitempty"`
37	ToolArgs   json.RawMessage `json:"tool_args,omitempty"`
38	ToolResult json.RawMessage `json:"tool_result,omitempty"`
39}
40
41// ResidentHistoryPage is a recent-first, bounded page of finalized history.
42// OlderCursor is opaque and can be supplied to ReadResidentHistoryPage to load
43// the adjacent older page. An empty cursor means this page reaches the oldest
44// finalized content.
45type ResidentHistoryPage struct {
46	Items       []ResidentHistoryItem
47	OlderCursor string
48}
49
50type residentHistoryCursor struct {
51	Generation string `json:"generation"`
52	Offset     int64  `json:"offset"`
53	Size       int64  `json:"size"`
54}
55
56type residentHistoryEntry struct {
57	item   ResidentHistoryItem
58	offset int64
59}
60
61// ReadResidentHistoryPage reads a bounded suffix of one resident journal. It
62// never follows arbitrary paths at public manager boundaries; callers outside
63// this package should use ResidentManager.HistoryPage with a child ID.
64//
65// A page starts at a user or assistant record. That preserves the adjacent
66// assistant/tool-call/tool-result group when paging backward. Incomplete final
67// lines are deliberately deferred until the writer completes them.
68func ReadResidentHistoryPage(dir, olderCursor string, limit int) (ResidentHistoryPage, error) {
69	if limit <= 0 {
70		limit = residentHistoryDefaultLimit
71	}
72	if limit > residentHistoryMaximumLimit {
73		limit = residentHistoryMaximumLimit
74	}
75	f, err := os.Open(filepath.Join(dir, residentTranscriptName))
76	if err != nil {
77		return ResidentHistoryPage{}, err
78	}
79	defer f.Close()
80	info, err := f.Stat()
81	if err != nil {
82		return ResidentHistoryPage{}, err
83	}
84	generation, err := residentHistoryGeneration(f, info, info.Size())
85	if err != nil {
86		return ResidentHistoryPage{}, err
87	}
88	end := info.Size()
89	if olderCursor != "" {
90		cursor, err := decodeResidentHistoryCursor(olderCursor)
91		if err != nil {
92			return ResidentHistoryPage{}, err
93		}
94		cursorGeneration, generationErr := residentHistoryGeneration(f, info, cursor.Size)
95		if generationErr != nil {
96			return ResidentHistoryPage{}, generationErr
97		}
98		if cursor.Generation != cursorGeneration || cursor.Size > end || cursor.Offset <= 0 || cursor.Offset > end {
99			return ResidentHistoryPage{}, errors.New("resident history: cursor is no longer valid")
100		}
101		end = cursor.Offset
102	}
103	return readResidentHistorySuffix(f, end, limit, generation, info.Size())
104}
105
106func residentHistoryGeneration(f *os.File, info os.FileInfo, originalSize int64) (string, error) {
107	prefixSize := originalSize
108	if prefixSize > 4096 {
109		prefixSize = 4096
110	}
111	prefix := make([]byte, prefixSize)
112	if prefixSize > 0 {
113		read, err := f.ReadAt(prefix, 0)
114		if err != nil && !errors.Is(err, io.EOF) {
115			return "", fmt.Errorf("resident history: read journal generation: %w", err)
116		}
117		prefix = prefix[:read]
118	}
119	sum := sha256.Sum256(prefix)
120	return fmt.Sprintf("%s:%x", residentHistoryFileIdentity(info), sum[:8]), nil
121}
122
123// residentHistoryFileIdentity uses only stable file identity fields when the
124// host exposes them. Dev/Ino cover Unix; creation time is the portable
125// fallback for Windows metadata. The prefix digest remains the corruption
126// check when a platform offers neither.
127func residentHistoryFileIdentity(info os.FileInfo) string {
128	value := reflect.ValueOf(info.Sys())
129	if value.Kind() == reflect.Pointer {
130		if value.IsNil() {
131			return ""
132		}
133		value = value.Elem()
134	}
135	if value.Kind() != reflect.Struct {
136		return ""
137	}
138	var parts []string
139	for _, field := range []string{"Dev", "Ino", "VolumeSerialNumber", "FileIndexHigh", "FileIndexLow", "CreationTime"} {
140		v := value.FieldByName(field)
141		if !v.IsValid() || !v.CanInterface() {
142			continue
143		}
144		parts = append(parts, field+"="+fmt.Sprint(v.Interface()))
145	}
146	return strings.Join(parts, ",")
147}
148
149func decodeResidentHistoryCursor(encoded string) (residentHistoryCursor, error) {
150	data, err := base64.RawURLEncoding.DecodeString(encoded)
151	if err != nil {
152		return residentHistoryCursor{}, errors.New("resident history: invalid cursor")
153	}
154	var cursor residentHistoryCursor
155	if err := json.Unmarshal(data, &cursor); err != nil || cursor.Generation == "" || cursor.Size <= 0 {
156		return residentHistoryCursor{}, errors.New("resident history: invalid cursor")
157	}
158	return cursor, nil
159}
160
161func encodeResidentHistoryCursor(generation string, offset, size int64) string {
162	data, _ := json.Marshal(residentHistoryCursor{Generation: generation, Offset: offset, Size: size})
163	return base64.RawURLEncoding.EncodeToString(data)
164}
165
166func readResidentHistorySuffix(f *os.File, end int64, limit int, generation string, size int64) (ResidentHistoryPage, error) {
167	if end == 0 {
168		return ResidentHistoryPage{}, nil
169	}
170	start := end
171	data := make([]byte, 0, residentHistoryReadChunk)
172	for {
173		readSize := int64(residentHistoryReadChunk)
174		if readSize > start {
175			readSize = start
176		}
177		if int64(len(data))+readSize > residentHistoryPageBytes {
178			return ResidentHistoryPage{}, errors.New("resident history: page byte budget exceeded")
179		}
180		start -= readSize
181		chunk := make([]byte, readSize)
182		read, err := f.ReadAt(chunk, start)
183		if err != nil && !errors.Is(err, io.EOF) {
184			return ResidentHistoryPage{}, fmt.Errorf("resident history: read journal suffix: %w", err)
185		}
186		chunk = chunk[:read]
187		data = append(chunk, data...)
188
189		entries, err := decodeResidentHistoryEntries(data, start, start == 0)
190		if err != nil {
191			return ResidentHistoryPage{}, err
192		}
193		first := residentHistoryPageStart(entries, limit)
194		if first >= 0 {
195			page := ResidentHistoryPage{Items: make([]ResidentHistoryItem, len(entries)-first)}
196			for index := first; index < len(entries); index++ {
197				page.Items[index-first] = cloneResidentHistoryItem(entries[index].item)
198			}
199			if start > 0 || first > 0 {
200				page.OlderCursor = encodeResidentHistoryCursor(generation, entries[first].offset, size)
201			}
202			return page, nil
203		}
204		if start == 0 {
205			if len(entries) == 0 {
206				return ResidentHistoryPage{}, nil
207			}
208			return ResidentHistoryPage{}, errors.New("resident history: tool group has no message boundary")
209		}
210	}
211}
212
213func decodeResidentHistoryEntries(data []byte, start int64, startsAtBeginning bool) ([]residentHistoryEntry, error) {
214	lastNewline := bytes.LastIndexByte(data, '\n')
215	if lastNewline < 0 {
216		return nil, nil
217	}
218	complete := data[:lastNewline+1]
219	base := start
220	if !startsAtBeginning {
221		firstNewline := bytes.IndexByte(complete, '\n')
222		if firstNewline < 0 {
223			return nil, nil
224		}
225		base += int64(firstNewline + 1)
226		complete = complete[firstNewline+1:]
227	}
228	entries := make([]residentHistoryEntry, 0)
229	for len(complete) > 0 {
230		newline := bytes.IndexByte(complete, '\n')
231		if newline < 0 {
232			break
233		}
234		line := complete[:newline]
235		lineOffset := base
236		base += int64(newline + 1)
237		complete = complete[newline+1:]
238		if len(line) > residentMaxRecordBytes {
239			return nil, errors.New("resident history: record too large")
240		}
241		var record residentRecord
242		if err := json.Unmarshal(line, &record); err != nil {
243			return nil, fmt.Errorf("resident history: decode journal record: %w", err)
244		}
245		item, include, err := residentHistoryItem(record)
246		if err != nil {
247			return nil, err
248		}
249		if include {
250			entries = append(entries, residentHistoryEntry{item: item, offset: lineOffset})
251		}
252	}
253	return entries, nil
254}
255
256func residentHistoryItem(record residentRecord) (ResidentHistoryItem, bool, error) {
257	item := ResidentHistoryItem{Type: record.Type, Time: record.Time}
258	switch record.Type {
259	case residentRecordUser, residentRecordAssistant:
260		if len(record.Message) == 0 || !json.Valid(record.Message) {
261			return ResidentHistoryItem{}, false, errors.New("resident history: malformed message record")
262		}
263		item.Message = append(json.RawMessage(nil), record.Message...)
264	case residentRecordToolCall:
265		if record.ToolID == "" || record.ToolName == "" || !json.Valid(record.ToolArgs) {
266			return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool call record")
267		}
268		item.ToolID, item.ToolName = record.ToolID, record.ToolName
269		item.ToolArgs = append(json.RawMessage(nil), record.ToolArgs...)
270	case residentRecordToolResult:
271		if record.ToolID == "" || !json.Valid(record.ToolResult) {
272			return ResidentHistoryItem{}, false, errors.New("resident history: malformed tool result record")
273		}
274		item.ToolID, item.ToolResult = record.ToolID, append(json.RawMessage(nil), record.ToolResult...)
275	default:
276		return ResidentHistoryItem{}, false, nil
277	}
278	return item, true, nil
279}
280
281func residentHistoryPageStart(entries []residentHistoryEntry, limit int) int {
282	first, fallback := -1, -1
283	for index := range entries {
284		if entries[index].item.Type != residentRecordUser && entries[index].item.Type != residentRecordAssistant {
285			continue
286		}
287		fallback = index
288		if len(entries)-index <= limit {
289			first = index
290		}
291	}
292	if first < 0 {
293		return fallback
294	}
295	return first
296}
297
298func cloneResidentHistoryItem(item ResidentHistoryItem) ResidentHistoryItem {
299	item.Message = append(json.RawMessage(nil), item.Message...)
300	item.ToolArgs = append(json.RawMessage(nil), item.ToolArgs...)
301	item.ToolResult = append(json.RawMessage(nil), item.ToolResult...)
302	return item
303}
304
305// ResidentHistoryMessages converts finalized journal items into the same
306// provider-neutral message structures used by tui.View. Tool-call records
307// duplicate calls already embedded in an assistant message, so they are only
308// synthesized when the assistant record is absent (for recovery resilience).
309func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, error) {
310	messages := make([]provider.Message, 0, len(items))
311	calls := make(map[string]struct{})
312	for _, item := range items {
313		switch item.Type {
314		case residentRecordUser, residentRecordAssistant:
315			message, err := core.DecodeMessageJSON(item.Message)
316			if err != nil {
317				return nil, fmt.Errorf("resident history: decode message: %w", err)
318			}
319			for _, content := range message.Content {
320				if call, ok := content.(provider.ToolCallBlock); ok {
321					calls[call.ID] = struct{}{}
322				}
323			}
324			messages = append(messages, message)
325		case residentRecordToolCall:
326			if _, exists := calls[item.ToolID]; exists {
327				continue
328			}
329			messages = append(messages, provider.Message{Role: provider.RoleAssistant, Time: item.Time, Content: []provider.Content{provider.ToolCallBlock{ID: item.ToolID, Name: item.ToolName, Arguments: append(json.RawMessage(nil), item.ToolArgs...)}}})
330			calls[item.ToolID] = struct{}{}
331		case residentRecordToolResult:
332			var result struct {
333				Content []json.RawMessage    `json:"Content"`
334				IsError bool                 `json:"IsError"`
335				Timing  *provider.ToolTiming `json:"Timing"`
336			}
337			if err := json.Unmarshal(item.ToolResult, &result); err != nil || len(result.Content) == 0 {
338				if err == nil {
339					err = errors.New("missing content")
340				}
341				return nil, fmt.Errorf("resident history: decode tool result: %w", err)
342			}
343			raw, err := json.Marshal(struct {
344				Role    provider.Role `json:"role"`
345				Time    time.Time     `json:"time"`
346				Content []struct {
347					CallID  string               `json:"call_id"`
348					Content []json.RawMessage    `json:"content"`
349					IsError bool                 `json:"is_error"`
350					Timing  *provider.ToolTiming `json:"timing,omitempty"`
351				} `json:"content"`
352			}{Role: provider.RoleTool, Time: item.Time, Content: []struct {
353				CallID  string               `json:"call_id"`
354				Content []json.RawMessage    `json:"content"`
355				IsError bool                 `json:"is_error"`
356				Timing  *provider.ToolTiming `json:"timing,omitempty"`
357			}{{CallID: item.ToolID, Content: result.Content, IsError: result.IsError, Timing: result.Timing}}})
358			if err != nil {
359				return nil, err
360			}
361			message, err := core.DecodeMessageJSON(raw)
362			if err != nil {
363				return nil, fmt.Errorf("resident history: decode tool result message: %w", err)
364			}
365			messages = append(messages, message)
366		}
367	}
368	return messages, nil
369}
370
371// ReadResidentTranscriptMessages rebuilds finalized conversation state for an
372// explicit resident resume. It reads the authoritative transcript, unlike the
373// bounded UI pager, and must therefore only be used at the resume boundary.
374func ReadResidentTranscriptMessages(dir string) ([]provider.Message, error) {
375	records, err := ReadResidentJournal(filepath.Join(dir, residentTranscriptName))
376	if err != nil {
377		return nil, err
378	}
379	items := make([]ResidentHistoryItem, 0, len(records))
380	for _, record := range records {
381		item, include, err := residentHistoryItem(record)
382		if err != nil {
383			return nil, err
384		}
385		if include {
386			items = append(items, item)
387		}
388	}
389	return ResidentHistoryMessages(items)
390}
391
392// ReadResidentHistory returns the recent finalized journal items, bounded by
393// item count. Public callers provide a child directory obtained from manager
394// state, never an arbitrary user-controlled path.
395func ReadResidentHistory(dir string, limit int) ([]ResidentHistoryItem, error) {
396	page, err := ReadResidentHistoryPage(dir, "", limit)
397	if err != nil {
398		return nil, err
399	}
400	return page.Items, nil
401}
402
403// ResidentHistoryDir resolves a child ID beneath root without accepting path
404// traversal. It is kept separate from the reader so manager APIs can apply
405// their own visibility checks before exposing history.
406func ResidentHistoryDir(root, childID string) (string, error) {
407	childID = strings.TrimSpace(childID)
408	if !residentChildID(childID) {
409		return "", errors.New("resident history: invalid child ID")
410	}
411	dir := filepath.Join(root, childID)
412	if info, err := os.Stat(dir); err != nil {
413		return "", err
414	} else if !info.IsDir() {
415		return "", errors.New("resident history: child path is not a directory")
416	}
417	if !pathWithin(dir, root) {
418		return "", errors.New("resident history: child path escapes root")
419	}
420	return dir, nil
421}

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