v0.7.0 3110ba7

session_portable.go RawHistoryFolder

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}

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