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