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}