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 // cache_read/write on the final row already represent the last prompt's
217 // cache hit/creation, not a running total of bytes; use directly.
218 lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens
219 if lastTurn.CacheReadTokens < 0 {
220 lastTurn.CacheReadTokens = cumulative.CacheReadTokens
221 }
222 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens
223 if lastTurn.CacheWriteTokens < 0 {
224 lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens
225 }
226 lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD
227 if lastTurn.CostUSD < 0 {
228 lastTurn.CostUSD = 0
229 }
230 }
231 return cumulative, lastTurn, nil
232}
233
234func nonNegDelta(cur, prev int) int {
235 if cur < prev {
236 return cur
237 }
238 return cur - prev
239}
240
241// OpenSession opens an existing session for appending.
242func OpenSession(path string) (*Session, []provider.Message, error) {
243 f, err := os.Open(path)
244 if err != nil {
245 return nil, nil, err
246 }
247 defer f.Close()
248
249 var meta SessionMeta
250 var messages []provider.Message
251 if err := forEachJSONLLine(f, func(line []byte) error {
252 var head sessionLineHead
253 if err := json.Unmarshal(line, &head); err != nil {
254 return nil
255 }
256 switch head.Type {
257 case "meta":
258 var row struct {
259 Meta SessionMeta `json:"meta"`
260 }
261 if err := json.Unmarshal(line, &row); err == nil {
262 meta = row.Meta
263 }
264 case "message":
265 if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
266 messages = append(messages, msg)
267 }
268 case "compaction":
269 if compacted, err := hydrateCompaction(line); err == nil {
270 messages = compacted
271 }
272 }
273 return nil
274 }); err != nil {
275 return nil, nil, err
276 }
277 messages = repairToolUseResultPairs(messages)
278 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
279 if err != nil {
280 return nil, nil, err
281 }
282 s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
283 return s, messages, nil
284}
285
286// repairToolUseResultPairs walks a restored transcript and
287// synthesises stub tool_result blocks for any assistant
288// tool_use blocks that aren't paired with a matching result in
289// the next message. Anthropic (and OpenAI via the responses API)
290// reject any request whose transcript leaves a tool_use without
291// its matching tool_result immediately after, with errors like:
292//
293// messages.8: `tool_use` ids were found without `tool_result`
294// blocks immediately after
295//
296// Corruption gets into the transcript two ways we know of:
297//
298// - Older zot builds that persisted the assistant tool_use row
299// before the tool_result row, then crashed between the two.
300// - Abort paths in older builds that didn't drop the mid-turn
301// assistant message cleanly.
302//
303// Rather than change runtime semantics (which would risk hiding a
304// real bug), we scrub on load: any unmatched tool_use gets a stub
305// tool_result injected as a RoleTool message so the next
306// outbound request passes the provider's validity check. The stub
307// reads "tool call was aborted; no result recorded." so the
308// model can see what happened and decide whether to retry.
309//
310// Runs once per OpenSession call. No cost on the hot path.
311func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
312 if len(msgs) == 0 {
313 return msgs
314 }
315 out := make([]provider.Message, 0, len(msgs)+2)
316 for i, m := range msgs {
317 out = append(out, m)
318 if m.Role != provider.RoleAssistant {
319 continue
320 }
321 // Collect tool_use ids in this assistant message.
322 var ids []string
323 for _, c := range m.Content {
324 if tc, ok := c.(provider.ToolCallBlock); ok {
325 ids = append(ids, tc.ID)
326 }
327 }
328 if len(ids) == 0 {
329 continue
330 }
331 // Look at the next message (if any) and collect tool_result
332 // CallIDs it covers.
333 have := map[string]bool{}
334 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
335 for _, c := range msgs[i+1].Content {
336 if tr, ok := c.(provider.ToolResultBlock); ok {
337 have[tr.CallID] = true
338 }
339 }
340 }
341 // Build stubs for any missing id.
342 var stubs []provider.Content
343 for _, id := range ids {
344 if have[id] {
345 continue
346 }
347 stubs = append(stubs, provider.ToolResultBlock{
348 CallID: id,
349 Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
350 IsError: true,
351 })
352 }
353 if len(stubs) == 0 {
354 continue
355 }
356 // Merge into the next tool-role message if present,
357 // otherwise insert a synthetic one right after the
358 // assistant message. Merging keeps the tool-role row
359 // count stable; inserting handles the common case where
360 // no tool message was persisted at all.
361 if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
362 msgs[i+1].Content = append(msgs[i+1].Content, stubs...)
363 // We already appended m to out; the modified next
364 // message will be appended on the following iteration.
365 continue
366 }
367 out = append(out, provider.Message{
368 Role: provider.RoleTool,
369 Content: stubs,
370 Time: m.Time,
371 })
372 }
373 return out
374}
375
376// LatestSession returns the most recent session file for cwd, or "".
377func LatestSession(root, cwd string) string {
378 paths := ListSessions(root, cwd)
379 if len(paths) == 0 {
380 return ""
381 }
382 return paths[0]
383}
384
385// SessionSummary describes one on-disk session at a glance for UI pickers.
386type SessionSummary struct {
387 Path string
388 Started time.Time
389 Model string
390 Provider string
391 MessageCount int
392 FirstUserText string
393 TotalCost float64
394 Title string
395 BranchDepth int
396}
397
398// RenameSession updates the title field in the session's meta line.
399// It rewrites the first line of the file (the meta line) with the
400// updated title.
401// RenameSession appends a rename line to the session file. This is
402// safe even for the currently active session because it opens the
403// file independently and appends (doesn't rewrite).
404func RenameSession(path, title string) error {
405 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
406 if err != nil {
407 return err
408 }
409 defer f.Close()
410 line, _ := json.Marshal(map[string]string{"type": "rename", "title": title})
411 line = append(line, '\n')
412 _, err = f.Write(line)
413 return err
414}
415
416// DeleteSession removes a session file. It refuses an empty path so
417// callers do not accidentally delete their cwd or another implicit target.
418func DeleteSession(path string) error {
419 if strings.TrimSpace(path) == "" {
420 return fmt.Errorf("session path is empty")
421 }
422 return os.Remove(path)
423}
424
425// DescribeSessions returns lightweight summaries for every session in
426// cwd, newest first. Parses only the first few lines and the last usage
427// line so it's cheap to run on every dialog open.
428func DescribeSessions(root, cwd string) []SessionSummary {
429 paths := ListSessions(root, cwd)
430 summaries := make([]SessionSummary, 0, len(paths))
431 metas := make(map[string]SessionMeta, len(paths))
432 idToPath := make(map[string]string, len(paths))
433 for _, p := range paths {
434 if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
435 metas[p] = meta
436 idToPath[meta.ID] = p
437 }
438 }
439 for _, p := range paths {
440 if meta := metas[p]; meta.HideFromSessions {
441 continue
442 }
443 summaries = append(summaries, describeSession(p))
444 }
445 for idx := range summaries {
446 summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
447 }
448 return summaries
449}
450
451func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
452 depth := 0
453 seen := map[string]bool{}
454 for {
455 meta, ok := metas[path]
456 if !ok || meta.Parent == "" || seen[meta.ID] {
457 return depth
458 }
459 seen[meta.ID] = true
460 parentPath := idToPath[meta.Parent]
461 if parentPath == "" {
462 return depth
463 }
464 depth++
465 path = parentPath
466 }
467}
468
469func describeSession(path string) SessionSummary {
470 s := SessionSummary{Path: path}
471 f, err := os.Open(path)
472 if err != nil {
473 return s
474 }
475 defer f.Close()
476 isBranch := false
477 forkPoint := 0
478 messageIdx := 0
479 branchPrompt := ""
480 lastUser := ""
481 renameMessageIdx := -1
482 _ = forEachJSONLLine(f, func(line []byte) error {
483 var head sessionLineHead
484 if err := json.Unmarshal(line, &head); err != nil {
485 return nil
486 }
487 switch head.Type {
488 case "meta":
489 var row struct {
490 Meta SessionMeta `json:"meta"`
491 }
492 if err := json.Unmarshal(line, &row); err == nil {
493 s.Started = row.Meta.Started
494 s.Model = row.Meta.Model
495 s.Provider = row.Meta.Provider
496 s.Title = row.Meta.Title
497 isBranch = row.Meta.Parent != ""
498 forkPoint = row.Meta.ForkPoint
499 }
500 case "message":
501 if text := firstUserText(line); text != "" {
502 lastUser = text
503 if s.FirstUserText == "" {
504 s.FirstUserText = text
505 }
506 if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
507 branchPrompt = text
508 }
509 }
510 messageIdx++
511 s.MessageCount++
512 case "compaction":
513 if compacted, err := hydrateCompaction(line); err == nil {
514 s.MessageCount = len(compacted)
515 messageIdx = len(compacted)
516 s.FirstUserText = firstUserTextFromMessages(compacted)
517 lastUser = lastUserText(compacted)
518 branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
519 }
520 case "rename":
521 var row struct {
522 Title string `json:"title"`
523 }
524 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
525 s.Title = row.Title
526 renameMessageIdx = messageIdx
527 }
528 case "usage":
529 var row struct {
530 Cumulative provider.Usage `json:"cumulative"`
531 }
532 if err := json.Unmarshal(line, &row); err == nil {
533 s.TotalCost = row.Cumulative.CostUSD
534 }
535 }
536 return nil
537 })
538 if isBranch {
539 // A rename written after the branch has diverged is user intent
540 // and must win. Older generated branch titles were written at
541 // fork creation time, before any post-fork prompt, so those are
542 // allowed to be replaced by the branch prompt fallback.
543 userRenamedAfterFork := renameMessageIdx > forkPoint
544 if !userRenamedAfterFork && branchPrompt != "" {
545 s.Title = branchPrompt
546 } else if s.Title == "" && lastUser != "" {
547 s.Title = lastUser
548 }
549 }
550 return s
551}
552
553func firstUserText(line []byte) string {
554 var row struct {
555 Message struct {
556 Role string `json:"role"`
557 Content []struct {
558 Text string `json:"text"`
559 } `json:"content"`
560 } `json:"message"`
561 }
562 if err := json.Unmarshal(line, &row); err != nil {
563 return ""
564 }
565 if row.Message.Role != "user" {
566 return ""
567 }
568 for _, c := range row.Message.Content {
569 if c.Text != "" {
570 return c.Text
571 }
572 }
573 return ""
574}
575
576func firstUserTextFromMessages(msgs []provider.Message) string {
577 return firstUserTextFromMessagesAfter(msgs, 0)
578}
579
580func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
581 if start < 0 {
582 start = 0
583 }
584 for idx, msg := range msgs {
585 if idx < start || msg.Role != provider.RoleUser {
586 continue
587 }
588 if text := firstTextFromMessage(msg); text != "" {
589 return text
590 }
591 }
592 return ""
593}
594
595func firstTextFromMessage(msg provider.Message) string {
596 for _, c := range msg.Content {
597 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
598 return tb.Text
599 }
600 }
601 return ""
602}
603
604// PruneEmptySessions deletes session files in cwd's session directory
605// that contain only a meta line (no messages were ever appended).
606// Cleans up the backlog of empty stubs created by old zot versions
607// that wrote a meta line at NewSession time and never followed up.
608// Errors are swallowed; the caller treats this as best-effort.
609func PruneEmptySessions(root, cwd string) {
610 dir := SessionsDir(root, cwd)
611 entries, err := os.ReadDir(dir)
612 if err != nil {
613 return
614 }
615 for _, e := range entries {
616 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
617 continue
618 }
619 p := filepath.Join(dir, e.Name())
620 if sessionHasNoMessages(p) {
621 _ = os.Remove(p)
622 }
623 }
624}
625
626// sessionHasNoMessages returns true when the file at path contains
627// no lines of type "message". Meta-only / usage-only files count as
628// empty. Used by PruneEmptySessions and the Describe path.
629func sessionHasNoMessages(path string) bool {
630 f, err := os.Open(path)
631 if err != nil {
632 return false
633 }
634 defer f.Close()
635 hasMessage := false
636 _ = forEachJSONLLine(f, func(line []byte) error {
637 var head sessionLineHead
638 if err := json.Unmarshal(line, &head); err != nil {
639 return nil
640 }
641 if head.Type == "message" {
642 hasMessage = true
643 return io.EOF
644 }
645 return nil
646 })
647 return !hasMessage
648}
649
650// ListSessions returns session file paths for cwd, most-recently-
651// modified first. Sorting on filesystem ModTime instead of the
652// timestamp embedded in the filename means a long-running session
653// the user actually returned to recently floats to the top of
654// /sessions, /continue, and the resume picker, even when it was
655// originally created days earlier than newer but idle sessions.
656// Files with identical ModTime fall back to filename desc so the
657// order stays stable across calls.
658func ListSessions(root, cwd string) []string {
659 dir := SessionsDir(root, cwd)
660 entries, err := os.ReadDir(dir)
661 if err != nil {
662 return nil
663 }
664 type rec struct {
665 path string
666 mod time.Time
667 }
668 var files []rec
669 for _, e := range entries {
670 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
671 continue
672 }
673 p := filepath.Join(dir, e.Name())
674 info, err := e.Info()
675 if err != nil {
676 continue
677 }
678 files = append(files, rec{path: p, mod: info.ModTime()})
679 }
680 sort.Slice(files, func(i, j int) bool {
681 if !files[i].mod.Equal(files[j].mod) {
682 return files[i].mod.After(files[j].mod)
683 }
684 return files[i].path > files[j].path
685 })
686 out := make([]string, 0, len(files))
687 for _, r := range files {
688 out = append(out, r.path)
689 }
690 return out
691}
692
693// AppendMessage writes a message to the session.
694func (s *Session) AppendMessage(m provider.Message) error {
695 if s == nil {
696 return nil
697 }
698 if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
699 return err
700 }
701 s.messagesAppended++
702 return nil
703}
704
705// AppendCompaction writes a checkpoint that replaces all earlier
706// transcript rows when the session is resumed. The old rows remain in
707// the JSONL file for audit/export, while loaders use the latest
708// compaction row as the effective transcript.
709func (s *Session) AppendCompaction(messages []provider.Message) error {
710 if s == nil {
711 return nil
712 }
713 if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {
714 return err
715 }
716 s.messagesAppended = len(messages)
717 return nil
718}
719
720// UpdateModel records a provider/model switch in the session file.
721// The reader keeps the most recent meta entry, so the session resumes
722// with the updated model.
723func (s *Session) UpdateModel(providerName, model string) error {
724 if s == nil {
725 return nil
726 }
727 s.Meta.Provider = providerName
728 s.Meta.Model = model
729 return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})
730}
731
732// AppendUsage writes a usage row to the session.
733func (s *Session) AppendUsage(u, cum provider.Usage) error {
734 if s == nil {
735 return nil
736 }
737 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
738}
739
740// Close flushes and closes the session file. If the session was
741// freshly created in this process and never had any messages
742// appended (the user opened zot, looked around, and exited without
743// prompting), the file is deleted on close so the sessions list
744// doesn't fill up with empty meta-only stubs.
745func (s *Session) Close() error {
746 if s == nil {
747 return nil
748 }
749 flushErr := s.buf.Flush()
750 closeErr := s.writer.Close()
751 if s.freshFile && s.messagesAppended == 0 {
752 // Best-effort cleanup. We deliberately don't propagate the
753 // remove error: if it fails (file already gone, perms changed)
754 // the worst case is one stale empty file in the listing.
755 _ = os.Remove(s.Path)
756 }
757 if flushErr != nil {
758 return flushErr
759 }
760 return closeErr
761}
762
763func (s *Session) writeLine(row sessionLine) error {
764 b, err := json.Marshal(row)
765 if err != nil {
766 return err
767 }
768 if _, err := s.buf.Write(b); err != nil {
769 return err
770 }
771 if err := s.buf.WriteByte('\n'); err != nil {
772 return err
773 }
774 return s.buf.Flush()
775}
776
777// ---- content (de)serialization ----
778//
779// provider.Content is an interface; encoding/json drops type information.
780// We persist messages by reading the raw "message" object back and
781// rebuilding Content from discriminated fields.
782
783func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
784 var row struct {
785 Messages []json.RawMessage `json:"messages"`
786 }
787 if err := json.Unmarshal(lineBytes, &row); err != nil {
788 return nil, err
789 }
790 messages := make([]provider.Message, 0, len(row.Messages))
791 for _, raw := range row.Messages {
792 msg, err := hydrateMessageObject(raw)
793 if err == nil && len(msg.Content) > 0 {
794 messages = append(messages, msg)
795 }
796 }
797 return messages, nil
798}
799
800func hydrateMessage(lineBytes []byte) (provider.Message, error) {
801 var row struct {
802 Message json.RawMessage `json:"message"`
803 }
804 if err := json.Unmarshal(lineBytes, &row); err != nil {
805 return provider.Message{}, err
806 }
807 return hydrateMessageObject(row.Message)
808}
809
810func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
811 var row struct {
812 Role provider.Role `json:"role"`
813 Content []json.RawMessage `json:"content"`
814 Time time.Time `json:"time"`
815 Meta map[string]string `json:"meta,omitempty"`
816 AddedToolNames []string `json:"added_tool_names,omitempty"`
817 }
818 if err := json.Unmarshal(rawMessage, &row); err != nil {
819 return provider.Message{}, err
820 }
821 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta, AddedToolNames: row.AddedToolNames}
822 for _, raw := range row.Content {
823 var head struct {
824 Text string `json:"text"`
825 MimeType string `json:"mime_type"`
826 Data []byte `json:"data"`
827 ID string `json:"id"`
828 Name string `json:"name"`
829 CallID string `json:"call_id"`
830 ReasoningID string `json:"reasoning_id"`
831 Summary string `json:"summary"`
832 Encrypted string `json:"encrypted_content"`
833 ThoughtSignature string `json:"thought_signature"`
834 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
835 }
836 if err := json.Unmarshal(raw, &head); err != nil {
837 continue
838 }
839 // Discriminate by presence of fields.
840 switch {
841 case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "":
842 msg.Content = append(msg.Content, provider.ReasoningBlock{
843 ID: head.ReasoningID,
844 Summary: head.Summary,
845 Encrypted: head.Encrypted,
846 })
847 case head.Name != "" && head.ID != "":
848 var tc struct {
849 ID string `json:"id"`
850 Name string `json:"name"`
851 Arguments json.RawMessage `json:"arguments"`
852 ThoughtSignature string `json:"thought_signature"`
853 }
854 _ = json.Unmarshal(raw, &tc)
855 msg.Content = append(msg.Content, provider.ToolCallBlock{
856 ID: tc.ID,
857 Name: tc.Name,
858 Arguments: tc.Arguments,
859 ThoughtSignature: tc.ThoughtSignature,
860 })
861 case head.CallID != "":
862 var tr struct {
863 CallID string `json:"call_id"`
864 Content []json.RawMessage `json:"content"`
865 IsError bool `json:"is_error"`
866 }
867 _ = json.Unmarshal(raw, &tr)
868 block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
869 for _, c := range tr.Content {
870 var inner struct {
871 Text string `json:"text"`
872 MimeType string `json:"mime_type"`
873 Data []byte `json:"data"`
874 }
875 _ = json.Unmarshal(c, &inner)
876 if inner.MimeType != "" {
877 block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
878 } else {
879 block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
880 }
881 }
882 msg.Content = append(msg.Content, block)
883 case head.MimeType != "":
884 msg.Content = append(msg.Content, provider.ImageBlock{
885 MimeType: head.MimeType,
886 Data: head.Data,
887 ThoughtSignature: head.ThoughtSignature,
888 })
889 default:
890 msg.Content = append(msg.Content, provider.TextBlock{
891 Text: head.Text,
892 ThoughtSignature: head.ThoughtSignature,
893 })
894 }
895 }
896 return msg, nil
897}