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}