From 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:43:08 -0300 Subject: 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) --- web/transcript.go | 602 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 web/transcript.go (limited to 'web/transcript.go') diff --git a/web/transcript.go b/web/transcript.go new file mode 100644 index 0000000..0678b80 --- /dev/null +++ b/web/transcript.go @@ -0,0 +1,602 @@ +// transcript.go — the conversation a session's reading happened inside. +// +// The notevi 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 web + +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 notevi log — a read, a grep, a note. +type Turn struct { + Role string // user | assistant | thinking | tool | result | notevi + Kind string // qualifies Role: the notevi 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 // notevi turns: the file the op touched + Note *Note // notevi 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-