v0.2.14 63e33d9

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	"strings"
13	"time"
14
15	"github.com/google/uuid"
16)
17
18// PortableExt is the filesystem extension used for exported sessions.
19// A ".zotsession" is just a zot JSONL session file with the meta
20// header rewritten so the importing user gets fresh ownership.
21const PortableExt = ".zotsession"
22
23// ExportSession writes the session at srcPath to dstPath as a
24// portable .zotsession file. If dstPath is an existing directory the
25// file is created inside it with a name derived from the session's
26// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zotsession"). The
27// destination's directory is created if needed. Returns the final
28// resolved path so the caller can tell the user where it landed.
29//
30// The on-disk format is unchanged from a live session; only the
31// meta.cwd is stripped of its per-machine prefix (the importing
32// user doesn't care what directory it came from). Everything else
33// round-trips byte-for-byte.
34func ExportSession(srcPath, dstPath string) (string, error) {
35	if srcPath == "" {
36		return "", errors.New("export: source path is empty")
37	}
38	if dstPath == "" {
39		return "", errors.New("export: destination path is empty")
40	}
41
42	// Read the source meta up-front so we can name the output sensibly
43	// when dstPath is a directory, and so we can validate it's a real
44	// session before starting to write.
45	src, err := os.Open(srcPath)
46	if err != nil {
47		return "", fmt.Errorf("export: open source: %w", err)
48	}
49	defer src.Close()
50
51	sc := bufio.NewScanner(src)
52	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
53	if !sc.Scan() {
54		return "", errors.New("export: session file is empty")
55	}
56	var head sessionLine
57	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
58		return "", fmt.Errorf("export: parse meta: %w", err)
59	}
60	if head.Type != "meta" || head.Meta == nil {
61		return "", errors.New("export: first line is not a meta row")
62	}
63
64	// Scan the rest of the file for the first user message so we can
65	// build a humane filename. Only reads if dstPath doesn't already
66	// end in .zotsession.
67	firstPrompt := ""
68	if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
69		if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
70			p, err := firstUserPrompt(src)
71			if err != nil {
72				return "", fmt.Errorf("export: read first prompt: %w", err)
73			}
74			firstPrompt = p
75		}
76	}
77
78	// Resolve dstPath: if it's a directory, build a name inside it.
79	outPath := dstPath
80	if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
81		name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
82		outPath = filepath.Join(dstPath, name)
83	} else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
84		outPath += PortableExt
85	}
86
87	// Re-open the source from the top since we advanced the scanner.
88	if _, err := src.Seek(0, io.SeekStart); err != nil {
89		return "", fmt.Errorf("export: rewind: %w", err)
90	}
91
92	if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
93		return "", fmt.Errorf("export: mkdir dst: %w", err)
94	}
95	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
96	if err != nil {
97		return "", fmt.Errorf("export: create dst: %w", err)
98	}
99	defer dst.Close()
100	bw := bufio.NewWriter(dst)
101
102	// Rewrite the meta row: strip the cwd (the importing user has
103	// their own) and keep everything else identical. ID stays so the
104	// export is traceable; the importer will rotate to a fresh ID.
105	exportMeta := *head.Meta
106	exportMeta.CWD = ""
107	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
108	if err != nil {
109		return "", fmt.Errorf("export: marshal meta: %w", err)
110	}
111	if _, err := bw.Write(metaLine); err != nil {
112		return "", err
113	}
114	if err := bw.WriteByte('\n'); err != nil {
115		return "", err
116	}
117
118	// Stream every non-meta row verbatim. Use ReadBytes instead of
119	// bufio.Scanner: large sessions can contain very long JSONL rows
120	// (image blocks, big tool outputs, compacted history) that exceed
121	// Scanner's token limit and fail with "token too long".
122	r := bufio.NewReader(src)
123	for {
124		line, err := r.ReadBytes('\n')
125		if len(line) > 0 {
126			line = bytes.TrimRight(line, "\r\n")
127			var h sessionLineHead
128			if err := json.Unmarshal(line, &h); err == nil && h.Type != "meta" {
129				if _, werr := bw.Write(line); werr != nil {
130					return "", werr
131				}
132				if werr := bw.WriteByte('\n'); werr != nil {
133					return "", werr
134				}
135			}
136		}
137		if err == io.EOF {
138			break
139		}
140		if err != nil {
141			return "", fmt.Errorf("export: read source: %w", err)
142		}
143	}
144	if err := bw.Flush(); err != nil {
145		return "", err
146	}
147	return outPath, nil
148}
149
150// ImportSession copies the .zotsession file at srcPath into the
151// running user's session store under the given root+cwd, rewriting
152// the meta's id / cwd / started fields so the imported session is
153// owned by the current user / directory / clock. Returns the path
154// of the created session file, ready to pass to OpenSession.
155//
156// The imported session is a first-class zot session: it'll show up
157// in /sessions, /jump, and on-disk summaries just like any other.
158// Messages and usage rows are preserved verbatim.
159func ImportSession(srcPath, root, cwd, version string) (string, error) {
160	if srcPath == "" {
161		return "", errors.New("import: source path is empty")
162	}
163	src, err := os.Open(srcPath)
164	if err != nil {
165		return "", fmt.Errorf("import: open source: %w", err)
166	}
167	defer src.Close()
168
169	// Validate the file header before committing to a destination.
170	sc := bufio.NewScanner(src)
171	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
172	if !sc.Scan() {
173		return "", errors.New("import: session file is empty")
174	}
175	var head sessionLine
176	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
177		return "", fmt.Errorf("import: parse meta: %w", err)
178	}
179	if head.Type != "meta" || head.Meta == nil {
180		return "", errors.New("import: first line is not a meta row")
181	}
182
183	// Build the destination inside the current cwd's session dir
184	// with a fresh timestamped name.
185	dir := SessionsDir(root, cwd)
186	if err := os.MkdirAll(dir, 0o755); err != nil {
187		return "", err
188	}
189	newID := uuid.NewString()
190	name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
191	outPath := filepath.Join(dir, name)
192	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
193	if err != nil {
194		return "", fmt.Errorf("import: create dst: %w", err)
195	}
196	defer dst.Close()
197	bw := bufio.NewWriter(dst)
198
199	// Write a fresh meta row claiming ownership.
200	importMeta := SessionMeta{
201		ID:       newID,
202		CWD:      cwd,
203		Model:    head.Meta.Model,
204		Provider: head.Meta.Provider,
205		Started:  time.Now().UTC(),
206		Version:  version,
207	}
208	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
209	if err != nil {
210		return "", fmt.Errorf("import: marshal meta: %w", err)
211	}
212	if _, err := bw.Write(metaLine); err != nil {
213		return "", err
214	}
215	if err := bw.WriteByte('\n'); err != nil {
216		return "", err
217	}
218
219	// Rewind the source and stream every non-meta row. Avoid
220	// bufio.Scanner so exported sessions with huge JSONL rows import
221	// cleanly.
222	if _, err := src.Seek(0, io.SeekStart); err != nil {
223		return "", fmt.Errorf("import: rewind: %w", err)
224	}
225	if err := forEachJSONLLine(src, func(line []byte) error {
226		var h sessionLineHead
227		if err := json.Unmarshal(line, &h); err != nil || h.Type == "meta" {
228			return nil
229		}
230		if _, err := bw.Write(line); err != nil {
231			return err
232		}
233		return bw.WriteByte('\n')
234	}); err != nil {
235		return "", fmt.Errorf("import: read source: %w", err)
236	}
237	if err := bw.Flush(); err != nil {
238		return "", err
239	}
240	return outPath, nil
241}
242
243// BranchSession creates a new session in root/cwd that contains the
244// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
245// assistant+tool rows). The new meta records Parent=<parent id> and
246// ForkPoint=N so /session tree can rebuild the branch topology
247// later. All non-message rows (usage) are preserved up to the cut
248// point so the running cost tracker stays accurate.
249//
250// upToMessageIdx is a count over the flat message stream as
251// returned by OpenSession. To "branch at user turn 3" the caller
252// passes the index of that user message in msgs + 1 (so the
253// message itself is included). The caller figures that out; this
254// helper just copies the first N message rows.
255//
256// Returns the path of the new session file, ready for OpenSession.
257func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
258	if parentPath == "" {
259		return "", errors.New("branch: parent path is empty")
260	}
261	if upToMessageIdx < 0 {
262		return "", errors.New("branch: upToMessageIdx must be >= 0")
263	}
264
265	src, err := os.Open(parentPath)
266	if err != nil {
267		return "", fmt.Errorf("branch: open parent: %w", err)
268	}
269	defer src.Close()
270
271	// Read the parent meta so we can copy model/provider and record
272	// the parent id on the child.
273	sc := bufio.NewScanner(src)
274	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
275	if !sc.Scan() {
276		return "", errors.New("branch: parent session is empty")
277	}
278	var head sessionLine
279	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
280		return "", fmt.Errorf("branch: parse parent meta: %w", err)
281	}
282	if head.Type != "meta" || head.Meta == nil {
283		return "", errors.New("branch: parent first line is not a meta row")
284	}
285	parentMeta := *head.Meta
286
287	// Build the destination file.
288	dir := SessionsDir(root, cwd)
289	if err := os.MkdirAll(dir, 0o755); err != nil {
290		return "", err
291	}
292	newID := uuid.NewString()
293	name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
294	outPath := filepath.Join(dir, name)
295	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
296	if err != nil {
297		return "", fmt.Errorf("branch: create dst: %w", err)
298	}
299	defer dst.Close()
300	bw := bufio.NewWriter(dst)
301
302	// Write the branch meta.
303	branchMeta := SessionMeta{
304		ID:        newID,
305		CWD:       cwd,
306		Model:     parentMeta.Model,
307		Provider:  parentMeta.Provider,
308		Started:   time.Now().UTC(),
309		Version:   version,
310		Parent:    parentMeta.ID,
311		ForkPoint: upToMessageIdx,
312	}
313	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
314	if err != nil {
315		return "", fmt.Errorf("branch: marshal meta: %w", err)
316	}
317	if _, err := bw.Write(metaLine); err != nil {
318		return "", err
319	}
320	if err := bw.WriteByte('\n'); err != nil {
321		return "", err
322	}
323
324	// Copy message rows up to the cut point, plus all usage rows
325	// that land before the cut (they describe the cost of those
326	// messages). Rewind and use the large-row-safe JSONL reader.
327	if _, err := src.Seek(0, io.SeekStart); err != nil {
328		return "", fmt.Errorf("branch: rewind parent: %w", err)
329	}
330	msgCount := 0
331	if err := forEachJSONLLine(src, func(line []byte) error {
332		if msgCount >= upToMessageIdx {
333			return io.EOF
334		}
335		var h sessionLineHead
336		if err := json.Unmarshal(line, &h); err != nil {
337			return nil
338		}
339		switch h.Type {
340		case "message":
341			if _, err := bw.Write(line); err != nil {
342				return err
343			}
344			if err := bw.WriteByte('\n'); err != nil {
345				return err
346			}
347			msgCount++
348		case "usage":
349			if _, err := bw.Write(line); err != nil {
350				return err
351			}
352			if err := bw.WriteByte('\n'); err != nil {
353				return err
354			}
355			// don't increment msgCount for usage rows
356		}
357		return nil
358	}); err != nil && err != io.EOF {
359		return "", fmt.Errorf("branch: read parent: %w", err)
360	}
361	if err := bw.Flush(); err != nil {
362		return "", err
363	}
364	return outPath, nil
365}
366
367// TreeNode is one entry in the branch tree returned by
368// BuildSessionTree. Children are populated by linking on Parent ID.
369type TreeNode struct {
370	Summary  SessionSummary
371	Meta     SessionMeta
372	Children []*TreeNode
373}
374
375// BuildSessionTree loads every session in the cwd dir and returns
376// the forest rooted at parentless sessions, with each non-root
377// session placed under its parent. Used by /session tree to render
378// the branch hierarchy.
379func BuildSessionTree(root, cwd string) []*TreeNode {
380	dir := SessionsDir(root, cwd)
381	entries, err := os.ReadDir(dir)
382	if err != nil {
383		return nil
384	}
385	nodes := make(map[string]*TreeNode)
386	order := []string{}
387	for _, e := range entries {
388		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
389			continue
390		}
391		path := filepath.Join(dir, e.Name())
392		summary := describeSession(path)
393		meta, _ := readSessionMeta(path)
394		if meta.ID == "" {
395			continue
396		}
397		nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
398		order = append(order, meta.ID)
399	}
400	var roots []*TreeNode
401	for _, id := range order {
402		n := nodes[id]
403		if n.Meta.Parent == "" {
404			roots = append(roots, n)
405			continue
406		}
407		if parent, ok := nodes[n.Meta.Parent]; ok {
408			parent.Children = append(parent.Children, n)
409		} else {
410			// Parent file missing (was manually deleted). Treat as
411			// a root so it still shows up in the tree.
412			roots = append(roots, n)
413		}
414	}
415	return roots
416}
417
418// readSessionMeta opens path, reads the meta row, and returns it.
419// Empty SessionMeta when the file is missing or not a valid session.
420func readSessionMeta(path string) (SessionMeta, error) {
421	f, err := os.Open(path)
422	if err != nil {
423		return SessionMeta{}, err
424	}
425	defer f.Close()
426	sc := bufio.NewScanner(f)
427	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
428	if !sc.Scan() {
429		return SessionMeta{}, errors.New("empty file")
430	}
431	var line sessionLine
432	if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
433		return SessionMeta{}, err
434	}
435	if line.Type != "meta" || line.Meta == nil {
436		return SessionMeta{}, errors.New("first line is not meta")
437	}
438	return *line.Meta, nil
439}
440
441// FindSessionByID looks up a session file in root/cwd whose meta id
442// matches. Used by /session tree when the user picks an entry. O(n)
443// over the files in the dir; the list is small in practice.
444func FindSessionByID(root, cwd, id string) string {
445	dir := SessionsDir(root, cwd)
446	entries, err := os.ReadDir(dir)
447	if err != nil {
448		return ""
449	}
450	for _, e := range entries {
451		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
452			continue
453		}
454		path := filepath.Join(dir, e.Name())
455		meta, err := readSessionMeta(path)
456		if err != nil {
457			continue
458		}
459		if meta.ID == id {
460			return path
461		}
462	}
463	return ""
464}
465
466// firstUserPrompt scans forward from the current source position
467// looking for the first user-role message and returns its text.
468// Used to build a humane export filename. Uses Reader instead of
469// Scanner so a very large JSONL row before the first user prompt
470// cannot trip Scanner's token limit.
471func firstUserPrompt(src io.Reader) (string, error) {
472	r := bufio.NewReader(src)
473	for {
474		lineBytes, err := r.ReadBytes('\n')
475		if len(lineBytes) > 0 {
476			lineBytes = bytes.TrimRight(lineBytes, "\r\n")
477			var line sessionLine
478			if err := json.Unmarshal(lineBytes, &line); err == nil {
479				if line.Type == "message" && line.Message != nil && line.Message.Role == "user" {
480					b, _ := json.Marshal(line.Message)
481					var m struct {
482						Content []struct {
483							Text string `json:"text"`
484						} `json:"content"`
485					}
486					_ = json.Unmarshal(b, &m)
487					for _, c := range m.Content {
488						if c.Text != "" {
489							return c.Text, nil
490						}
491					}
492				}
493			}
494		}
495		if err == io.EOF {
496			return "", nil
497		}
498		if err != nil {
499			return "", err
500		}
501	}
502}
503
504// filenameFor builds a descriptive .zotsession filename from the
505// session's start time and, when available, an excerpt of the
506// first user prompt.
507func filenameFor(started time.Time, id, firstPrompt string) string {
508	base := started.UTC().Format("20060102-150405")
509	if id != "" && len(id) >= 8 {
510		base += "-" + id[:8]
511	}
512	slug := slugify(firstPrompt, 40)
513	if slug != "" {
514		base += "-" + slug
515	}
516	return base + PortableExt
517}
518
519// slugify lowercases, strips punctuation, collapses whitespace to
520// hyphens, and truncates to max runes so it's safe as a filename.
521func slugify(s string, max int) string {
522	s = strings.TrimSpace(strings.ToLower(s))
523	if s == "" {
524		return ""
525	}
526	var out strings.Builder
527	prevDash := false
528	for _, r := range s {
529		switch {
530		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
531			out.WriteRune(r)
532			prevDash = false
533		default:
534			if !prevDash && out.Len() > 0 {
535				out.WriteByte('-')
536				prevDash = true
537			}
538		}
539		if out.Len() >= max {
540			break
541		}
542	}
543	return strings.TrimRight(out.String(), "-")
544}

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