v0.3.47 56b82c0

session.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close