summaryrefslogtreecommitdiff
path: root/vrsite/main.go
blob: 69c90a85c838b3c44a338ec931cbbb225c2c16fe (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// vrsite — the reading room for vr trace logs.
//
// A vr log records who read what, where, and what they thought about it. This
// program 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 it, and the
// repository is only ever read, never modified.
//
// 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 main

import (
	"embed"
	"flag"
	"fmt"
	"os"
	"os/user"
	"path/filepath"
	"strings"
	"time"
)

//go:embed templates assets
var tfs embed.FS

func fatal(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "vrsite: "+format+"\n", a...)
	os.Exit(1)
}

const usageText = `vrsite — the reading room for vr trace logs.

vr logs every file a coding agent reads, greps and notes, one JSON line each,
keyed to the jj change the code was read at. vrsite 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 over the repo.

usage:
  vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]
        serve it, at http://127.0.0.1:4200 by default
  vrsite -log FILE -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. the project must be a jj repo — in an existing git one:
       jj git init --colocate
  2. give the agents vr and one log for the repo:
       export VR_LOG=$PWD/vr-log.jsonl
     and tell them to read with it; constrain/AGENTS.md in the vr repo is a
     drop-in brief that does, and constrain/ can also enforce it
  3. let them work, then:
       vrsite -log $PWD/vr-log.jsonl -repo $PWD

A log belongs to one repository. Entries carry the jj change id they were
recorded at, and vrsite 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 log, under -user, at the
change being read. Nothing else is ever written: the repository is only read,
and the app keeps no state of its own.

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:
`

func main() {
	logPath := flag.String("log", "", "vr trace log (jsonl)")
	repo := flag.String("repo", "", "jj repo the trace was recorded in")
	addr := flag.String("addr", "127.0.0.1:4200", "address to serve on")
	who := flag.String("user", "", "name your browser notes are logged under (default: $USER)")
	out := flag.String("out", "", "write a static site here instead of serving")
	themePath := flag.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)")
	themeName := flag.String("theme-name", "", "theme name inside the family")
	lightPath := flag.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)")
	lightName := flag.String("light-theme-name", "", "theme name inside the light family")
	title := flag.String("title", "vr trace", "site title")
	roots := flag.String("transcripts", strings.Join(defaultTranscriptRoots(), ","),
		"comma-separated roots to look for harness session transcripts in")
	watch := flag.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle")
	debounce := flag.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild")
	flag.Usage = func() {
		fmt.Fprint(flag.CommandLine.Output(), usageText)
		flag.PrintDefaults()
	}
	flag.Parse()
	if *logPath == "" || *repo == "" {
		missing := "-log and -repo are required"
		if *logPath != "" {
			missing = "-repo is required: the repository the log was recorded in"
		} else if *repo != "" {
			missing = "-log is required: the jsonl file vr appends to"
		}
		fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing)
		flag.Usage()
		os.Exit(2)
	}
	repoAbs, err := filepath.Abs(*repo)
	if err != nil {
		fatal("%v", err)
	}
	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 == "" {
		if *watch {
			fatal("-watch is for -out builds; the served app already follows the log")
		}
		serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes)
		return
	}
	build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) }
	build()
	if !*watch {
		return
	}
	fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce)
	watchLog(*logPath, *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) ─────────────────────────────────

type logState struct {
	size    int64
	modTime time.Time
}

func statLog(path string) (logState, error) {
	info, err := os.Stat(path)
	if err != nil {
		return logState{}, err
	}
	return logState{size: info.Size(), modTime: info.ModTime()}, nil
}

func watchLog(path string, debounce time.Duration, rebuild func()) {
	watchLogUntil(path, debounce, time.Second, nil, rebuild)
}

func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
	state, err := statLog(path)
	if err != nil {
		fatal("watch %s: %v", path, 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 := statLog(path)
			if err != nil {
				if msg := err.Error(); msg != lastStatErr {
					fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, 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("vrsite: change detected; rebuild scheduled after %s without changes\n", debounce)

		case <-timerC:
			fmt.Println("vrsite: rebuilding after trace changes settled")
			timer = nil
			timerC = nil
			rebuild()
		}
	}
}