1package core
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/google/uuid"
18 "github.com/patriceckhart/zot/packages/provider"
19)
20
21// Session is a JSONL-backed conversation transcript tied to a cwd.
22type Session struct {
23 ID string
24 Path string
25 Meta SessionMeta
26 writer *os.File
27 buf *bufio.Writer
28
29 // freshFile is true when the file was created by NewSession (this
30 // process owns it) and false when OpenSession reopened an existing
31 // transcript. Used by Close() to delete the file if the run never
32 // appended any messages — prevents a flood of empty session files
33 // from sessions the user opens then exits without prompting.
34 freshFile bool
35
36 // messagesAppended counts AppendMessage calls. Combined with
37 // freshFile it tells Close() whether the session left any content
38 // worth keeping.
39 messagesAppended int
40}
41
42// SessionMeta is written as the first line of every session file.
43type SessionMeta struct {
44 ID string `json:"id"`
45 CWD string `json:"cwd"`
46 Model string `json:"model"`
47 Provider string `json:"provider"`
48 Started time.Time `json:"started"`
49 Version string `json:"version"`
50 Title string `json:"title,omitempty"`
51
52 // Parent is the ID of the session this one was forked from, or
53 // empty for top-level sessions. The tree picker walks parents
54 // upward and sibling files (same cwd dir, same parent ID)
55 // laterally to render the branch topology.
56 Parent string `json:"parent,omitempty"`
57 // ForkPoint is the 0-indexed message position within the parent
58 // transcript where this branch diverges. Messages 0..ForkPoint-1
59 // are copied from the parent verbatim; the user's next turn on
60 // the child session continues from there.
61 ForkPoint int `json:"fork_point,omitempty"`
62 // HideFromSessions hides internal tree-navigation branches from the
63 // flat /sessions picker while keeping them available to /session tree.
64 HideFromSessions bool `json:"hide_from_sessions,omitempty"`
65}
66
67// sessionLine is the on-disk row type. Message is kept as a raw
68// JSON message on reads (because Content is an interface slice that
69// the default unmarshaler cannot reconstruct); it is written with a
70// regular provider.Message value.
71type sessionLine struct {
72 Type string `json:"type"`
73 Meta *SessionMeta `json:"meta,omitempty"`
74 Message *provider.Message `json:"message,omitempty"`
75 Messages []provider.Message `json:"messages,omitempty"`
76 Usage *provider.Usage `json:"usage,omitempty"`
77 Cumulative *provider.Usage `json:"cumulative,omitempty"`
78}
79
80type sessionLineHead struct {
81 Type string `json:"type"`
82}
83
84// SessionsDir returns the per-cwd sessions directory under root.
85func SessionsDir(root, cwd string) string {
86 sum := sha256.Sum256([]byte(cwd))
87 short := hex.EncodeToString(sum[:8])
88 return filepath.Join(root, "sessions", short)
89}
90
91// NewSession creates and opens a new session file under
92// SessionsDir(root, cwd) with an autogenerated, time-stamped name.
93func NewSession(root, cwd, providerName, model, version string) (*Session, error) {
94 dir := SessionsDir(root, cwd)
95 if err := os.MkdirAll(dir, 0o755); err != nil {
96 return nil, err
97 }
98 id := uuid.NewString()
99 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])
100 p := filepath.Join(dir, name)
101 return newSessionAt(p, cwd, providerName, model, version)
102}
103
104// NewSessionAtPath creates a session at an explicit file path. Used
105// by callers (notably the swarm-agent child) that need the session
106// file to live at a path chosen by their parent rather than under
107// SessionsDir. Returns an error if the file already exists — use
108// OpenSession for that case.
109func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) {
110 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
111 return nil, err
112 }
113 return newSessionAt(path, cwd, providerName, model, version)
114}
115
116// newSessionAt is the shared implementation. Both NewSession and
117// NewSessionAtPath funnel through here so the meta-line layout,
118// freshFile bookkeeping, and id format stay identical.
119func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) {
120 id := uuid.NewString()
121 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
122 if err != nil {
123 return nil, err
124 }
125 s := &Session{
126 ID: id,
127 Path: p,
128 Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
129 writer: f,
130 buf: bufio.NewWriter(f),
131 freshFile: true,
132 }
133 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
134 f.Close()
135 return nil, err
136 }
137 return s, nil
138}
139
140func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
141 br := bufio.NewReader(r)
142 for {
143 line, err := br.ReadBytes('\n')
144 if len(line) > 0 {
145 line = bytes.TrimRight(line, "\r\n")
146 if len(line) > 0 {
147 if ferr := fn(line); ferr != nil {
148 return ferr
149 }
150 }
151 }
152 if err == io.EOF {
153 return nil
154 }
155 if err != nil {
156 return err
157 }
158 }
159}
160
161// SessionUsage returns the most recent cumulative usage row stored in
162// a session file. Sessions append one usage row per completed turn; the
163// latest row's cumulative field is the session total. Missing usage rows
164// are valid for old/empty sessions and return the zero value.
165func SessionUsage(path string) (provider.Usage, error) {
166 cum, _, err := SessionUsageDetail(path)
167 return cum, err
168}
169
170// SessionUsageDetail returns the latest cumulative usage and the
171// per-turn usage of the final completed turn. The per-turn row drives
172// the live "context used" gauge in the status bar (input + cache
173// approximates the prompt size the model just saw), letting the TUI
174// rehydrate the gauge on resume instead of starting at 0% until the
175// next turn lands.
176func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
177 f, ferr := os.Open(path)
178 if ferr != nil {
179 return provider.Usage{}, provider.Usage{}, ferr
180 }
181 defer f.Close()
182
183 // Some historical sessions logged the per-turn `usage` field as a copy
184 // of `cumulative` instead of the true delta. To recover an accurate
185 // last-turn snapshot (used by the status-bar context gauge on resume),
186 // we always derive lastTurn from the delta between the final two
187 // cumulative rows. For prompt-size purposes, cache_read/cache_write
188 // reflect the most recent prompt directly, so we take those from the
189 // final cumulative row as-is rather than as a delta.
190 var prevCum provider.Usage
191 var haveCum bool
192 if ierr := forEachJSONLLine(f, func(line []byte) error {
193 var head sessionLineHead
194 if err := json.Unmarshal(line, &head); err != nil || head.Type != "usage" {
195 return nil
196 }
197 var row struct {
198 Cumulative provider.Usage `json:"cumulative"`
199 }
200 if err := json.Unmarshal(line, &row); err != nil {
201 return nil
202 }
203 if haveCum {
204 prevCum = cumulative
205 }
206 cumulative = row.Cumulative
207 haveCum = true
208 return nil
209 }); ierr != nil {
210 return provider.Usage{}, provider.Usage{}, ierr
211 }
212 if haveCum {
213 // input/output are monotonic totals -> per-turn = delta.
214 lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)
215 lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)
216 lastTurn.ReasoningTokens = nonNegDelta(cumulative.ReasoningTokens, prevCum.ReasoningTokens)
217 lastTurn.ReasoningTokensKnown = cumulative.ReasoningTokensKnown
218 // cache_read/write on the final row already represent the last prompt's
219 // cache hit/creation, not a running total of bytes; use directly.
220 lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens
221 if lastTurn.CacheReadTokens < 0 {
222 lastTurn.CacheReadTokens = cumulative.CacheReadTokens
223 }
224 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens
225 if lastTurn.CacheWriteTokens < 0 {
226 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens
227 }
228 lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD
229 if lastTurn.CostUSD < 0 {
230 lastTurn.CostUSD = 0
231 }
232 }
233 return cumulative, lastTurn, nil
234}
235
236func nonNegDelta(cur, prev int) int {
237 if cur < prev {
238 return cur
239 }
240 return cur - prev
241}
242
243// OpenSession opens an existing session for appending.
244func OpenSession(path string) (*Session, []provider.Message, error) {
245 f, err := os.Open(path)
246 if err != nil {
247 return nil, nil, err
248 }
249 defer f.Close()
250
251 var meta SessionMeta
252 var messages []provider.Message
253 if err := forEachJSONLLine(f, func(line []byte) error {
254 var head sessionLineHead
255 if err := json.Unmarshal(line, &head); err != nil {
256 return nil
257 }
258 switch head.Type {
259 case "meta":
260 var row struct {
261 Meta SessionMeta `json:"meta"`
262 }
263 if err := json.Unmarshal(line, &row); err == nil {
264 meta = row.Meta
265 }
266 case "message":
267 if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
268 messages = append(messages, msg)
269 }
270 case "compaction":
271 if compacted, err := hydrateCompaction(line); err == nil {
272 messages = compacted
273 }
274 }
275 return nil
276 }); err != nil {
277 return nil, nil, err
278 }
279 messages = repairToolUseResultPairs(messages)
280 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
281 if err != nil {
282 return nil, nil, err
283 }
284 s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
285 return s, messages, nil
286}
287
288// repairToolUseResultPairs walks a restored transcript and
289// synthesises stub tool_result blocks for any assistant
290// tool_use blocks that aren't paired with a matching result in
291// the next message. Anthropic (and OpenAI via the responses API)
292// reject any request whose transcript leaves a tool_use without
293// its matching tool_result immediately after, with errors like:
294//
295// messages.8: `tool_use` ids were found without `tool_result`
296// blocks immediately after
297//
298// Corruption gets into the transcript two ways we know of:
299//
300// - Older zot builds that persisted the assistant tool_use row
301// before the tool_result row, then crashed between the two.
302// - Abort paths in older builds that didn't drop the mid-turn
303// assistant message cleanly.
304//
305// Rather than change runtime semantics (which would risk hiding a
306// real bug), we scrub on load: any unmatched tool_use gets a stub
307// tool_result injected as a RoleTool message so the next
308// outbound request passes the provider's validity check. The stub
309// reads "tool call was aborted; no result recorded." so the
310// model can see what happened and decide whether to retry.
311//
312// Runs once per OpenSession call. No cost on the hot path.
313func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
314 if len(msgs) == 0 {
315 return msgs
316 }
317 out := make([]provider.Message, 0, len(msgs)+2)
318 for i, m := range msgs {
319 out = append(out, m)
320 if m.Role != provider.RoleAssistant {
321 continue
322 }
323 // Collect tool_use ids in this assistant message.
324 var ids []string
325 for _, c := range m.Content {
326 if tc, ok := c.(provider.ToolCallBlock); ok {
327 ids = append(ids, tc.ID)
328 }
329 }
330 if len(ids) == 0 {
331 continue
332 }
333 // Look at the next message (if any) and collect tool_result
334 // CallIDs it covers.
335 have := map[string]bool{}
336 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
337 for _, c := range msgs[i+1].Content {
338 if tr, ok := c.(provider.ToolResultBlock); ok {
339 have[tr.CallID] = true
340 }
341 }
342 }
343 // Build stubs for any missing id.
344 var stubs []provider.Content
345 for _, id := range ids {
346 if have[id] {
347 continue
348 }
349 stubs = append(stubs, provider.ToolResultBlock{
350 CallID: id,
351 Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
352 IsError: true,
353 })
354 }
355 if len(stubs) == 0 {
356 continue
357 }
358 // Merge into the next tool-role message if present,
359 // otherwise insert a synthetic one right after the
360 // assistant message. Merging keeps the tool-role row
361 // count stable; inserting handles the common case where
362 // no tool message was persisted at all.
363 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
364 msgs[i+1].Content = append(msgs[i+1].Content, stubs...)
365 // We already appended m to out; the modified next
366 // message will be appended on the following iteration.
367 continue
368 }
369 out = append(out, provider.Message{
370 Role: provider.RoleTool,
371 Content: stubs,
372 Time: m.Time,
373 })
374 }
375 return out
376}
377
378// LatestSession returns the most recent session file for cwd, or "".
379func LatestSession(root, cwd string) string {
380 paths := ListSessions(root, cwd)
381 if len(paths) == 0 {
382 return ""
383 }
384 return paths[0]
385}
386
387// SessionSummary describes one on-disk session at a glance for UI pickers.
388type SessionSummary struct {
389 Path string
390 Started time.Time
391 Model string
392 Provider string
393 MessageCount int
394 FirstUserText string
395 TotalCost float64
396 Title string
397 BranchDepth int
398}
399
400// RenameSession updates the title field in the session's meta line.
401// It rewrites the first line of the file (the meta line) with the
402// updated title.
403// RenameSession appends a rename line to the session file. This is
404// safe even for the currently active session because it opens the
405// file independently and appends (doesn't rewrite).
406func RenameSession(path, title string) error {
407 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
408 if err != nil {
409 return err
410 }
411 defer f.Close()
412 line, _ := json.Marshal(map[string]string{"type": "rename", "title": title})
413 line = append(line, '\n')
414 _, err = f.Write(line)
415 return err
416}
417
418// DeleteSession removes a session file. It refuses an empty path so
419// callers do not accidentally delete their cwd or another implicit target.
420func DeleteSession(path string) error {
421 if strings.TrimSpace(path) == "" {
422 return fmt.Errorf("session path is empty")
423 }
424 return os.Remove(path)
425}
426
427// DescribeSessions returns lightweight summaries for every session in
428// cwd, newest first. Parses only the first few lines and the last usage
429// line so it's cheap to run on every dialog open.
430func DescribeSessions(root, cwd string) []SessionSummary {
431 paths := ListSessions(root, cwd)
432 summaries := make([]SessionSummary, 0, len(paths))
433 metas := make(map[string]SessionMeta, len(paths))
434 idToPath := make(map[string]string, len(paths))
435 for _, p := range paths {
436 if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
437 metas[p] = meta
438 idToPath[meta.ID] = p
439 }
440 }
441 for _, p := range paths {
442 if meta := metas[p]; meta.HideFromSessions {
443 continue
444 }
445 summaries = append(summaries, describeSession(p))
446 }
447 for idx := range summaries {
448 summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
449 }
450 return summaries
451}
452
453func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
454 depth := 0
455 seen := map[string]bool{}
456 for {
457 meta, ok := metas[path]
458 if !ok || meta.Parent == "" || seen[meta.ID] {
459 return depth
460 }
461 seen[meta.ID] = true
462 parentPath := idToPath[meta.Parent]
463 if parentPath == "" {
464 return depth
465 }
466 depth++
467 path = parentPath
468 }
469}
470
471func describeSession(path string) SessionSummary {
472 s := SessionSummary{Path: path}
473 f, err := os.Open(path)
474 if err != nil {
475 return s
476 }
477 defer f.Close()
478 isBranch := false
479 forkPoint := 0
480 messageIdx := 0
481 branchPrompt := ""
482 lastUser := ""
483 renameMessageIdx := -1
484 _ = forEachJSONLLine(f, func(line []byte) error {
485 var head sessionLineHead
486 if err := json.Unmarshal(line, &head); err != nil {
487 return nil
488 }
489 switch head.Type {
490 case "meta":
491 var row struct {
492 Meta SessionMeta `json:"meta"`
493 }
494 if err := json.Unmarshal(line, &row); err == nil {
495 s.Started = row.Meta.Started
496 s.Model = row.Meta.Model
497 s.Provider = row.Meta.Provider
498 s.Title = row.Meta.Title
499 isBranch = row.Meta.Parent != ""
500 forkPoint = row.Meta.ForkPoint
501 }
502 case "message":
503 if text := firstUserText(line); text != "" {
504 lastUser = text
505 if s.FirstUserText == "" {
506 s.FirstUserText = text
507 }
508 if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
509 branchPrompt = text
510 }
511 }
512 messageIdx++
513 s.MessageCount++
514 case "compaction":
515 if compacted, err := hydrateCompaction(line); err == nil {
516 s.MessageCount = len(compacted)
517 messageIdx = len(compacted)
518 s.FirstUserText = firstUserTextFromMessages(compacted)
519 lastUser = lastUserText(compacted)
520 branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
521 }
522 case "rename":
523 var row struct {
524 Title string `json:"title"`
525 }
526 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
527 s.Title = row.Title
528 renameMessageIdx = messageIdx
529 }
530 case "usage":
531 var row struct {
532 Cumulative provider.Usage `json:"cumulative"`
533 }
534 if err := json.Unmarshal(line, &row); err == nil {
535 s.TotalCost = row.Cumulative.CostUSD
536 }
537 }
538 return nil
539 })
540 if isBranch {
541 // A rename written after the branch has diverged is user intent
542 // and must win. Older generated branch titles were written at
543 // fork creation time, before any post-fork prompt, so those are
544 // allowed to be replaced by the branch prompt fallback.
545 userRenamedAfterFork := renameMessageIdx > forkPoint
546 if !userRenamedAfterFork && branchPrompt != "" {
547 s.Title = branchPrompt
548 } else if s.Title == "" && lastUser != "" {
549 s.Title = lastUser
550 }
551 }
552 return s
553}
554
555func firstUserText(line []byte) string {
556 var row struct {
557 Message struct {
558 Role string `json:"role"`
559 Content []struct {
560 Text string `json:"text"`
561 } `json:"content"`
562 } `json:"message"`
563 }
564 if err := json.Unmarshal(line, &row); err != nil {
565 return ""
566 }
567 if row.Message.Role != "user" {
568 return ""
569 }
570 for _, c := range row.Message.Content {
571 if c.Text != "" {
572 return c.Text
573 }
574 }
575 return ""
576}
577
578func firstUserTextFromMessages(msgs []provider.Message) string {
579 return firstUserTextFromMessagesAfter(msgs, 0)
580}
581
582func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
583 if start < 0 {
584 start = 0
585 }
586 for idx, msg := range msgs {
587 if idx < start || msg.Role != provider.RoleUser {
588 continue
589 }
590 if text := firstTextFromMessage(msg); text != "" {
591 return text
592 }
593 }
594 return ""
595}
596
597func firstTextFromMessage(msg provider.Message) string {
598 for _, c := range msg.Content {
599 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
600 return tb.Text
601 }
602 }
603 return ""
604}
605
606// PruneEmptySessions deletes session files in cwd's session directory
607// that contain only a meta line (no messages were ever appended).
608// Cleans up the backlog of empty stubs created by old zot versions
609// that wrote a meta line at NewSession time and never followed up.
610// Errors are swallowed; the caller treats this as best-effort.
611func PruneEmptySessions(root, cwd string) {
612 dir := SessionsDir(root, cwd)
613 entries, err := os.ReadDir(dir)
614 if err != nil {
615 return
616 }
617 for _, e := range entries {
618 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
619 continue
620 }
621 p := filepath.Join(dir, e.Name())
622 if sessionHasNoMessages(p) {
623 _ = os.Remove(p)
624 }
625 }
626}
627
628// sessionHasNoMessages returns true when the file at path contains
629// no lines of type "message". Meta-only / usage-only files count as
630// empty. Used by PruneEmptySessions and the Describe path.
631func sessionHasNoMessages(path string) bool {
632 f, err := os.Open(path)
633 if err != nil {
634 return false
635 }
636 defer f.Close()
637 hasMessage := false
638 _ = forEachJSONLLine(f, func(line []byte) error {
639 var head sessionLineHead
640 if err := json.Unmarshal(line, &head); err != nil {
641 return nil
642 }
643 if head.Type == "message" {
644 hasMessage = true
645 return io.EOF
646 }
647 return nil
648 })
649 return !hasMessage
650}
651
652// ListSessions returns session file paths for cwd, most-recently-
653// modified first. Sorting on filesystem ModTime instead of the
654// timestamp embedded in the filename means a long-running session
655// the user actually returned to recently floats to the top of
656// /sessions, /continue, and the resume picker, even when it was
657// originally created days earlier than newer but idle sessions.
658// Files with identical ModTime fall back to filename desc so the
659// order stays stable across calls.
660func ListSessions(root, cwd string) []string {
661 dir := SessionsDir(root, cwd)
662 entries, err := os.ReadDir(dir)
663 if err != nil {
664 return nil
665 }
666 type rec struct {
667 path string
668 mod time.Time
669 }
670 var files []rec
671 for _, e := range entries {
672 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
673 continue
674 }
675 p := filepath.Join(dir, e.Name())
676 info, err := e.Info()
677 if err != nil {
678 continue
679 }
680 files = append(files, rec{path: p, mod: info.ModTime()})
681 }
682 sort.Slice(files, func(i, j int) bool {
683 if !files[i].mod.Equal(files[j].mod) {
684 return files[i].mod.After(files[j].mod)
685 }
686 return files[i].path > files[j].path
687 })
688 out := make([]string, 0, len(files))
689 for _, r := range files {
690 out = append(out, r.path)
691 }
692 return out
693}
694
695// AppendMessage writes a message to the session.
696func (s *Session) AppendMessage(m provider.Message) error {
697 if s == nil {
698 return nil
699 }
700 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
701 return err
702 }
703 s.messagesAppended++
704 return nil
705}
706
707// AppendCompaction writes a checkpoint that replaces all earlier
708// transcript rows when the session is resumed. The old rows remain in
709// the JSONL file for audit/export, while loaders use the latest
710// compaction row as the effective transcript.
711func (s *Session) AppendCompaction(messages []provider.Message) error {
712 if s == nil {
713 return nil
714 }
715 if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {
716 return err
717 }
718 s.messagesAppended = len(messages)
719 return nil
720}
721
722// UpdateModel records a provider/model switch in the session file.
723// The reader keeps the most recent meta entry, so the session resumes
724// with the updated model.
725func (s *Session) UpdateModel(providerName, model string) error {
726 if s == nil {
727 return nil
728 }
729 s.Meta.Provider = providerName
730 s.Meta.Model = model
731 return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})
732}
733
734// AppendUsage writes a usage row to the session.
735func (s *Session) AppendUsage(u, cum provider.Usage) error {
736 if s == nil {
737 return nil
738 }
739 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
740}
741
742// Close flushes and closes the session file. If the session was
743// freshly created in this process and never had any messages
744// appended (the user opened zot, looked around, and exited without
745// prompting), the file is deleted on close so the sessions list
746// doesn't fill up with empty meta-only stubs.
747func (s *Session) Close() error {
748 if s == nil {
749 return nil
750 }
751 flushErr := s.buf.Flush()
752 closeErr := s.writer.Close()
753 if s.freshFile && s.messagesAppended == 0 {
754 // Best-effort cleanup. We deliberately don't propagate the
755 // remove error: if it fails (file already gone, perms changed)
756 // the worst case is one stale empty file in the listing.
757 _ = os.Remove(s.Path)
758 }
759 if flushErr != nil {
760 return flushErr
761 }
762 return closeErr
763}
764
765func (s *Session) writeLine(row sessionLine) error {
766 b, err := json.Marshal(row)
767 if err != nil {
768 return err
769 }
770 if _, err := s.buf.Write(b); err != nil {
771 return err
772 }
773 if err := s.buf.WriteByte('\n'); err != nil {
774 return err
775 }
776 return s.buf.Flush()
777}
778
779// ---- content (de)serialization ----
780//
781// provider.Content is an interface; encoding/json drops type information.
782// We persist messages by reading the raw "message" object back and
783// rebuilding Content from discriminated fields.
784
785func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
786 var row struct {
787 Messages []json.RawMessage `json:"messages"`
788 }
789 if err := json.Unmarshal(lineBytes, &row); err != nil {
790 return nil, err
791 }
792 messages := make([]provider.Message, 0, len(row.Messages))
793 for _, raw := range row.Messages {
794 msg, err := hydrateMessageObject(raw)
795 if err == nil && len(msg.Content) > 0 {
796 messages = append(messages, msg)
797 }
798 }
799 return messages, nil
800}
801
802func hydrateMessage(lineBytes []byte) (provider.Message, error) {
803 var row struct {
804 Message json.RawMessage `json:"message"`
805 }
806 if err := json.Unmarshal(lineBytes, &row); err != nil {
807 return provider.Message{}, err
808 }
809 return hydrateMessageObject(row.Message)
810}
811
812func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
813 var row struct {
814 Role provider.Role `json:"role"`
815 Content []json.RawMessage `json:"content"`
816 Time time.Time `json:"time"`
817 Meta map[string]string `json:"meta,omitempty"`
818 AddedToolNames []string `json:"added_tool_names,omitempty"`
819 }
820 if err := json.Unmarshal(rawMessage, &row); err != nil {
821 return provider.Message{}, err
822 }
823 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
824 for _, raw := range row.Content {
825 var head struct {
826 Text string `json:"text"`
827 MimeType string `json:"mime_type"`
828 Data []byte `json:"data"`
829 ID string `json:"id"`
830 Name string `json:"name"`
831 CallID string `json:"call_id"`
832 ReasoningID string `json:"reasoning_id"`
833 Summary string `json:"summary"`
834 Encrypted string `json:"encrypted_content"`
835 ThoughtSignature string `json:"thought_signature"`
836 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
837 }
838 if err := json.Unmarshal(raw, &head); err != nil {
839 continue
840 }
841 // Discriminate by presence of fields.
842 switch {
843 case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "":
844 msg.Content = append(msg.Content, provider.ReasoningBlock{
845 ID: head.ReasoningID,
846 Summary: head.Summary,
847 Encrypted: head.Encrypted,
848 })
849 case head.Name != "" && head.ID != "":
850 var tc struct {
851 ID string `json:"id"`
852 Name string `json:"name"`
853 Arguments json.RawMessage `json:"arguments"`
854 ThoughtSignature string `json:"thought_signature"`
855 }
856 _ = json.Unmarshal(raw, &tc)
857 msg.Content = append(msg.Content, provider.ToolCallBlock{
858 ID: tc.ID,
859 Name: tc.Name,
860 Arguments: tc.Arguments,
861 ThoughtSignature: tc.ThoughtSignature,
862 })
863 case head.CallID != "":
864 var tr struct {
865 CallID string `json:"call_id"`
866 Content []json.RawMessage `json:"content"`
867 IsError bool `json:"is_error"`
868 }
869 _ = json.Unmarshal(raw, &tr)
870 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
871 for _, c := range tr.Content {
872 var inner struct {
873 Text string `json:"text"`
874 MimeType string `json:"mime_type"`
875 Data []byte `json:"data"`
876 }
877 _ = json.Unmarshal(c, &inner)
878 if inner.MimeType != "" {
879 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
880 } else {
881 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
882 }
883 }
884 msg.Content = append(msg.Content, block)
885 case head.MimeType != "":
886 msg.Content = append(msg.Content, provider.ImageBlock{
887 MimeType: head.MimeType,
888 Data: head.Data,
889 ThoughtSignature: head.ThoughtSignature,
890 })
891 default:
892 msg.Content = append(msg.Content, provider.TextBlock{
893 Text: head.Text,
894 ThoughtSignature: head.ThoughtSignature,
895 })
896 }
897 }
898 return msg, nil
899}