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 now := time.Now()
219 timezone, timezoneOffset := localTimeMetadata(now)
220 importMeta := SessionMeta{
221 ID: newID,
222 CWD: cwd,
223 Model: snapshot.Meta.Model,
224 Provider: snapshot.Meta.Provider,
225 Started: now.UTC(),
226 Version: version,
227 Title: snapshot.Title,
228 Timezone: timezone,
229 TimezoneOffset: timezoneOffset,
230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231 Goal: cloneSessionGoal(snapshot.Meta.Goal),
232 }
233 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
234 if err != nil {
235 return "", fmt.Errorf("import: marshal meta: %w", err)
236 }
237 if _, err := bw.Write(metaLine); err != nil {
238 return "", err
239 }
240 if err := bw.WriteByte('\n'); err != nil {
241 return "", err
242 }
243
244 // Rewind the source and stream every non-meta row. Avoid
245 // bufio.Scanner so exported sessions with huge JSONL rows import
246 // cleanly.
247 if _, err := src.Seek(0, io.SeekStart); err != nil {
248 return "", fmt.Errorf("import: rewind: %w", err)
249 }
250 if err := forEachJSONLLine(src, func(line []byte) error {
251 var h sessionLineHead
252 if err := json.Unmarshal(line, &h); err != nil || h.Type == "meta" {
253 return nil
254 }
255 if _, err := bw.Write(line); err != nil {
256 return err
257 }
258 return bw.WriteByte('\n')
259 }); err != nil {
260 return "", fmt.Errorf("import: read source: %w", err)
261 }
262 if err := bw.Flush(); err != nil {
263 return "", err
264 }
265 return outPath, nil
266}
267
268// BranchSession creates a new session in root/cwd that contains the
269// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
270// assistant+tool rows). The new meta records Parent=<parent id> and
271// ForkPoint=N so /session tree can rebuild the branch topology
272// later. The effective repaired prefix is materialized, and all eligible
273// usage checkpoints at or before the cut are preserved so the running cost
274// tracker can reconstruct the final-turn delta. A boundary that ends on a
275// tool call is extended through its paired tool result.
276//
277// upToMessageIdx is a count over the flat message stream as
278// returned by OpenSession. To "branch at user turn 3" the caller
279// passes the index of that user message in msgs + 1 (so the
280// message itself is included). The caller figures that out; this
281// helper rejects a count outside the current effective snapshot.
282//
283// Returns the path of the new session file, ready for OpenSession.
284func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
285 return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
286}
287
288// BranchSessionHidden creates a branch that participates in /session tree but
289// is hidden from the flat /sessions picker. Used for in-place tree navigation.
290func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
291 return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
292}
293
294func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
295 if parentPath == "" {
296 return "", errors.New("branch: parent path is empty")
297 }
298 if upToMessageIdx < 0 {
299 return "", errors.New("branch: upToMessageIdx must be >= 0")
300 }
301
302 snapshot, err := ReadSessionSnapshot(parentPath)
303 if err != nil {
304 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
305 }
306 if upToMessageIdx > len(snapshot.Messages) {
307 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
308 }
309
310 // A tool-call row is not a safe provider boundary by itself. Accept the
311 // convenient row index used by existing callers, but extend it through the
312 // immediately paired tool result when necessary. Orphan calls already have
313 // a synthetic result in the shared snapshot.
314 limit := upToMessageIdx
315 if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool {
316 limit++
317 }
318
319 extensionState, err := readExtensionStateAtFork(parentPath, limit)
320 if err != nil {
321 return "", err
322 }
323 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
324 goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit)
325 return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal)
326}
327
328// BranchSessionHiddenFromHistory creates a hidden tree branch from a
329// pre-compaction transcript segment. The segment is already repaired and is
330// kept separate from the effective snapshot so normal resume semantics remain
331// unchanged while older fork points stay usable.
332func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
333 if parentPath == "" {
334 return "", errors.New("branch: parent path is empty")
335 }
336 if upToMessageIdx < 0 {
337 return "", errors.New("branch: upToMessageIdx must be >= 0")
338 }
339 if upToMessageIdx > len(segment.Messages) {
340 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
341 }
342
343 snapshot, err := ReadSessionSnapshot(parentPath)
344 if err != nil {
345 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
346 }
347 limit := upToMessageIdx
348 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
349 limit++
350 }
351 extensionState, err := readExtensionStateAtFork(parentPath, limit)
352 if err != nil {
353 return "", err
354 }
355 return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil)
356}
357
358// readExtensionStateAtFork reconstructs the latest extension snapshot that was
359// persisted at or before a branch boundary. Extension state is independent of
360// provider messages, but its timeline still follows the effective message
361// stream across compaction rows.
362func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) {
363 f, err := os.Open(path)
364 if err != nil {
365 return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
366 }
367 defer f.Close()
368
369 state := make(map[string]json.RawMessage)
370 forkState := make(map[string]json.RawMessage)
371 cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
372 clone := make(map[string]json.RawMessage, len(source))
373 for name, raw := range source {
374 clone[name] = append(json.RawMessage(nil), raw...)
375 }
376 return clone
377 }
378 applyState := func(target map[string]json.RawMessage, row sessionLine) {
379 if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
380 delete(target, row.Extension)
381 return
382 }
383 if json.Valid(row.State) {
384 target[row.Extension] = append(json.RawMessage(nil), row.State...)
385 }
386 }
387 effectiveCount := 0
388 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
389 var head sessionLineHead
390 if err := json.Unmarshal(line, &head); err != nil {
391 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
392 }
393 switch head.Type {
394 case "meta", "usage", "rename":
395 return nil
396 case "message":
397 if _, err := hydrateMessage(line); err != nil {
398 return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
399 }
400 effectiveCount++
401 case "compaction":
402 messages, err := hydrateCompaction(line)
403 if err != nil {
404 return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
405 }
406 effectiveCount = len(messages)
407 // A compaction replaces the effective transcript. State that was
408 // recorded anywhere before this boundary belongs to every fork
409 // inside the replacement transcript, even when its old message
410 // index was after the requested fork point.
411 forkState = cloneState(state)
412 case "extension_state":
413 var row sessionLine
414 if err := json.Unmarshal(line, &row); err != nil {
415 return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
416 }
417 if row.Extension == "" || len(row.State) > maxExtensionStateBytes {
418 return nil
419 }
420 applyState(state, row)
421 if effectiveCount <= limit {
422 applyState(forkState, row)
423 }
424 default:
425 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
426 }
427 return nil
428 })
429 if err != nil {
430 return nil, fmt.Errorf("branch: read extension state: %w", err)
431 }
432 return forkState, nil
433}
434
435func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
436 if len(parent.CompactHandoff) == 0 || limit != len(messages) {
437 return nil
438 }
439 return append(json.RawMessage(nil), parent.CompactHandoff...)
440}
441
442func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal {
443 if limit != len(messages) {
444 return nil
445 }
446 return cloneSessionGoal(parent.Goal)
447}
448
449func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
450 if goal == nil {
451 return nil
452 }
453 clone := *goal
454 return &clone
455}
456
457func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
458 dir := SessionsDir(root, cwd)
459 if err := os.MkdirAll(dir, 0o755); err != nil {
460 return "", err
461 }
462 newID := uuid.NewString()
463 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
464 outPath := filepath.Join(dir, name)
465 // Build in a same-directory temporary file. Rename is atomic only when
466 // the temporary and final paths share a directory; closing the file first
467 // also ensures no buffered writer or descriptor can expose a partial branch.
468 dst, err := os.CreateTemp(dir, ".zut-branch-*.tmp")
469 if err != nil {
470 return "", fmt.Errorf("branch: create temp: %w", err)
471 }
472 tmpPath := dst.Name()
473 if err := dst.Chmod(0o644); err != nil {
474 _ = dst.Close()
475 _ = os.Remove(tmpPath)
476 return "", fmt.Errorf("branch: chmod temp: %w", err)
477 }
478 committed := false
479 defer func() {
480 _ = dst.Close()
481 if !committed {
482 _ = os.Remove(tmpPath)
483 _ = os.Remove(outPath)
484 }
485 }()
486 bw := bufio.NewWriter(dst)
487
488 branchMeta := SessionMeta{
489 ID: newID,
490 CWD: cwd,
491 Model: parent.Model,
492 Provider: parent.Provider,
493 Started: time.Now().UTC(),
494 Version: version,
495 Parent: parent.ID,
496 ForkPoint: limit,
497 HideFromSessions: hideFromSessions,
498 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
499 Goal: cloneSessionGoal(goal),
500 }
501 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
502 if err != nil {
503 return "", fmt.Errorf("branch: marshal meta: %w", err)
504 }
505 if _, err := bw.Write(metaLine); err != nil {
506 return "", err
507 }
508 if err := bw.WriteByte('\n'); err != nil {
509 return "", err
510 }
511
512 for idx := 0; idx < limit; idx++ {
513 msg := messages[idx]
514 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
515 if err != nil {
516 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
517 }
518 if _, err := bw.Write(line); err != nil {
519 return "", err
520 }
521 if err := bw.WriteByte('\n'); err != nil {
522 return "", err
523 }
524 }
525
526 // Extension snapshots are branch state, not provider-visible transcript
527 // content. Write the latest snapshot that existed at the fork point so a
528 // child branch starts from the same extension state as its copied prefix.
529 extensionNames := make([]string, 0, len(extensionState))
530 for name := range extensionState {
531 extensionNames = append(extensionNames, name)
532 }
533 sort.Strings(extensionNames)
534 for _, name := range extensionNames {
535 line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
536 if err != nil {
537 return "", fmt.Errorf("branch: marshal extension state: %w", err)
538 }
539 if _, err := bw.Write(line); err != nil {
540 return "", fmt.Errorf("branch: write extension state: %w", err)
541 }
542 if err := bw.WriteByte('\n'); err != nil {
543 return "", fmt.Errorf("branch: terminate extension state row: %w", err)
544 }
545 }
546 // Preserve every cumulative checkpoint that belongs to this prefix. The
547 // final two rows are needed to reconstruct LastTurnUsage as a delta.
548 for _, checkpoint := range checkpoints {
549 if checkpoint.MessageCount > limit {
550 continue
551 }
552 usageLine, err := json.Marshal(sessionLine{
553 Type: "usage",
554 Usage: &checkpoint.Cumulative,
555 Cumulative: &checkpoint.Cumulative,
556 })
557 if err != nil {
558 return "", fmt.Errorf("branch: marshal usage: %w", err)
559 }
560 if _, err := bw.Write(usageLine); err != nil {
561 return "", err
562 }
563 if err := bw.WriteByte('\n'); err != nil {
564 return "", err
565 }
566 }
567
568 if err := bw.Flush(); err != nil {
569 return "", err
570 }
571 if err := dst.Sync(); err != nil {
572 return "", fmt.Errorf("branch: sync temp: %w", err)
573 }
574 if err := dst.Close(); err != nil {
575 return "", fmt.Errorf("branch: close temp: %w", err)
576 }
577 if err := os.Rename(tmpPath, outPath); err != nil {
578 return "", fmt.Errorf("branch: commit: %w", err)
579 }
580 committed = true
581 return outPath, nil
582}
583
584func lastUserText(msgs []provider.Message) string {
585 for i := len(msgs) - 1; i >= 0; i-- {
586 if msgs[i].Role != provider.RoleUser {
587 continue
588 }
589 if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
590 return text
591 }
592 }
593 return ""
594}
595
596// TreeNode is one entry in the branch tree returned by
597// BuildSessionTree. Children are populated by linking on Parent ID.
598type TreeNode struct {
599 Summary SessionSummary
600 Meta SessionMeta
601 Children []*TreeNode
602}
603
604// BuildSessionTree loads every session in the cwd dir and returns
605// the forest rooted at parentless sessions, with each non-root
606// session placed under its parent. Used by /session tree to render
607// the branch hierarchy.
608func BuildSessionTree(root, cwd string) []*TreeNode {
609 dir := SessionsDir(root, cwd)
610 entries, err := os.ReadDir(dir)
611 if err != nil {
612 return nil
613 }
614 nodes := make(map[string]*TreeNode)
615 order := []string{}
616 for _, e := range entries {
617 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
618 continue
619 }
620 path := filepath.Join(dir, e.Name())
621 summary := describeSession(path)
622 meta, _ := readSessionMeta(path)
623 if meta.ID == "" {
624 continue
625 }
626 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
627 order = append(order, meta.ID)
628 }
629 return linkSessionTreeNodes(nodes, order)
630}
631
632// BuildSessionTreeStrict is the complete-read counterpart to the permissive
633// forest builder. It validates every session file before linking any node, so
634// callers that need a complete forest cannot silently omit malformed members.
635func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
636 dir := SessionsDir(root, cwd)
637 entries, err := os.ReadDir(dir)
638 if err != nil {
639 if errors.Is(err, os.ErrNotExist) {
640 return nil, nil
641 }
642 return nil, fmt.Errorf("build session tree: read directory: %w", err)
643 }
644 nodes := make(map[string]*TreeNode)
645 order := make([]string, 0, len(entries))
646 for _, e := range entries {
647 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
648 continue
649 }
650 path := filepath.Join(dir, e.Name())
651 snapshot, err := ReadSessionSnapshot(path)
652 if err != nil {
653 return nil, fmt.Errorf("build session tree: read %q: %w", path, err)
654 }
655 if snapshot.Meta.ID == "" {
656 return nil, fmt.Errorf("build session tree: %q has no session id", path)
657 }
658 summary := SessionSummary{
659 Path: path,
660 Started: snapshot.Meta.Started,
661 Model: snapshot.Meta.Model,
662 Provider: snapshot.Meta.Provider,
663 MessageCount: len(snapshot.Messages),
664 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
665 Title: snapshot.Meta.Title,
666 }
667 if len(snapshot.UsageCheckpoints) > 0 {
668 summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
669 }
670 if _, exists := nodes[snapshot.Meta.ID]; exists {
671 return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID)
672 }
673 nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta}
674 order = append(order, snapshot.Meta.ID)
675 }
676 return linkSessionTreeNodes(nodes, order), nil
677}
678
679// BuildSessionTreeFamilyStrict locates the current session using lightweight
680// metadata headers, then strictly validates only that root and its descendants.
681// A corrupt unrelated root in the same cwd bucket must not hide an otherwise
682// readable current family, while a corrupt member of the selected family still
683// fails the whole preflight.
684func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
685 return BuildSessionTreeFamilyStrictContext(context.Background(), root, cwd, currentPath)
686}
687
688// BuildSessionTreeFamilyStrictContext is the cancellation-aware counterpart
689// to BuildSessionTreeFamilyStrict. It performs all filesystem reads on behalf
690// of callers that must remain responsive while a family is being discovered.
691func BuildSessionTreeFamilyStrictContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
692 roots, err := buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
693 if err != nil || len(roots) == 0 {
694 return roots, err
695 }
696 var validate func(*TreeNode) error
697 validate = func(node *TreeNode) error {
698 if err := contextErr(ctx); err != nil {
699 return err
700 }
701 snapshot, err := readSessionSnapshot(ctx, node.Summary.Path)
702 if err != nil {
703 return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
704 }
705 if snapshot.Meta.ID != node.Meta.ID {
706 return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
707 }
708 node.Meta = snapshot.Meta
709 node.Summary = SessionSummary{
710 Path: node.Summary.Path,
711 Started: snapshot.Meta.Started,
712 Model: snapshot.Meta.Model,
713 Provider: snapshot.Meta.Provider,
714 MessageCount: len(snapshot.Messages),
715 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
716 Title: snapshot.Meta.Title,
717 }
718 if len(snapshot.UsageCheckpoints) > 0 {
719 node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
720 }
721 for _, child := range node.Children {
722 if err := validate(child); err != nil {
723 return err
724 }
725 }
726 return nil
727 }
728 if err := validate(roots[0]); err != nil {
729 return nil, err
730 }
731 return roots, nil
732}
733
734// BuildSessionTreeFamilyContext discovers the current family from metadata
735// headers. Consumers that read every member themselves can start that work in
736// their preferred order without a redundant whole-family snapshot pass.
737func BuildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
738 return buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
739}
740
741func buildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
742 if err := contextErr(ctx); err != nil {
743 return nil, err
744 }
745 if currentPath == "" {
746 return nil, errors.New("build session family: current path is empty")
747 }
748 dir := SessionsDir(root, cwd)
749 entries, err := os.ReadDir(dir)
750 if err != nil {
751 if errors.Is(err, os.ErrNotExist) {
752 return nil, nil
753 }
754 return nil, fmt.Errorf("build session family: read directory: %w", err)
755 }
756 cleanCurrent := filepath.Clean(currentPath)
757 nodes := make(map[string]*TreeNode)
758 order := make([]string, 0, len(entries))
759 for _, entry := range entries {
760 if err := contextErr(ctx); err != nil {
761 return nil, err
762 }
763 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
764 continue
765 }
766 path := filepath.Join(dir, entry.Name())
767 meta, err := readSessionMetaHeader(path)
768 if err != nil {
769 if filepath.Clean(path) == cleanCurrent {
770 return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err)
771 }
772 // A file whose header is unreadable cannot be associated with the
773 // selected family. Valid descendants are linked below and fully
774 // preflighted; unrelated corrupt roots stay out of this family.
775 continue
776 }
777 if _, exists := nodes[meta.ID]; exists {
778 return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID)
779 }
780 nodes[meta.ID] = &TreeNode{
781 Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider},
782 Meta: meta,
783 }
784 order = append(order, meta.ID)
785 }
786 roots := linkSessionTreeNodes(nodes, order)
787 var familyRoot *TreeNode
788 for _, candidate := range roots {
789 if treeNodeContainsPath(candidate, cleanCurrent) {
790 familyRoot = candidate
791 break
792 }
793 }
794 if familyRoot == nil {
795 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
796 }
797 return []*TreeNode{familyRoot}, nil
798}
799
800func readSessionMetaHeader(path string) (SessionMeta, error) {
801 f, err := os.Open(path)
802 if err != nil {
803 return SessionMeta{}, err
804 }
805 defer f.Close()
806 line, err := bufio.NewReader(f).ReadBytes('\n')
807 if err != nil && len(line) == 0 {
808 return SessionMeta{}, err
809 }
810 line = bytes.TrimSpace(line)
811 var row sessionLine
812 if err := json.Unmarshal(line, &row); err != nil {
813 return SessionMeta{}, err
814 }
815 if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" {
816 return SessionMeta{}, errors.New("first row is not a valid meta row")
817 }
818 return *row.Meta, nil
819}
820
821func treeNodeContainsPath(node *TreeNode, path string) bool {
822 if node == nil {
823 return false
824 }
825 if filepath.Clean(node.Summary.Path) == filepath.Clean(path) {
826 return true
827 }
828 for _, child := range node.Children {
829 if treeNodeContainsPath(child, path) {
830 return true
831 }
832 }
833 return false
834}
835
836func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode {
837 var roots []*TreeNode
838 for _, id := range order {
839 n := nodes[id]
840 if n.Meta.Parent == "" {
841 roots = append(roots, n)
842 continue
843 }
844 if parent, ok := nodes[n.Meta.Parent]; ok {
845 parent.Children = append(parent.Children, n)
846 } else {
847 // Parent file missing (was manually deleted). Treat as a root so it
848 // still shows up in the tree.
849 roots = append(roots, n)
850 }
851 }
852 return roots
853}
854
855// scanSessionMeta reads JSONL rows without hydrating transcript content and
856// returns the latest valid metadata row. Listing and ID lookup only need this
857// small projection; full snapshot validation remains the responsibility of
858// resume, export, branching, and tree preflight callers.
859func scanSessionMeta(path string) (meta SessionMeta, err error) {
860 return scanSessionMetaContext(context.Background(), path)
861}
862
863func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
864 if err := contextErr(ctx); err != nil {
865 return SessionMeta{}, err
866 }
867 f, err := os.Open(path)
868 if err != nil {
869 return SessionMeta{}, err
870 }
871 defer f.Close()
872
873 var sawMeta bool
874 err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
875 var head sessionLineHead
876 if err := json.Unmarshal(line, &head); err != nil {
877 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
878 }
879 if head.Type == "" {
880 return fmt.Errorf("line %d: missing row type", lineNo)
881 }
882 if !sawMeta && head.Type != "meta" {
883 return fmt.Errorf("line %d: first row is not meta", lineNo)
884 }
885 switch head.Type {
886 case "meta":
887 var row struct {
888 Meta *SessionMeta `json:"meta"`
889 }
890 if err := json.Unmarshal(line, &row); err != nil {
891 return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
892 }
893 if row.Meta == nil || row.Meta.ID == "" {
894 return fmt.Errorf("line %d: meta row has no session id", lineNo)
895 }
896 meta = *row.Meta
897 sawMeta = true
898 case "message", "compaction", "usage", "rename", "extension_state":
899 // These rows are intentionally not hydrated here.
900 default:
901 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
902 }
903 return nil
904 })
905 if err != nil {
906 return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
907 }
908 if !sawMeta {
909 return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
910 }
911 return meta, nil
912}
913
914// readSessionMeta returns the latest validated metadata for path without
915// reconstructing its transcript. Full snapshot validation is performed by
916// callers that need messages rather than listing metadata.
917func readSessionMeta(path string) (SessionMeta, error) {
918 return scanSessionMeta(path)
919}
920
921// FindSessionByID looks up a session file in root/cwd whose meta id
922// matches. Used by /session tree when the user picks an entry. O(n)
923// over the files in the dir; the list is small in practice.
924func FindSessionByID(root, cwd, id string) string {
925 dir := SessionsDir(root, cwd)
926 entries, err := os.ReadDir(dir)
927 if err != nil {
928 return ""
929 }
930 for _, e := range entries {
931 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
932 continue
933 }
934 path := filepath.Join(dir, e.Name())
935 meta, err := scanSessionMeta(path)
936 if err != nil {
937 continue
938 }
939 if meta.ID == id {
940 return path
941 }
942 }
943 return ""
944}
945
946// FindManagedSessionByID looks up a session UUID across zut's managed
947// session stores, independent of cwd. A nil error and empty path mean no
948// matching session was found. Read, storage, metadata, and duplicate-ID
949// failures are returned to the caller instead of being reported as a miss.
950func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
951 if strings.TrimSpace(root) == "" {
952 return "", errors.New("find managed session: root is empty")
953 }
954 if strings.TrimSpace(id) == "" {
955 return "", errors.New("find managed session: id is empty")
956 }
957
958 stores := []string{root}
959 agentsDir := filepath.Join(root, "sessions", "agents")
960 agents, err := os.ReadDir(agentsDir)
961 if err != nil && !errors.Is(err, os.ErrNotExist) {
962 return "", fmt.Errorf("find managed session: read agent stores: %w", err)
963 }
964 if err == nil {
965 for _, entry := range agents {
966 if entry.IsDir() {
967 stores = append(stores, filepath.Join(agentsDir, entry.Name()))
968 }
969 }
970 }
971
972 var matches []string
973 for _, store := range stores {
974 if err := contextErr(ctx); err != nil {
975 return "", err
976 }
977 if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
978 return "", err
979 }
980 }
981 if err := contextErr(ctx); err != nil {
982 return "", err
983 }
984 if len(matches) == 0 {
985 return "", nil
986 }
987 if len(matches) > 1 {
988 return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
989 }
990 return matches[0], nil
991}
992
993func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
994 if err := contextErr(ctx); err != nil {
995 return err
996 }
997 dir := filepath.Join(root, "sessions")
998 entries, err := os.ReadDir(dir)
999 if errors.Is(err, os.ErrNotExist) {
1000 return nil
1001 }
1002 if err != nil {
1003 return fmt.Errorf("find managed session: read %q: %w", dir, err)
1004 }
1005 for _, bucket := range entries {
1006 if err := contextErr(ctx); err != nil {
1007 return err
1008 }
1009 if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
1010 continue
1011 }
1012 bucketPath := filepath.Join(dir, bucket.Name())
1013 files, err := os.ReadDir(bucketPath)
1014 if err != nil {
1015 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
1016 }
1017 for _, file := range files {
1018 if err := contextErr(ctx); err != nil {
1019 return err
1020 }
1021 if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
1022 continue
1023 }
1024 path := filepath.Join(bucketPath, file.Name())
1025 meta, err := scanSessionMetaContext(ctx, path)
1026 if err != nil {
1027 return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
1028 }
1029 if meta.ID == id {
1030 *matches = append(*matches, path)
1031 }
1032 }
1033 }
1034 return nil
1035}
1036
1037func isSessionBucketName(name string) bool {
1038 if len(name) != 16 {
1039 return false
1040 }
1041 for _, r := range name {
1042 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
1043 return false
1044 }
1045 }
1046 return true
1047}
1048
1049// firstUserPrompt scans forward from the current source position
1050// looking for the first user-role message and returns its text.
1051// Used to build a humane export filename. Uses Reader instead of
1052// Scanner so a very large JSONL row before the first user prompt
1053// cannot trip Scanner's token limit.
1054func firstUserPrompt(src io.Reader) (string, error) {
1055 r := bufio.NewReader(src)
1056 for {
1057 lineBytes, err := r.ReadBytes('\n')
1058 if len(lineBytes) > 0 {
1059 lineBytes = bytes.TrimRight(lineBytes, "\r\n")
1060 var line sessionLine
1061 if err := json.Unmarshal(lineBytes, &line); err == nil {
1062 if line.Type == "message" && line.Message != nil && line.Message.Role == "user" {
1063 b, _ := json.Marshal(line.Message)
1064 var m struct {
1065 Content []struct {
1066 Text string `json:"text"`
1067 } `json:"content"`
1068 }
1069 _ = json.Unmarshal(b, &m)
1070 for _, c := range m.Content {
1071 if c.Text != "" {
1072 return c.Text, nil
1073 }
1074 }
1075 }
1076 }
1077 }
1078 if err == io.EOF {
1079 return "", nil
1080 }
1081 if err != nil {
1082 return "", err
1083 }
1084 }
1085}
1086
1087// filenameFor builds a descriptive .zutsession filename from the
1088// session's start time and, when available, an excerpt of the
1089// first user prompt.
1090func filenameFor(started time.Time, id, firstPrompt string) string {
1091 base := started.UTC().Format("20060102-150405")
1092 if id != "" && len(id) >= 8 {
1093 base += "-" + id[:8]
1094 }
1095 slug := slugify(firstPrompt, 40)
1096 if slug != "" {
1097 base += "-" + slug
1098 }
1099 return base + PortableExt
1100}
1101
1102// slugify lowercases, strips punctuation, collapses whitespace to
1103// hyphens, and truncates to max runes so it's safe as a filename.
1104func slugify(s string, max int) string {
1105 s = strings.TrimSpace(strings.ToLower(s))
1106 if s == "" {
1107 return ""
1108 }
1109 var out strings.Builder
1110 prevDash := false
1111 for _, r := range s {
1112 switch {
1113 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
1114 out.WriteRune(r)
1115 prevDash = false
1116 default:
1117 if !prevDash && out.Len() > 0 {
1118 out.WriteByte('-')
1119 prevDash = true
1120 }
1121 }
1122 if out.Len() >= max {
1123 break
1124 }
1125 }
1126 return strings.TrimRight(out.String(), "-")
1127}