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}