// transcript.go — the conversation a session's reading happened inside. // // The vr log says a session read a file at 10:04; the harness that ran that // session kept the whole conversation on disk, in its own format. Here we find // that file by session id and flatten both known formats into Turns, so a note // can be opened at the moment it was written. // // Two rules keep this cheap and safe. Tool calls and their output are reduced // to a line each — transcripts reach megabytes and none of that belongs in a // page — and nothing outside a configured root is ever opened, with session // ids checked before they touch the filesystem. package main import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) const ( maxTranscript = 32 << 20 // refuse to parse more than this in one file transcriptCacheMax = 8 // parsed transcripts are large; keep few briefLine = 160 // one line of a tool call or its output maxText = 6000 // one message convWindow = 80 // turns rendered around the anchored moment convMax = 1500 // turns rendered by "show all" ) // A Turn is one moment in a session: something said, a tool called or answered, // or — woven in from the vr log — a read, a grep, a note. type Turn struct { Role string // user | assistant | thinking | tool | result | vr Kind string // qualifies Role: the vr op, or "error" on a failed tool When time.Time Text string // what was said Tool string // tool name, on tool and result turns Detail string // one compact line: the call's arguments, or its output File string // vr turns: the file the op touched Note *Note // vr note turns: the note itself, rendered as a card Here bool // the anchored moment } func (t Turn) Clock() string { if t.When.IsZero() { return "" } return t.When.Local().Format("15:04:05") } // A Transcript is one harness conversation, already normalized. type Transcript struct { Path string Harness string // claude-code | codex Turns []Turn Err string } // transcripts finds and parses harness transcripts, remembering what it has // already parsed. Roots are the only places it will look. type transcripts struct { roots []string mu sync.Mutex cache map[string]*Transcript // path|mtime|size -> parsed } func newTranscripts(roots []string) *transcripts { tx := &transcripts{cache: map[string]*Transcript{}} for _, r := range roots { r = strings.TrimSpace(r) if r == "" { continue } if abs, err := filepath.Abs(r); err == nil { r = abs } tx.roots = append(tx.roots, filepath.Clean(r)) } return tx } // defaultTranscriptRoots are where the two harnesses we know about keep their // conversations. func defaultTranscriptRoots() []string { home, err := os.UserHomeDir() if err != nil { return nil } return []string{filepath.Join(home, ".claude", "projects"), filepath.Join(home, ".codex", "sessions")} } // safeSession refuses ids that could name something we did not mean to open: // the id goes into a filesystem path, so it must be one plain component. func safeSession(id string) error { if id == "" { return fmt.Errorf("no session id") } if len(id) > 128 { return fmt.Errorf("session id is too long") } for _, r := range id { ok := r == '-' || r == '_' || r == ':' || r == '.' || '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' if !ok { return fmt.Errorf("session id contains %q", r) } } if strings.Contains(id, "..") { return fmt.Errorf("session id contains %q", "..") } return nil } // mangleDir is how Claude Code names a project directory after its cwd: // /home/u/0x4200.cafe -> -home-u-0x4200-cafe. func mangleDir(dir string) string { return strings.NewReplacer("/", "-", ".", "-").Replace(dir) } // under states the rule the globs below already obey: a transcript we open // lies inside a configured root. It is checked anyway, so the rule has one // place to fail rather than living in the shape of two patterns. func under(root, path string) bool { rel, err := filepath.Rel(root, path) if err != nil { return false } return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // find locates a session's transcript. Claude Code writes // //.jsonl; codex writes // /YYYY/MM/DD/rollout-