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}