v0.12.2 1adbe2f

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

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 477

↑↓ moveenter openesc close