1package core
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/bnema/zut/packages/provider"
18 "github.com/google/uuid"
19)
20
21// PortableExt is the filesystem extension used for exported sessions.
22// A ".zutsession" is just a zut JSONL session file with the meta
23// header rewritten so the importing user gets fresh ownership.
24const PortableExt = ".zutsession"
25
26// ExportSession writes the session at srcPath to dstPath as a
27// portable .zutsession file. If dstPath is an existing directory the
28// file is created inside it with a name derived from the session's
29// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zutsession"). The
30// destination's directory is created if needed. Returns the final
31// resolved path so the caller can tell the user where it landed.
32//
33// The on-disk format is unchanged from a live session; only the
34// meta.cwd is stripped of its per-machine prefix (the importing
35// user doesn't care what directory it came from). Everything else
36// round-trips byte-for-byte.
37func ExportSession(srcPath, dstPath string) (string, error) {
38 if srcPath == "" {
39 return "", errors.New("export: source path is empty")
40 }
41 if dstPath == "" {
42 return "", errors.New("export: destination path is empty")
43 }
44
45 // Read the effective snapshot so a later model/provider update is not
46 // replaced by the stale metadata in the first row.
47 snapshot, err := ReadSessionSnapshot(srcPath)
48 if err != nil {
49 return "", fmt.Errorf("export: read session snapshot: %w", err)
50 }
51
52 // Read the source meta up-front so we can name the output sensibly
53 // when dstPath is a directory, and so we can validate it's a real
54 // session before starting to write.
55 src, err := os.Open(srcPath)
56 if err != nil {
57 return "", fmt.Errorf("export: open source: %w", err)
58 }
59 defer src.Close()
60
61 sc := bufio.NewScanner(src)
62 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
63 if !sc.Scan() {
64 return "", errors.New("export: session file is empty")
65 }
66 var head sessionLine
67 if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
68 return "", fmt.Errorf("export: parse meta: %w", err)
69 }
70 if head.Type != "meta" || head.Meta == nil {
71 return "", errors.New("export: first line is not a meta row")
72 }
73
74 // Scan the rest of the file for the first user message so we can
75 // build a humane filename. Only reads if dstPath doesn't already
76 // end in .zutsession.
77 firstPrompt := ""
78 if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
79 if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
80 p, err := firstUserPrompt(src)
81 if err != nil {
82 return "", fmt.Errorf("export: read first prompt: %w", err)
83 }
84 firstPrompt = p
85 }
86 }
87
88 // Resolve dstPath: if it's a directory, build a name inside it.
89 outPath := dstPath
90 if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
91 name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
92 outPath = filepath.Join(dstPath, name)
93 } else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
94 outPath += PortableExt
95 }
96
97 // Re-open the source from the top since we advanced the scanner.
98 if _, err := src.Seek(0, io.SeekStart); err != nil {
99 return "", fmt.Errorf("export: rewind: %w", err)
100 }
101
102 if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
103 return "", fmt.Errorf("export: mkdir dst: %w", err)
104 }
105 dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
106 if err != nil {
107 return "", fmt.Errorf("export: create dst: %w", err)
108 }
109 defer dst.Close()
110 bw := bufio.NewWriter(dst)
111
112 // Rewrite the meta row: strip the cwd (the importing user has
113 // their own) and keep everything else identical. ID stays so the
114 // export is traceable; the importer will rotate to a fresh ID.
115 exportMeta := snapshot.Meta
116 exportMeta.CWD = ""
117 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
118 if err != nil {
119 return "", fmt.Errorf("export: marshal meta: %w", err)
120 }
121 if _, err := bw.Write(metaLine); err != nil {
122 return "", err
123 }
124 if err := bw.WriteByte('\n'); err != nil {
125 return "", err
126 }
127
128 // Stream every non-meta row verbatim. Use ReadBytes instead of
129 // bufio.Scanner: large sessions can contain very long JSONL rows
130 // (image blocks, big tool outputs, compacted history) that exceed
131 // Scanner's token limit and fail with "token too long".
132 r := bufio.NewReader(src)
133 for {
134 line, err := r.ReadBytes('\n')
135 if len(line) > 0 {
136 line = bytes.TrimRight(line, "\r\n")
137 var h sessionLineHead
138 if err := json.Unmarshal(line, &h); err == nil && h.Type != "meta" {
139 if _, werr := bw.Write(line); werr != nil {
140 return "", werr
141 }
142 if werr := bw.WriteByte('\n'); werr != nil {
143 return "", werr
144 }
145 }
146 }
147 if err == io.EOF {
148 break
149 }
150 if err != nil {
151 return "", fmt.Errorf("export: read source: %w", err)
152 }
153 }
154 if err := bw.Flush(); err != nil {
155 return "", err
156 }
157 return outPath, nil
158}
159
160// ImportSession copies the .zutsession file at srcPath into the
161// running user's session store under the given root+cwd, rewriting
162// the meta's id / cwd / started fields so the imported session is
163// owned by the current user / directory / clock. Returns the path
164// of the created session file, ready to pass to OpenSession.
165//
166// The imported session is a first-class zut session: it'll show up
167// in /sessions, /jump, and on-disk summaries just like any other.
168// Messages and usage rows are preserved verbatim.
169func ImportSession(srcPath, root, cwd, version string) (string, error) {
170 if srcPath == "" {
171 return "", errors.New("import: source path is empty")
172 }
173 src, err := os.Open(srcPath)
174 if err != nil {
175 return "", fmt.Errorf("import: open source: %w", err)
176 }
177 defer src.Close()
178
179 // Read the effective metadata before committing to a destination. Meta
180 // rows are append-only, so the first row may describe an older provider or
181 // model after a runtime switch.
182 snapshot, err := ReadSessionSnapshot(srcPath)
183 if err != nil {
184 return "", fmt.Errorf("import: read session snapshot: %w", err)
185 }
186
187 // Validate the file header before committing to a destination.
188 sc := bufio.NewScanner(src)
189 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
190 if !sc.Scan() {
191 return "", errors.New("import: session file is empty")
192 }
193 var head sessionLine
194 if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
195 return "", fmt.Errorf("import: parse meta: %w", err)
196 }
197 if head.Type != "meta" || head.Meta == nil {
198 return "", errors.New("import: first line is not a meta row")
199 }
200
201 // Build the destination inside the current cwd's session dir
202 // with a fresh timestamped name.
203 dir := SessionsDir(root, cwd)
204 if err := os.MkdirAll(dir, 0o755); err != nil {
205 return "", err
206 }
207 newID := uuid.NewString()
208 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
209 outPath := filepath.Join(dir, name)
210 dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
211 if err != nil {
212 return "", fmt.Errorf("import: create dst: %w", err)
213 }
214 defer dst.Close()
215 bw := bufio.NewWriter(dst)
216
217 // Write a fresh meta row claiming ownership.
218 importMeta := SessionMeta{
219 ID: newID,
220 CWD: cwd,
221 Model: snapshot.Meta.Model,
222 Provider: snapshot.Meta.Provider,
223 Started: time.Now().UTC(),
224 Version: version,
225 Title: snapshot.Title,
226 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
227 }
228 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
229 if err != nil {
230 return "", fmt.Errorf("import: marshal meta: %w", err)
231 }
232 if _, err := bw.Write(metaLine); err != nil {
233 return "", err
234 }
235 if err := bw.WriteByte('\n'); err != nil {
236 return "", err
237 }
238
239 // Rewind the source and stream every non-meta row. Avoid
240 // bufio.Scanner so exported sessions with huge JSONL rows import
241 // cleanly.
242 if _, err := src.Seek(0, io.SeekStart); err != nil {
243 return "", fmt.Errorf("import: rewind: %w", err)
244 }
245 if err := forEachJSONLLine(src, func(line []byte) error {
246 var h sessionLineHead
247 if err := json.Unmarshal(line, &h); err != nil || h.Type == "meta" {
248 return nil
249 }
250 if _, err := bw.Write(line); err != nil {
251 return err
252 }
253 return bw.WriteByte('\n')
254 }); err != nil {
255 return "", fmt.Errorf("import: read source: %w", err)
256 }
257 if err := bw.Flush(); err != nil {
258 return "", err
259 }
260 return outPath, nil
261}
262
263// BranchSession creates a new session in root/cwd that contains the
264// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
265// assistant+tool rows). The new meta records Parent=<parent id> and
266// ForkPoint=N so /session tree can rebuild the branch topology
267// later. The effective repaired prefix is materialized, and all eligible
268// usage checkpoints at or before the cut are preserved so the running cost
269// tracker can reconstruct the final-turn delta. A boundary that ends on a
270// tool call is extended through its paired tool result.
271//
272// upToMessageIdx is a count over the flat message stream as
273// returned by OpenSession. To "branch at user turn 3" the caller
274// passes the index of that user message in msgs + 1 (so the
275// message itself is included). The caller figures that out; this
276// helper rejects a count outside the current effective snapshot.
277//
278// Returns the path of the new session file, ready for OpenSession.
279func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
280 return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
281}
282
283// BranchSessionHidden creates a branch that participates in /session tree but
284// is hidden from the flat /sessions picker. Used for in-place tree navigation.
285func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
286 return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
287}
288
289func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
290 if parentPath == "" {
291 return "", errors.New("branch: parent path is empty")
292 }
293 if upToMessageIdx < 0 {
294 return "", errors.New("branch: upToMessageIdx must be >= 0")
295 }
296
297 snapshot, err := ReadSessionSnapshot(parentPath)
298 if err != nil {
299 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
300 }
301 if upToMessageIdx > len(snapshot.Messages) {
302 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
303 }
304
305 // A tool-call row is not a safe provider boundary by itself. Accept the
306 // convenient row index used by existing callers, but extend it through the
307 // immediately paired tool result when necessary. Orphan calls already have
308 // a synthetic result in the shared snapshot.
309 limit := upToMessageIdx
310 if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool {
311 limit++
312 }
313
314 extensionState, err := readExtensionStateAtFork(parentPath, limit)
315 if err != nil {
316 return "", err
317 }
318 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
319 return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff)
320}
321
322// BranchSessionHiddenFromHistory creates a hidden tree branch from a
323// pre-compaction transcript segment. The segment is already repaired and is
324// kept separate from the effective snapshot so normal resume semantics remain
325// unchanged while older fork points stay usable.
326func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
327 if parentPath == "" {
328 return "", errors.New("branch: parent path is empty")
329 }
330 if upToMessageIdx < 0 {
331 return "", errors.New("branch: upToMessageIdx must be >= 0")
332 }
333 if upToMessageIdx > len(segment.Messages) {
334 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
335 }
336
337 snapshot, err := ReadSessionSnapshot(parentPath)
338 if err != nil {
339 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
340 }
341 limit := upToMessageIdx
342 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
343 limit++
344 }
345 extensionState, err := readExtensionStateAtFork(parentPath, limit)
346 if err != nil {
347 return "", err
348 }
349 return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil)
350}
351
352// readExtensionStateAtFork reconstructs the latest extension snapshot that was
353// persisted at or before a branch boundary. Extension state is independent of
354// provider messages, but its timeline still follows the effective message
355// stream across compaction rows.
356func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) {
357 f, err := os.Open(path)
358 if err != nil {
359 return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
360 }
361 defer f.Close()
362
363 state := make(map[string]json.RawMessage)
364 forkState := make(map[string]json.RawMessage)
365 cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
366 clone := make(map[string]json.RawMessage, len(source))
367 for name, raw := range source {
368 clone[name] = append(json.RawMessage(nil), raw...)
369 }
370 return clone
371 }
372 applyState := func(target map[string]json.RawMessage, row sessionLine) {
373 if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
374 delete(target, row.Extension)
375 return
376 }
377 if json.Valid(row.State) {
378 target[row.Extension] = append(json.RawMessage(nil), row.State...)
379 }
380 }
381 effectiveCount := 0
382 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
383 var head sessionLineHead
384 if err := json.Unmarshal(line, &head); err != nil {
385 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
386 }
387 switch head.Type {
388 case "meta", "usage", "rename":
389 return nil
390 case "message":
391 if _, err := hydrateMessage(line); err != nil {
392 return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
393 }
394 effectiveCount++
395 case "compaction":
396 messages, err := hydrateCompaction(line)
397 if err != nil {
398 return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
399 }
400 effectiveCount = len(messages)
401 // A compaction replaces the effective transcript. State that was
402 // recorded anywhere before this boundary belongs to every fork
403 // inside the replacement transcript, even when its old message
404 // index was after the requested fork point.
405 forkState = cloneState(state)
406 case "extension_state":
407 var row sessionLine
408 if err := json.Unmarshal(line, &row); err != nil {
409 return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
410 }
411 if row.Extension == "" || len(row.State) > maxExtensionStateBytes {
412 return nil
413 }
414 applyState(state, row)
415 if effectiveCount <= limit {
416 applyState(forkState, row)
417 }
418 default:
419 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
420 }
421 return nil
422 })
423 if err != nil {
424 return nil, fmt.Errorf("branch: read extension state: %w", err)
425 }
426 return forkState, nil
427}
428
429func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
430 if len(parent.CompactHandoff) == 0 || limit != len(messages) {
431 return nil
432 }
433 return append(json.RawMessage(nil), parent.CompactHandoff...)
434}
435
436func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage) (string, error) {
437 dir := SessionsDir(root, cwd)
438 if err := os.MkdirAll(dir, 0o755); err != nil {
439 return "", err
440 }
441 newID := uuid.NewString()
442 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
443 outPath := filepath.Join(dir, name)
444 // Build in a same-directory temporary file. Rename is atomic only when
445 // the temporary and final paths share a directory; closing the file first
446 // also ensures no buffered writer or descriptor can expose a partial branch.
447 dst, err := os.CreateTemp(dir, ".zut-branch-*.tmp")
448 if err != nil {
449 return "", fmt.Errorf("branch: create temp: %w", err)
450 }
451 tmpPath := dst.Name()
452 if err := dst.Chmod(0o644); err != nil {
453 _ = dst.Close()
454 _ = os.Remove(tmpPath)
455 return "", fmt.Errorf("branch: chmod temp: %w", err)
456 }
457 committed := false
458 defer func() {
459 _ = dst.Close()
460 if !committed {
461 _ = os.Remove(tmpPath)
462 _ = os.Remove(outPath)
463 }
464 }()
465 bw := bufio.NewWriter(dst)
466
467 branchMeta := SessionMeta{
468 ID: newID,
469 CWD: cwd,
470 Model: parent.Model,
471 Provider: parent.Provider,
472 Started: time.Now().UTC(),
473 Version: version,
474 Parent: parent.ID,
475 ForkPoint: limit,
476 HideFromSessions: hideFromSessions,
477 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
478 }
479 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
480 if err != nil {
481 return "", fmt.Errorf("branch: marshal meta: %w", err)
482 }
483 if _, err := bw.Write(metaLine); err != nil {
484 return "", err
485 }
486 if err := bw.WriteByte('\n'); err != nil {
487 return "", err
488 }
489
490 for idx := 0; idx < limit; idx++ {
491 msg := messages[idx]
492 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
493 if err != nil {
494 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
495 }
496 if _, err := bw.Write(line); err != nil {
497 return "", err
498 }
499 if err := bw.WriteByte('\n'); err != nil {
500 return "", err
501 }
502 }
503
504 // Extension snapshots are branch state, not provider-visible transcript
505 // content. Write the latest snapshot that existed at the fork point so a
506 // child branch starts from the same extension state as its copied prefix.
507 extensionNames := make([]string, 0, len(extensionState))
508 for name := range extensionState {
509 extensionNames = append(extensionNames, name)
510 }
511 sort.Strings(extensionNames)
512 for _, name := range extensionNames {
513 line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
514 if err != nil {
515 return "", fmt.Errorf("branch: marshal extension state: %w", err)
516 }
517 if _, err := bw.Write(line); err != nil {
518 return "", fmt.Errorf("branch: write extension state: %w", err)
519 }
520 if err := bw.WriteByte('\n'); err != nil {
521 return "", fmt.Errorf("branch: terminate extension state row: %w", err)
522 }
523 }
524 // Preserve every cumulative checkpoint that belongs to this prefix. The
525 // final two rows are needed to reconstruct LastTurnUsage as a delta.
526 for _, checkpoint := range checkpoints {
527 if checkpoint.MessageCount > limit {
528 continue
529 }
530 usageLine, err := json.Marshal(sessionLine{
531 Type: "usage",
532 Usage: &checkpoint.Cumulative,
533 Cumulative: &checkpoint.Cumulative,
534 })
535 if err != nil {
536 return "", fmt.Errorf("branch: marshal usage: %w", err)
537 }
538 if _, err := bw.Write(usageLine); err != nil {
539 return "", err
540 }
541 if err := bw.WriteByte('\n'); err != nil {
542 return "", err
543 }
544 }
545
546 if err := bw.Flush(); err != nil {
547 return "", err
548 }
549 if err := dst.Sync(); err != nil {
550 return "", fmt.Errorf("branch: sync temp: %w", err)
551 }
552 if err := dst.Close(); err != nil {
553 return "", fmt.Errorf("branch: close temp: %w", err)
554 }
555 if err := os.Rename(tmpPath, outPath); err != nil {
556 return "", fmt.Errorf("branch: commit: %w", err)
557 }
558 committed = true
559 return outPath, nil
560}
561
562func lastUserText(msgs []provider.Message) string {
563 for i := len(msgs) - 1; i >= 0; i-- {
564 if msgs[i].Role != provider.RoleUser {
565 continue
566 }
567 if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
568 return text
569 }
570 }
571 return ""
572}
573
574// TreeNode is one entry in the branch tree returned by
575// BuildSessionTree. Children are populated by linking on Parent ID.
576type TreeNode struct {
577 Summary SessionSummary
578 Meta SessionMeta
579 Children []*TreeNode
580}
581
582// BuildSessionTree loads every session in the cwd dir and returns
583// the forest rooted at parentless sessions, with each non-root
584// session placed under its parent. Used by /session tree to render
585// the branch hierarchy.
586func BuildSessionTree(root, cwd string) []*TreeNode {
587 dir := SessionsDir(root, cwd)
588 entries, err := os.ReadDir(dir)
589 if err != nil {
590 return nil
591 }
592 nodes := make(map[string]*TreeNode)
593 order := []string{}
594 for _, e := range entries {
595 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
596 continue
597 }
598 path := filepath.Join(dir, e.Name())
599 summary := describeSession(path)
600 meta, _ := readSessionMeta(path)
601 if meta.ID == "" {
602 continue
603 }
604 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
605 order = append(order, meta.ID)
606 }
607 return linkSessionTreeNodes(nodes, order)
608}
609
610// BuildSessionTreeStrict is the complete-read counterpart to the permissive
611// forest builder. It validates every session file before linking any node, so
612// callers that need a complete forest cannot silently omit malformed members.
613func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
614 dir := SessionsDir(root, cwd)
615 entries, err := os.ReadDir(dir)
616 if err != nil {
617 if errors.Is(err, os.ErrNotExist) {
618 return nil, nil
619 }
620 return nil, fmt.Errorf("build session tree: read directory: %w", err)
621 }
622 nodes := make(map[string]*TreeNode)
623 order := make([]string, 0, len(entries))
624 for _, e := range entries {
625 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
626 continue
627 }
628 path := filepath.Join(dir, e.Name())
629 snapshot, err := ReadSessionSnapshot(path)
630 if err != nil {
631 return nil, fmt.Errorf("build session tree: read %q: %w", path, err)
632 }
633 if snapshot.Meta.ID == "" {
634 return nil, fmt.Errorf("build session tree: %q has no session id", path)
635 }
636 summary := SessionSummary{
637 Path: path,
638 Started: snapshot.Meta.Started,
639 Model: snapshot.Meta.Model,
640 Provider: snapshot.Meta.Provider,
641 MessageCount: len(snapshot.Messages),
642 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
643 Title: snapshot.Meta.Title,
644 }
645 if len(snapshot.UsageCheckpoints) > 0 {
646 summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
647 }
648 if _, exists := nodes[snapshot.Meta.ID]; exists {
649 return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID)
650 }
651 nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta}
652 order = append(order, snapshot.Meta.ID)
653 }
654 return linkSessionTreeNodes(nodes, order), nil
655}
656
657// BuildSessionTreeFamilyStrict locates the current session using lightweight
658// metadata headers, then strictly validates only that root and its descendants.
659// A corrupt unrelated root in the same cwd bucket must not hide an otherwise
660// readable current family, while a corrupt member of the selected family still
661// fails the whole preflight.
662func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
663 if currentPath == "" {
664 return nil, errors.New("build session family: current path is empty")
665 }
666 dir := SessionsDir(root, cwd)
667 entries, err := os.ReadDir(dir)
668 if err != nil {
669 if errors.Is(err, os.ErrNotExist) {
670 return nil, nil
671 }
672 return nil, fmt.Errorf("build session family: read directory: %w", err)
673 }
674 cleanCurrent := filepath.Clean(currentPath)
675 nodes := make(map[string]*TreeNode)
676 order := make([]string, 0, len(entries))
677 for _, entry := range entries {
678 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
679 continue
680 }
681 path := filepath.Join(dir, entry.Name())
682 meta, err := readSessionMetaHeader(path)
683 if err != nil {
684 if filepath.Clean(path) == cleanCurrent {
685 return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err)
686 }
687 // A file whose header is unreadable cannot be associated with the
688 // selected family. Valid descendants are linked below and fully
689 // preflighted; unrelated corrupt roots stay out of this family.
690 continue
691 }
692 if _, exists := nodes[meta.ID]; exists {
693 return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID)
694 }
695 nodes[meta.ID] = &TreeNode{
696 Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider},
697 Meta: meta,
698 }
699 order = append(order, meta.ID)
700 }
701 roots := linkSessionTreeNodes(nodes, order)
702 var familyRoot *TreeNode
703 for _, candidate := range roots {
704 if treeNodeContainsPath(candidate, cleanCurrent) {
705 familyRoot = candidate
706 break
707 }
708 }
709 if familyRoot == nil {
710 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
711 }
712 var validate func(*TreeNode) error
713 validate = func(node *TreeNode) error {
714 snapshot, err := ReadSessionSnapshot(node.Summary.Path)
715 if err != nil {
716 return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
717 }
718 if snapshot.Meta.ID != node.Meta.ID {
719 return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
720 }
721 node.Meta = snapshot.Meta
722 node.Summary = SessionSummary{
723 Path: node.Summary.Path,
724 Started: snapshot.Meta.Started,
725 Model: snapshot.Meta.Model,
726 Provider: snapshot.Meta.Provider,
727 MessageCount: len(snapshot.Messages),
728 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
729 Title: snapshot.Meta.Title,
730 }
731 if len(snapshot.UsageCheckpoints) > 0 {
732 node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
733 }
734 for _, child := range node.Children {
735 if err := validate(child); err != nil {
736 return err
737 }
738 }
739 return nil
740 }
741 if err := validate(familyRoot); err != nil {
742 return nil, err
743 }
744 return []*TreeNode{familyRoot}, nil
745}
746
747func readSessionMetaHeader(path string) (SessionMeta, error) {
748 f, err := os.Open(path)
749 if err != nil {
750 return SessionMeta{}, err
751 }
752 defer f.Close()
753 line, err := bufio.NewReader(f).ReadBytes('\n')
754 if err != nil && len(line) == 0 {
755 return SessionMeta{}, err
756 }
757 line = bytes.TrimSpace(line)
758 var row sessionLine
759 if err := json.Unmarshal(line, &row); err != nil {
760 return SessionMeta{}, err
761 }
762 if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" {
763 return SessionMeta{}, errors.New("first row is not a valid meta row")
764 }
765 return *row.Meta, nil
766}
767
768func treeNodeContainsPath(node *TreeNode, path string) bool {
769 if node == nil {
770 return false
771 }
772 if filepath.Clean(node.Summary.Path) == filepath.Clean(path) {
773 return true
774 }
775 for _, child := range node.Children {
776 if treeNodeContainsPath(child, path) {
777 return true
778 }
779 }
780 return false
781}
782
783func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode {
784 var roots []*TreeNode
785 for _, id := range order {
786 n := nodes[id]
787 if n.Meta.Parent == "" {
788 roots = append(roots, n)
789 continue
790 }
791 if parent, ok := nodes[n.Meta.Parent]; ok {
792 parent.Children = append(parent.Children, n)
793 } else {
794 // Parent file missing (was manually deleted). Treat as a root so it
795 // still shows up in the tree.
796 roots = append(roots, n)
797 }
798 }
799 return roots
800}
801
802// scanSessionMeta reads JSONL rows without hydrating transcript content and
803// returns the latest valid metadata row. Listing and ID lookup only need this
804// small projection; full snapshot validation remains the responsibility of
805// resume, export, branching, and tree preflight callers.
806func scanSessionMeta(path string) (meta SessionMeta, err error) {
807 return scanSessionMetaContext(context.Background(), path)
808}
809
810func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
811 if err := contextErr(ctx); err != nil {
812 return SessionMeta{}, err
813 }
814 f, err := os.Open(path)
815 if err != nil {
816 return SessionMeta{}, err
817 }
818 defer f.Close()
819
820 var sawMeta bool
821 err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
822 var head sessionLineHead
823 if err := json.Unmarshal(line, &head); err != nil {
824 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
825 }
826 if head.Type == "" {
827 return fmt.Errorf("line %d: missing row type", lineNo)
828 }
829 if !sawMeta && head.Type != "meta" {
830 return fmt.Errorf("line %d: first row is not meta", lineNo)
831 }
832 switch head.Type {
833 case "meta":
834 var row struct {
835 Meta *SessionMeta `json:"meta"`
836 }
837 if err := json.Unmarshal(line, &row); err != nil {
838 return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
839 }
840 if row.Meta == nil || row.Meta.ID == "" {
841 return fmt.Errorf("line %d: meta row has no session id", lineNo)
842 }
843 meta = *row.Meta
844 sawMeta = true
845 case "message", "compaction", "usage", "rename", "extension_state":
846 // These rows are intentionally not hydrated here.
847 default:
848 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
849 }
850 return nil
851 })
852 if err != nil {
853 return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
854 }
855 if !sawMeta {
856 return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
857 }
858 return meta, nil
859}
860
861// readSessionMeta returns the latest validated metadata for path without
862// reconstructing its transcript. Full snapshot validation is performed by
863// callers that need messages rather than listing metadata.
864func readSessionMeta(path string) (SessionMeta, error) {
865 return scanSessionMeta(path)
866}
867
868// FindSessionByID looks up a session file in root/cwd whose meta id
869// matches. Used by /session tree when the user picks an entry. O(n)
870// over the files in the dir; the list is small in practice.
871func FindSessionByID(root, cwd, id string) string {
872 dir := SessionsDir(root, cwd)
873 entries, err := os.ReadDir(dir)
874 if err != nil {
875 return ""
876 }
877 for _, e := range entries {
878 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
879 continue
880 }
881 path := filepath.Join(dir, e.Name())
882 meta, err := scanSessionMeta(path)
883 if err != nil {
884 continue
885 }
886 if meta.ID == id {
887 return path
888 }
889 }
890 return ""
891}
892
893// FindManagedSessionByID looks up a session UUID across zut's managed
894// session stores, independent of cwd. A nil error and empty path mean no
895// matching session was found. Read, storage, metadata, and duplicate-ID
896// failures are returned to the caller instead of being reported as a miss.
897func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
898 if strings.TrimSpace(root) == "" {
899 return "", errors.New("find managed session: root is empty")
900 }
901 if strings.TrimSpace(id) == "" {
902 return "", errors.New("find managed session: id is empty")
903 }
904
905 stores := []string{root}
906 agentsDir := filepath.Join(root, "sessions", "agents")
907 agents, err := os.ReadDir(agentsDir)
908 if err != nil && !errors.Is(err, os.ErrNotExist) {
909 return "", fmt.Errorf("find managed session: read agent stores: %w", err)
910 }
911 if err == nil {
912 for _, entry := range agents {
913 if entry.IsDir() {
914 stores = append(stores, filepath.Join(agentsDir, entry.Name()))
915 }
916 }
917 }
918
919 var matches []string
920 for _, store := range stores {
921 if err := contextErr(ctx); err != nil {
922 return "", err
923 }
924 if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
925 return "", err
926 }
927 }
928 if err := contextErr(ctx); err != nil {
929 return "", err
930 }
931 if len(matches) == 0 {
932 return "", nil
933 }
934 if len(matches) > 1 {
935 return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
936 }
937 return matches[0], nil
938}
939
940func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
941 if err := contextErr(ctx); err != nil {
942 return err
943 }
944 dir := filepath.Join(root, "sessions")
945 entries, err := os.ReadDir(dir)
946 if errors.Is(err, os.ErrNotExist) {
947 return nil
948 }
949 if err != nil {
950 return fmt.Errorf("find managed session: read %q: %w", dir, err)
951 }
952 for _, bucket := range entries {
953 if err := contextErr(ctx); err != nil {
954 return err
955 }
956 if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
957 continue
958 }
959 bucketPath := filepath.Join(dir, bucket.Name())
960 files, err := os.ReadDir(bucketPath)
961 if err != nil {
962 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
963 }
964 for _, file := range files {
965 if err := contextErr(ctx); err != nil {
966 return err
967 }
968 if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
969 continue
970 }
971 path := filepath.Join(bucketPath, file.Name())
972 meta, err := scanSessionMetaContext(ctx, path)
973 if err != nil {
974 return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
975 }
976 if meta.ID == id {
977 *matches = append(*matches, path)
978 }
979 }
980 }
981 return nil
982}
983
984func isSessionBucketName(name string) bool {
985 if len(name) != 16 {
986 return false
987 }
988 for _, r := range name {
989 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
990 return false
991 }
992 }
993 return true
994}
995
996// firstUserPrompt scans forward from the current source position
997// looking for the first user-role message and returns its text.
998// Used to build a humane export filename. Uses Reader instead of
999// Scanner so a very large JSONL row before the first user prompt
1000// cannot trip Scanner's token limit.
1001func firstUserPrompt(src io.Reader) (string, error) {
1002 r := bufio.NewReader(src)
1003 for {
1004 lineBytes, err := r.ReadBytes('\n')
1005 if len(lineBytes) > 0 {
1006 lineBytes = bytes.TrimRight(lineBytes, "\r\n")
1007 var line sessionLine
1008 if err := json.Unmarshal(lineBytes, &line); err == nil {
1009 if line.Type == "message" && line.Message != nil && line.Message.Role == "user" {
1010 b, _ := json.Marshal(line.Message)
1011 var m struct {
1012 Content []struct {
1013 Text string `json:"text"`
1014 } `json:"content"`
1015 }
1016 _ = json.Unmarshal(b, &m)
1017 for _, c := range m.Content {
1018 if c.Text != "" {
1019 return c.Text, nil
1020 }
1021 }
1022 }
1023 }
1024 }
1025 if err == io.EOF {
1026 return "", nil
1027 }
1028 if err != nil {
1029 return "", err
1030 }
1031 }
1032}
1033
1034// filenameFor builds a descriptive .zutsession filename from the
1035// session's start time and, when available, an excerpt of the
1036// first user prompt.
1037func filenameFor(started time.Time, id, firstPrompt string) string {
1038 base := started.UTC().Format("20060102-150405")
1039 if id != "" && len(id) >= 8 {
1040 base += "-" + id[:8]
1041 }
1042 slug := slugify(firstPrompt, 40)
1043 if slug != "" {
1044 base += "-" + slug
1045 }
1046 return base + PortableExt
1047}
1048
1049// slugify lowercases, strips punctuation, collapses whitespace to
1050// hyphens, and truncates to max runes so it's safe as a filename.
1051func slugify(s string, max int) string {
1052 s = strings.TrimSpace(strings.ToLower(s))
1053 if s == "" {
1054 return ""
1055 }
1056 var out strings.Builder
1057 prevDash := false
1058 for _, r := range s {
1059 switch {
1060 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
1061 out.WriteRune(r)
1062 prevDash = false
1063 default:
1064 if !prevDash && out.Len() > 0 {
1065 out.WriteByte('-')
1066 prevDash = true
1067 }
1068 }
1069 if out.Len() >= max {
1070 break
1071 }
1072 }
1073 return strings.TrimRight(out.String(), "-")
1074}