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// firstUserPrompt scans forward from the current scanner position
242// looking for the first user-role message and returns its text
243// (trimmed, short). Used to build a humane export filename.
244func firstUserPrompt(sc *bufio.Scanner) string {
245 for sc.Scan() {
246 var line sessionLine
247 if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
248 continue
249 }
250 if line.Type != "message" || line.Message == nil || line.Message.Role != "user" {
251 continue
252 }
253 for _, c := range line.Message.Content {
254 // Use type name to avoid an import of provider here beyond
255 // the already-imported alias; TextBlock is the only content
256 // shape that yields a useful preview, so we just look for
257 // something with a reasonable string form.
258 s := fmt.Sprintf("%v", c)
259 _ = s // formatted value is too noisy; go straight to typed path
260 }
261 // Simpler: marshal the message and fish out the first "text"
262 // we can find. Avoids reaching into the provider package just
263 // for an interface type check.
264 b, _ := json.Marshal(line.Message)
265 var m struct {
266 Content []struct {
267 Text string `json:"text"`
268 } `json:"content"`
269 }
270 _ = json.Unmarshal(b, &m)
271 for _, c := range m.Content {
272 if c.Text != "" {
273 return c.Text
274 }
275 }
276 }
277 return ""
278}
279
280// filenameFor builds a descriptive .zotsession filename from the
281// session's start time and, when available, an excerpt of the
282// first user prompt.
283func filenameFor(started time.Time, id, firstPrompt string) string {
284 base := started.UTC().Format("20060102-150405")
285 if id != "" && len(id) >= 8 {
286 base += "-" + id[:8]
287 }
288 slug := slugify(firstPrompt, 40)
289 if slug != "" {
290 base += "-" + slug
291 }
292 return base + PortableExt
293}
294
295// slugify lowercases, strips punctuation, collapses whitespace to
296// hyphens, and truncates to max runes so it's safe as a filename.
297func slugify(s string, max int) string {
298 s = strings.TrimSpace(strings.ToLower(s))
299 if s == "" {
300 return ""
301 }
302 var out strings.Builder
303 prevDash := false
304 for _, r := range s {
305 switch {
306 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
307 out.WriteRune(r)
308 prevDash = false
309 default:
310 if !prevDash && out.Len() > 0 {
311 out.WriteByte('-')
312 prevDash = true
313 }
314 }
315 if out.Len() >= max {
316 break
317 }
318 }
319 return strings.TrimRight(out.String(), "-")
320}