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