v0.3.9 966d045

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

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