// Package web is notevi's reading room: "notevi web" served or exported. // // A notevi log records who read what, where, and what they thought about it. // This package projects that log onto the jj change the code was read at: the repo // tree with per-agent coverage colors, tree-sitter highlighted sources with // coverage-tinted line numbers (colors mix where agents overlap), and notes — // both the ones agents left and the ones you write in the browser. // // It runs as a live web app (default) or writes a static export (-out). // Either way the log is the only state: notes are appended to a private jj // sidecar, never to the project working copy. // // Theme comes from a Zed theme JSON; per-agent colors come from the theme's // player (collaborator) palette. Every other theme this machine has — Zed's // and Helix's both — is offered in the picker, previewed as you move through // it, and remembered in the browser. package web import ( "embed" "flag" "fmt" "os" "os/user" "path/filepath" "strings" "time" "0x4200.cafe/notevi/sidecar" ) //go:embed templates assets var tfs embed.FS // Titles for a single repository's trace and for the dashboard over all of them. const ( defaultTitle = "notevi trace" hubDefaultTitle = "notevi projects" ) func fatal(format string, a ...any) { fmt.Fprintf(os.Stderr, "notevi web: "+format+"\n", a...) os.Exit(1) } const usageText = `notevi web — the reading room for notevi trace logs. notevi logs every file a coding agent reads, greps and notes, one JSON line each, keyed to the jj change the code was read at. This serves that log: the repo tree with per-agent read coverage, syntax-highlighted sources, the notes agents left, the conversation each note was written in, and jj queries. usage: notevi web [-scan-root DIR] [-addr HOST:PORT] [-user NAME] serve every discovered project from one dashboard; scans ~ by default notevi web -repo DIR [-addr HOST:PORT] [-user NAME] serve one repository directly, preserving the original single-project mode notevi web [-repo DIR] -out DIR [-watch] write a static copy instead — no server, so no comments, no live activity, no jj queries; the focus toggle, filters and file finder still work, and it holds the one change the log points at starting on a new project: 1. open the dashboard; it discovers jj and Git repositories below ~ 2. initialize colocated jj or start the private sidecar from its explicit button — no project registry or scan result is written to disk 3. give the agents notevi and tell them to read with it; constrain/AGENTS.md in the notevi repo is a drop-in brief that does, and constrain/ enforces it 4. let them work, then refresh the dashboard when a new repo appears A log belongs to one repository. Entries carry the jj change id they were recorded at, and the site reads the code at those changes, so a log from another repo has nothing to show here. Point -repo at the repository the agents ran in. Notes written in the browser are appended to the same private jj sidecar, under -user, at the exact change and commit being read. The project working copy is never changed, and the app keeps no state of its own. The sidecar has no bookmark and is local-only. It is not included in a fresh clone, so back up the jj repository or export the site when the trace matters. Session transcripts (the conversation behind a note) are looked up by session id under -transcripts; Claude Code and Codex are found automatically. The site is built with the two themes -theme and -light-theme name. Every other theme installed on this machine — Zed families and Helix themes both — is offered in the picker at the bottom of the tree: it previews as you move through it, and the browser remembers what you settled on. flags: ` // Main runs the "notevi web" subcommand; args are everything after "web". func Main(args []string) { home, err := os.UserHomeDir() if err != nil { home = "." } fs := flag.NewFlagSet("notevi web", flag.ExitOnError) repo := fs.String("repo", "", "serve one jj repo directly (with -out, default: current directory)") scanRoot := fs.String("scan-root", home, "live dashboard root to scan for jj and Git repositories") addr := fs.String("addr", "127.0.0.1:4200", "address to serve on") who := fs.String("user", "", "name your browser notes are logged under (default: $USER)") out := fs.String("out", "", "write a static site here instead of serving") themePath := fs.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)") themeName := fs.String("theme-name", "", "theme name inside the family") lightPath := fs.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)") lightName := fs.String("light-theme-name", "", "theme name inside the light family") title := fs.String("title", defaultTitle, "site title") roots := fs.String("transcripts", strings.Join(defaultTranscriptRoots(), ","), "comma-separated roots to look for harness session transcripts in") watch := fs.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle") debounce := fs.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild") fs.Usage = func() { fmt.Fprint(fs.Output(), usageText) fs.PrintDefaults() } fs.Parse(args) if *debounce < 0 { fatal("debounce must not be negative") } theme := loadTheme(*themePath, *themeName, "dark") light := loadTheme(*lightPath, *lightName, "light") themes := newThemeSet(theme, light) if *out == "" && *watch { fatal("-watch is for -out builds; the served app already follows the log") } // Explicit -repo and every static export retain the original one-repository // path. A bare live invocation is the new, stateless project dashboard. if *repo == "" && *out == "" { hubTitle := *title if hubTitle == defaultTitle { hubTitle = hubDefaultTitle } hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes) if err != nil { fatal("discover projects: %v", err) } serveProjects(*addr, hub) return } repoPath := *repo if repoPath == "" { repoPath = "." } repoAbs, err := filepath.Abs(repoPath) if err != nil { fatal("%v", err) } log, err := sidecar.Open(repoAbs) if err != nil { fatal("open jj sidecar: %v", err) } if *out == "" { serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) return } build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) } build() if !*watch { return } fmt.Printf("notevi web: watching %s (rebuild after %s without changes)\n", log, *debounce) watchLog(log, *debounce, build) } func username(flagValue string) string { if flagValue != "" { return flagValue } if u := os.Getenv("USER"); u != "" { return u } if u, err := user.Current(); err == nil && u.Username != "" { return u.Username } return "web" } // ── watching the log (static export only) ───────────────────────────────── func watchLog(log logStore, debounce time.Duration, rebuild func()) { watchLogUntil(log, debounce, time.Second, nil, rebuild) } func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { state, err := log.Generation() if err != nil { fatal("watch %s: %v", log, err) } ticker := time.NewTicker(poll) defer ticker.Stop() var timer *time.Timer var timerC <-chan time.Time defer func() { if timer != nil { timer.Stop() } }() lastStatErr := "" for { select { case <-done: return case <-ticker.C: next, err := log.Generation() if err != nil { if msg := err.Error(); msg != lastStatErr { fmt.Fprintf(os.Stderr, "notevi web: watch %s: %v\n", log, err) lastStatErr = msg } continue } lastStatErr = "" if next == state { continue } state = next if timer == nil { timer = time.NewTimer(debounce) timerC = timer.C } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(debounce) } fmt.Printf("notevi web: change detected; rebuild scheduled after %s without changes\n", debounce) case <-timerC: fmt.Println("notevi web: rebuilding after trace changes settled") timer = nil timerC = nil rebuild() } } }