From 8ec4e135e318c7799b2576a8729493c063da4a97 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 30 Jul 2026 17:05:12 -0300 Subject: --- vrsite/transcript.go | 602 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 vrsite/transcript.go (limited to 'vrsite/transcript.go') diff --git a/vrsite/transcript.go b/vrsite/transcript.go new file mode 100644 index 0000000..713fe36 --- /dev/null +++ b/vrsite/transcript.go @@ -0,0 +1,602 @@ +// 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-