v0.1.20 e52a560

session_portable.go RawHistoryFolder

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

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