summaryrefslogtreecommitdiff
path: root/vrsite/transcript.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/transcript.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'vrsite/transcript.go')
-rw-r--r--vrsite/transcript.go602
1 files changed, 0 insertions, 602 deletions
diff --git a/vrsite/transcript.go b/vrsite/transcript.go
deleted file mode 100644
index 713fe36..0000000
--- a/vrsite/transcript.go
+++ /dev/null
@@ -1,602 +0,0 @@
-// 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
-// <root>/<mangled cwd>/<session>.jsonl; codex writes
-// <root>/YYYY/MM/DD/rollout-<time>-<thread>.jsonl and vr's session id is that
-// thread id, so it is matched on the suffix. Both patterns are tried under
-// every root — a root knows its own layout, we do not have to.
-func (tx *transcripts) find(session, dir string) string {
- if safeSession(session) != nil {
- return ""
- }
- for _, root := range tx.roots {
- pats := []string{
- filepath.Join(root, "*", session+".jsonl"),
- filepath.Join(root, "*", "*", "*", "rollout-*-"+session+".jsonl"),
- }
- // the cwd we logged names the directory directly, when it still exists
- if dir != "" {
- pats = append([]string{filepath.Join(root, mangleDir(dir), session+".jsonl")}, pats...)
- }
- for _, pat := range pats {
- hits, _ := filepath.Glob(pat)
- for _, hit := range hits {
- if under(root, hit) {
- return hit
- }
- }
- }
- }
- return ""
-}
-
-// load returns the parsed transcript for a session, or nil when there is none.
-// Parses are cached by path, mtime and size, so a 5 MB file is read once.
-func (tx *transcripts) load(session, dir string) *Transcript {
- path := tx.find(session, dir)
- if path == "" {
- return nil
- }
- st, err := os.Stat(path)
- if err != nil {
- return nil
- }
- key := fmt.Sprintf("%s|%d|%d", path, st.ModTime().UnixNano(), st.Size())
-
- tx.mu.Lock()
- t, ok := tx.cache[key]
- tx.mu.Unlock()
- if ok {
- return t
- }
-
- t = readTranscript(path, st.Size())
- tx.mu.Lock()
- if len(tx.cache) >= transcriptCacheMax {
- tx.cache = map[string]*Transcript{}
- }
- tx.cache[key] = t
- tx.mu.Unlock()
- return t
-}
-
-// ── parsing ───────────────────────────────────────────────────────────────
-
-// One line of either format. Codex wraps every record in "payload"; Claude
-// Code puts the model turn in "message". Sniffing per line keeps the two
-// parsers independent of any file header.
-type rawRecord struct {
- Type string `json:"type"`
- Timestamp string `json:"timestamp"`
- Payload json.RawMessage `json:"payload"`
- Message json.RawMessage `json:"message"`
-}
-
-type parser struct {
- t *Transcript
- tools map[string]string // tool call id -> name, so results keep their name
- last time.Time // records without a stamp inherit the previous one
-}
-
-func readTranscript(path string, size int64) *Transcript {
- t := &Transcript{Path: path}
- if size > maxTranscript {
- t.Err = fmt.Sprintf("transcript is %d MB — too large to parse", size>>20)
- return t
- }
- b, err := os.ReadFile(path)
- if err != nil {
- t.Err = err.Error()
- return t
- }
- p := &parser{t: t, tools: map[string]string{}}
- for _, line := range strings.Split(string(b), "\n") {
- if line == "" {
- continue
- }
- var rec rawRecord
- if json.Unmarshal([]byte(line), &rec) != nil {
- continue
- }
- at := parseTime(rec.Timestamp)
- if at.IsZero() {
- at = p.last
- } else {
- p.last = at
- }
- switch {
- case len(rec.Payload) > 0:
- t.Harness = "codex"
- p.codex(rec, at)
- case len(rec.Message) > 0:
- t.Harness = "claude-code"
- p.claude(rec, at)
- }
- }
- return t
-}
-
-// add drops turns that would render as nothing — empty thinking blocks,
-// tool results whose output was pure whitespace.
-func (p *parser) add(t Turn) {
- if t.Text == "" && t.Detail == "" && t.Tool == "" {
- return
- }
- t.Text = clip(t.Text, maxText)
- p.t.Turns = append(p.t.Turns, t)
-}
-
-// ── claude code ───────────────────────────────────────────────────────────
-
-type claudeMsg struct {
- Role string `json:"role"`
- Content json.RawMessage `json:"content"` // a string, or blocks
-}
-
-type claudeBlock struct {
- Type string `json:"type"`
- Text string `json:"text"`
- Thinking string `json:"thinking"`
- ID string `json:"id"`
- Name string `json:"name"`
- Input json.RawMessage `json:"input"`
- ToolUseID string `json:"tool_use_id"`
- Content json.RawMessage `json:"content"` // tool_result: a string, or blocks
- IsError bool `json:"is_error"`
-}
-
-// claude turns records of type user and assistant into Turns; the rest of the
-// file (modes, titles, file history, attachments) is harness bookkeeping.
-func (p *parser) claude(rec rawRecord, at time.Time) {
- if rec.Type != "user" && rec.Type != "assistant" {
- return
- }
- var m claudeMsg
- if json.Unmarshal(rec.Message, &m) != nil {
- return
- }
- var text string
- if json.Unmarshal(m.Content, &text) == nil { // plain prompts arrive as a string
- p.add(Turn{Role: rec.Type, When: at, Text: text})
- return
- }
- var blocks []claudeBlock
- if json.Unmarshal(m.Content, &blocks) != nil {
- return
- }
- for _, b := range blocks {
- switch b.Type {
- case "text":
- p.add(Turn{Role: rec.Type, When: at, Text: b.Text})
- case "thinking":
- p.add(Turn{Role: "thinking", When: at, Text: b.Thinking})
- case "tool_use":
- p.tools[b.ID] = b.Name
- p.add(Turn{Role: "tool", When: at, Tool: b.Name, Detail: callDetail(b.Input)})
- case "tool_result":
- t := Turn{Role: "result", When: at, Tool: p.tools[b.ToolUseID], Detail: resultDetail(b.Content)}
- if b.IsError {
- t.Kind = "error"
- }
- p.add(t)
- }
- }
-}
-
-// ── codex ─────────────────────────────────────────────────────────────────
-
-type codexPayload struct {
- Type string `json:"type"`
- Role string `json:"role"`
- Name string `json:"name"`
- CallID string `json:"call_id"`
- Arguments json.RawMessage `json:"arguments"` // a JSON string holding JSON
- Input json.RawMessage `json:"input"`
- Output json.RawMessage `json:"output"`
- Content []codexText `json:"content"`
- Summary []codexText `json:"summary"`
-}
-
-type codexText struct {
- Type string `json:"type"`
- Text string `json:"text"`
-}
-
-// codex reads response_items — the model's own history. The event_msg stream
-// beside it is the TUI's view of the same turns, so taking both would double
-// everything.
-func (p *parser) codex(rec rawRecord, at time.Time) {
- if rec.Type != "response_item" {
- return
- }
- var pl codexPayload
- if json.Unmarshal(rec.Payload, &pl) != nil {
- return
- }
- switch pl.Type {
- case "message":
- // developer messages are the harness's own instructions, and the first
- // user messages are context it injects; neither was said by anyone
- if pl.Role == "developer" {
- return
- }
- text := joinText(pl.Content)
- if injected(text) {
- return
- }
- role := "assistant"
- if pl.Role == "user" {
- role = "user"
- }
- p.add(Turn{Role: role, When: at, Text: text})
- case "reasoning":
- // summaries are usually encrypted and come back empty; add drops those
- p.add(Turn{Role: "thinking", When: at, Text: joinText(pl.Summary)})
- case "function_call", "custom_tool_call", "web_search_call":
- name := pl.Name
- if name == "" {
- name = strings.TrimSuffix(pl.Type, "_call")
- }
- p.tools[pl.CallID] = name
- detail := callDetail(pl.Arguments)
- if detail == "" {
- detail = callDetail(pl.Input)
- }
- p.add(Turn{Role: "tool", When: at, Tool: name, Detail: detail})
- case "function_call_output", "custom_tool_call_output", "web_search_output":
- p.add(Turn{Role: "result", When: at, Tool: p.tools[pl.CallID], Detail: resultDetail(pl.Output)})
- }
-}
-
-func joinText(items []codexText) string {
- var parts []string
- for _, it := range items {
- if it.Text != "" {
- parts = append(parts, it.Text)
- }
- }
- return strings.Join(parts, "\n\n")
-}
-
-// injected recognizes the wrappers a harness puts around a user turn to carry
-// its own state.
-func injected(text string) bool {
- for _, tag := range []string{"<environment_context>", "<user_instructions>", "<permissions instructions>"} {
- if strings.HasPrefix(text, tag) {
- return true
- }
- }
- return false
-}
-
-// ── summarizing ───────────────────────────────────────────────────────────
-
-// callFields are the arguments worth a line, most telling first. Anything that
-// carries file content (new_string, content) is deliberately not here.
-var callFields = []string{"command", "cmd", "file_path", "path", "pattern", "query", "url", "prompt", "description"}
-
-// callDetail reduces a tool call's arguments to their one interesting field.
-func callDetail(raw json.RawMessage) string {
- if len(raw) == 0 {
- return ""
- }
- var s string
- if json.Unmarshal(raw, &s) == nil { // codex passes arguments as a JSON string
- raw = json.RawMessage(s)
- }
- var m map[string]any
- if json.Unmarshal(raw, &m) != nil {
- return brief(string(raw))
- }
- for _, k := range callFields {
- if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
- return brief(v)
- }
- }
- b, err := json.Marshal(m)
- if err != nil {
- return ""
- }
- return brief(string(b))
-}
-
-// resultDetail summarizes what a tool answered, whether that came back as a
-// string, as content blocks, or as an image nobody wants inlined.
-func resultDetail(raw json.RawMessage) string {
- if len(raw) == 0 {
- return ""
- }
- var s string
- if json.Unmarshal(raw, &s) == nil {
- return brief(s)
- }
- var items []codexText
- if json.Unmarshal(raw, &items) == nil {
- var parts []string
- for _, it := range items {
- switch {
- case it.Text != "":
- parts = append(parts, it.Text)
- case it.Type != "":
- parts = append(parts, "["+it.Type+"]")
- }
- }
- return brief(strings.Join(parts, "\n"))
- }
- return brief(string(raw))
-}
-
-// brief keeps a blob's first line and says how much was left behind. Whole
-// files and command output pass through here; a page gets their shape, never
-// their bytes.
-func brief(s string) string {
- s = strings.TrimRight(s, "\n")
- if strings.TrimSpace(s) == "" {
- return ""
- }
- first, rest, more := strings.Cut(s, "\n")
- first = clip(strings.TrimSpace(first), briefLine)
- if !more || strings.TrimSpace(rest) == "" {
- return first
- }
- return fmt.Sprintf("%s … +%d lines", first, strings.Count(rest, "\n")+1)
-}
-
-func clip(s string, n int) string {
- if len(s) <= n { // bytes first: the common case never allocates
- return s
- }
- r := []rune(s)
- if len(r) <= n {
- return s
- }
- return strings.TrimRight(string(r[:n]), " \t") + "…"
-}
-
-// ── one session: the transcript with the vr log woven in ──────────────────
-
-// A Conv is what the session page shows: the conversation, the log entries
-// that happened during it, and where in a long one we are looking.
-type Conv struct {
- Session, Agent, Model, Dir string
- Bit uint8
- Harness, Path string
- Roots []string
- Err string
- Reads, Greps, Notes int
-
- Turns []Turn
- Total int // moments in the whole conversation
- From, To int // 1-based position of Turns within it
- Anchor string // element id to scroll to, "" when nothing is anchored
- AllHref string // same page, unwindowed
- Windowed bool
- Incomplete bool // even "show all" had to stop
-}
-
-// sessionTurns are the vr log's own moments for a session: what it read,
-// grepped and noted.
-func (s *Site) sessionTurns(id string) []Turn {
- var out []Turn
- for _, e := range s.Entries {
- if e.Session != id || e.Op == "note" {
- continue
- }
- out = append(out, Turn{Role: "vr", Kind: e.Op, When: parseTime(e.Time),
- Text: describe(e), File: e.File})
- }
- for _, n := range s.Notes {
- if n.Session == id {
- out = append(out, Turn{Role: "vr", Kind: "note", When: n.At, File: n.File, Note: n})
- }
- }
- return out
-}
-
-// conversation merges a session's transcript with its log entries. Both sides
-// are compared as instants — the log stamps carry an offset, transcripts are
-// UTC — so ordering is the real one, and only the rendering is local.
-func (sv *server) conversation(site *Site, id string, note int, all bool) *Conv {
- c := &Conv{Session: id, Roots: sv.tx.roots}
- if err := safeSession(id); err != nil {
- c.Err = err.Error()
- return c
- }
- for _, s := range site.Sessions(time.Now(), liveWindow, 0) {
- if s.ID == id {
- c.Agent, c.Model, c.Dir, c.Bit = s.Agent, s.Model, s.Dir, s.Bit
- c.Reads, c.Greps, c.Notes = s.Reads, s.Greps, s.Notes
- break
- }
- }
-
- logged := site.sessionTurns(id)
- // the parsed transcript is shared with other requests, so the merge copies
- // it rather than appending onto its slice
- var turns []Turn
- if t := sv.tx.load(id, c.Dir); t != nil {
- c.Path, c.Harness, c.Err = t.Path, t.Harness, t.Err
- turns = make([]Turn, 0, len(t.Turns)+len(logged))
- turns = append(turns, t.Turns...)
- }
- turns = append(turns, logged...)
- sort.SliceStable(turns, func(i, j int) bool { return turns[i].When.Before(turns[j].When) })
-
- at := -1
- if note > 0 {
- for i := range turns {
- if turns[i].Note != nil && turns[i].Note.ID == note {
- at, turns[i].Here = i, true
- c.Anchor = fmt.Sprintf("note-%d", note)
- break
- }
- }
- }
- c.Total = len(turns)
- from, window := convWindowAt(turns, at, all)
- c.Turns, c.From, c.To = window, from+1, from+len(window)
- c.Windowed = len(window) < len(turns)
- c.Incomplete = c.Windowed && all
- c.AllHref = "/session/" + escPath(id) + "?all=1"
- if note > 0 {
- c.AllHref += fmt.Sprintf("&note=%d#note-%d", note, note)
- }
- return c
-}
-
-// convWindowAt cuts a conversation down to what a page may render: a stretch
-// around the anchored moment, or the tail of it when nothing is anchored.
-// "show all" raises the bound but does not remove it.
-func convWindowAt(turns []Turn, at int, all bool) (int, []Turn) {
- n := convWindow
- if all {
- n = convMax
- }
- if len(turns) <= n {
- return 0, turns
- }
- if at < 0 {
- return len(turns) - n, turns[len(turns)-n:]
- }
- lo := at - n/2
- if lo < 0 {
- lo = 0
- }
- if lo+n > len(turns) {
- lo = len(turns) - n
- }
- return lo, turns[lo : lo+n]
-}