v0.38.0 58133ea

session_portable.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close