v0.3.30 2501d48

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		// 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}

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