diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:43:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-03 09:54:39 -0300 |
| commit | 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch) | |
| tree | f12f4b700fff4ff6f7e818d2a542672ba0b8d76e | |
| parent | a9263daee9413c5eff3c1f1bebcd224442fd8685 (diff) | |
| download | notevi-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]>
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 125 | ||||
| -rw-r--r-- | constrain/AGENTS.md | 14 | ||||
| -rw-r--r-- | constrain/README.txt | 57 | ||||
| -rw-r--r-- | constrain/claude-headless-settings.json | 4 | ||||
| -rw-r--r-- | constrain/claude-settings.json | 4 | ||||
| -rwxr-xr-x | constrain/notevi-only-guard.sh (renamed from constrain/vr-only-guard.sh) | 6 | ||||
| -rw-r--r-- | constrain/notevi-only.rules (renamed from constrain/vr-only.rules) | 8 | ||||
| -rw-r--r-- | doc.txt | 36 | ||||
| -rw-r--r-- | go.mod | 24 | ||||
| -rw-r--r-- | go.sum (renamed from vrsite/go.sum) | 0 | ||||
| -rw-r--r-- | main.go | 84 | ||||
| -rw-r--r-- | query-doc.txt | 18 | ||||
| -rw-r--r-- | sidecar/store.go | 295 | ||||
| -rw-r--r-- | sidecar/store_test.go | 134 | ||||
| -rw-r--r-- | vrsite/go.mod | 26 | ||||
| -rw-r--r-- | web/app_test.go (renamed from vrsite/app_test.go) | 12 | ||||
| -rw-r--r-- | web/assets/app.js (renamed from vrsite/assets/app.js) | 20 | ||||
| -rw-r--r-- | web/assets/bash.scm (renamed from vrsite/assets/bash.scm) | 0 | ||||
| -rw-r--r-- | web/assets/bemtevi.svg | 31 | ||||
| -rw-r--r-- | web/assets/c.scm (renamed from vrsite/assets/c.scm) | 0 | ||||
| -rw-r--r-- | web/assets/cpp.scm (renamed from vrsite/assets/cpp.scm) | 0 | ||||
| -rw-r--r-- | web/assets/go.scm (renamed from vrsite/assets/go.scm) | 0 | ||||
| -rw-r--r-- | web/assets/htmx.min.js (renamed from vrsite/assets/htmx.min.js) | 0 | ||||
| -rw-r--r-- | web/assets/javascript-params.scm (renamed from vrsite/assets/javascript-params.scm) | 0 | ||||
| -rw-r--r-- | web/assets/javascript.scm (renamed from vrsite/assets/javascript.scm) | 0 | ||||
| -rw-r--r-- | web/assets/json.scm (renamed from vrsite/assets/json.scm) | 0 | ||||
| -rw-r--r-- | web/assets/jsx.scm (renamed from vrsite/assets/jsx.scm) | 0 | ||||
| -rw-r--r-- | web/assets/one.json (renamed from vrsite/assets/one.json) | 2 | ||||
| -rw-r--r-- | web/assets/python.scm (renamed from vrsite/assets/python.scm) | 0 | ||||
| -rw-r--r-- | web/assets/rust.scm (renamed from vrsite/assets/rust.scm) | 0 | ||||
| -rw-r--r-- | web/assets/style.css.tmpl (renamed from vrsite/assets/style.css.tmpl) | 6 | ||||
| -rw-r--r-- | web/assets/typescript.scm (renamed from vrsite/assets/typescript.scm) | 0 | ||||
| -rw-r--r-- | web/assets/zig.scm (renamed from vrsite/assets/zig.scm) | 0 | ||||
| -rw-r--r-- | web/bench-browser.mjs (renamed from vrsite/bench-browser.mjs) | 4 | ||||
| -rw-r--r-- | web/bench_test.go (renamed from vrsite/bench_test.go) | 2 | ||||
| -rw-r--r-- | web/css.go (renamed from vrsite/css.go) | 4 | ||||
| -rw-r--r-- | web/highlight_test.go (renamed from vrsite/highlight_test.go) | 2 | ||||
| -rw-r--r-- | web/jj.go (renamed from vrsite/jj.go) | 2 | ||||
| -rw-r--r-- | web/log.go (renamed from vrsite/log.go) | 2 | ||||
| -rw-r--r-- | web/markdown.go (renamed from vrsite/markdown.go) | 2 | ||||
| -rw-r--r-- | web/markdown_test.go (renamed from vrsite/markdown_test.go) | 4 | ||||
| -rw-r--r-- | web/model.go (renamed from vrsite/model.go) | 6 | ||||
| -rw-r--r-- | web/page.go (renamed from vrsite/page.go) | 2 | ||||
| -rw-r--r-- | web/permalink_test.go (renamed from vrsite/permalink_test.go) | 2 | ||||
| -rw-r--r-- | web/projects.go (renamed from vrsite/projects.go) | 63 | ||||
| -rw-r--r-- | web/projects_test.go (renamed from vrsite/projects_test.go) | 16 | ||||
| -rw-r--r-- | web/render.go (renamed from vrsite/render.go) | 2 | ||||
| -rw-r--r-- | web/serve.go (renamed from vrsite/serve.go) | 25 | ||||
| -rw-r--r-- | web/static.go (renamed from vrsite/static.go) | 8 | ||||
| -rw-r--r-- | web/templates/frag-activity.tmpl (renamed from vrsite/templates/frag-activity.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/frag-change.tmpl (renamed from vrsite/templates/frag-change.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/frag-jj.tmpl (renamed from vrsite/templates/frag-jj.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/frag-note.tmpl (renamed from vrsite/templates/frag-note.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/layout.tmpl (renamed from vrsite/templates/layout.tmpl) | 9 | ||||
| -rw-r--r-- | web/templates/page-change.tmpl (renamed from vrsite/templates/page-change.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-file.tmpl (renamed from vrsite/templates/page-file.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-files.tmpl (renamed from vrsite/templates/page-files.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-index.tmpl (renamed from vrsite/templates/page-index.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-jj.tmpl (renamed from vrsite/templates/page-jj.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-note.tmpl (renamed from vrsite/templates/page-note.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-notes.tmpl (renamed from vrsite/templates/page-notes.tmpl) | 0 | ||||
| -rw-r--r-- | web/templates/page-session.tmpl (renamed from vrsite/templates/page-session.tmpl) | 10 | ||||
| -rw-r--r-- | web/templates/projects.tmpl (renamed from vrsite/templates/projects.tmpl) | 15 | ||||
| -rw-r--r-- | web/theme.go (renamed from vrsite/theme.go) | 2 | ||||
| -rw-r--r-- | web/themes.go (renamed from vrsite/themes.go) | 2 | ||||
| -rw-r--r-- | web/themes_test.go (renamed from vrsite/themes_test.go) | 2 | ||||
| -rw-r--r-- | web/transcript.go (renamed from vrsite/transcript.go) | 24 | ||||
| -rw-r--r-- | web/transcript_test.go (renamed from vrsite/transcript_test.go) | 22 | ||||
| -rw-r--r-- | web/tree.go (renamed from vrsite/tree.go) | 2 | ||||
| -rw-r--r-- | web/web.go (renamed from vrsite/main.go) | 90 | ||||
| -rw-r--r-- | web/web_test.go (renamed from vrsite/main_test.go) | 2 |
72 files changed, 888 insertions, 347 deletions
@@ -1,3 +1,2 @@ -/vr -/vrsite/vrsite +/notevi /site diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ca6eff --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +<img src="web/assets/bemtevi.svg" width="96" align="right" alt=""> + +# notevi + +A code reader that remembers. Read files and grep through `notevi`, pin notes to +line ranges, and every one of those calls is appended as a single JSON line to +an **append-only JSONL log** kept in a **private jj commit**. Not your working +copy. Not your history. + +Two front ends over that one log — a CLI and a local web app. Both read it, both +write to it. Use either. Use only one. + +The bem-te-vi calls all day and keeps a bright crown patch hidden under a plain +black cap. Same idea: the notes are always there, never in the way. + +## Install + +```sh +go build -o ~/.local/bin/notevi . +``` + +Needs `jj` (the repo must be a jj workspace) and `rg` for working-copy greps. + +## From the shell + +```sh +notevi read src/lib.rs # whole file, line-numbered +notevi read src/lib.rs:120-180 # just the range +notevi grep 'fn resolve' src/ # regex over the working copy + +notevi note -f src/lib.rs:143 -t invariant "holds only under the bank lock" +notevi note -reply 12 "no — see the early return above" + +notevi query -op note # replay the log, oldest first +notevi query -file accounts_db # everything touching a path +``` + +Add `-r REV` to any of them to work at another revision — any jj revset, default +`@`. Every entry records the full change **and** commit id, so a note stays +pinned to the exact bytes it was about even after the change is rewritten. +Notes take an optional `-t KIND` (`fn`, `struct`, `invariant`, whatever you +want) and can reply to each other by number. + +## From the browser + +```sh +notevi web # dashboard: every sidecar under ~ +notevi web -repo . # one repo, http://127.0.0.1:4200 +``` + +**This is not a viewer.** Click a line, type, and the note is appended to the +same JSONL log `notevi note` writes to — same numbering, same permalinks, at the +exact change and commit you are looking at, attributed to your username. Reply +to notes, thread them, filter by file, text, kind, author, model, session, +change or date range. Never open the CLI and you lose nothing. + +What it shows: + +- the repo tree with per-reader coverage, and sources highlighted by tree-sitter + with coverage-tinted line numbers — the colors mix where readers overlap +- every note pinned to its lines, plus a notes-only mode across the whole repo +- who is reading right now, refreshed every few seconds +- ad-hoc jj queries over the repo +- every Zed and Helix theme installed on the machine, previewed live as you + arrow through the picker + +The dashboard scans `~` for repositories, and can start a sidecar in a jj repo +or add colocated jj to a Git one — so a project can begin in the browser too. +It writes no registry and no scan cache. + +```sh +notevi web -repo . -out site # static export: hand it to someone +``` + +The export is a plain directory of HTML. Tree, coverage, highlighting, notes, +filters and the file finder all still work with no server; writing notes, live +activity and jj queries do not, because there is nothing running. + +## The log + +One file, `notevi-log.jsonl`, only ever appended to. Lines are never edited and +never deleted: a note you got wrong is superseded by a later note, not rewritten, +so the trace stays a faithful record of what was believed when. Which also means +you can skip the tooling entirely: + +```sh +jj file show -r 'exactly(notevi_log, 1)' -- notevi-log.jsonl | + jq -c 'select(.op == "note") | {file, start, text}' +``` + +That file lives in one anonymous commit whose parent is `root()`, described +`private: notevi log`. The repo-local revset alias `notevi_log` names it and +`git.private-commits` keeps it from being pushed. Each append is a new revision +of that commit, so the evolog is the log's own history: + +```sh +jj evolog -r 'exactly(notevi_log, 1)' --reversed -p --git +``` + +Appends are serialized and go through an isolated `jj run`; your `@` is never +snapshotted or moved. There is no bookmark and no push — **a fresh clone will +not have your notes.** Back up the jj repo, or export the site. + +## If a coding agent is the one running it + +`notevi` notices. It reads the harness environment (Claude Code, Codex, Hermes), +falling back to walking the process tree, and adds session id, model and +reasoning effort to every entry it writes. Nothing to configure; a human at a +shell just gets entries without those fields. + +That turns the trace into a record of what an agent actually looked at before it +told you something. The web view colors coverage per reader, so overlapping +work is visible, and each note links to the conversation turn it came from. + +`constrain/` goes further and makes `notevi` the *only* way to read: a Claude +Code `PreToolUse` hook and a codex execpolicy ruleset that deny +`cat`/`rg`/`sed`/`jj file show`/`git show`/… and point the agent at +`notevi -doc`. Agents converge on it with zero instructions. See +`constrain/README.txt`. + +## Coming from `vr` / `vrsite` + +Same tool, one binary. Old sidecars (`vr_log`, `vr-log.jsonl`) are still +recognized and renamed in place on first write; `notevi migrate DIR...` does it +up front. diff --git a/constrain/AGENTS.md b/constrain/AGENTS.md index 871fc6c..93df43b 100644 --- a/constrain/AGENTS.md +++ b/constrain/AGENTS.md @@ -1,14 +1,14 @@ # Reading code here -Files are read with the `vr` tool — run `vr -doc` once for full usage. +Files are read with the `notevi` tool — run `notevi -doc` once for full usage. -`vr` stores the trace in the repo's private local jj sidecar named `vr_log`. -Do not set `VR_LOG`, pass `-log`, or create a working-tree `vr-log.jsonl`. +`notevi` stores the trace in the repo's private local jj sidecar named +`notevi_log`. Do not create a working-tree log file; there is no path to set. -- `vr read FILE:START-END` — line-numbered read; prefer ranges over whole files -- `vr grep PATTERN [PATH]` — regex search; scope with a path -- `vr read -r REV FILE`, `vr grep -r REV PATTERN PATH` — at another jj change -- `vr note -f FILE:10-42 -t struct TEXT...` — record observations as you read +- `notevi read FILE:START-END` — line-numbered read; prefer ranges over whole files +- `notevi grep PATTERN [PATH]` — regex search; scope with a path +- `notevi read -r REV FILE`, `notevi grep -r REV PATTERN PATH` — at another jj change +- `notevi note -f FILE:10-42 -t struct TEXT...` — record observations as you read Navigate top-down: grep for the symbol, read the enclosing range, note what you learn. Every entry records the full current jj change and commit IDs; the diff --git a/constrain/README.txt b/constrain/README.txt index 4da2733..6d4b882 100644 --- a/constrain/README.txt +++ b/constrain/README.txt @@ -1,4 +1,4 @@ -Constrain agents to read files only through vr — enforcement comes from +Constrain agents to read files only through notevi — enforcement comes from harness config; AGENTS.md is navigation guidance only, never the constraint. ── files here ────────────────────────────────────────────────────────────── @@ -6,26 +6,28 @@ claude-settings.json project install: <repo>/.claude/settings.json (hook path uses $CLAUDE_PROJECT_DIR) claude-headless-settings.json no-install variant for `claude -p --settings` (hook path is absolute into this dir) -vr-only-guard.sh PreToolUse hook: blocks cat/rg/sed/... and +notevi-only-guard.sh PreToolUse hook: blocks cat/rg/sed/... and jj/git content reads; its error message points - the agent at `vr -doc`, so agents converge + the agent at `notevi -doc`, so agents converge even with zero instructions -vr-only.rules codex execpolicy rules (allow vr, forbid readers) -AGENTS.md how-to-navigate-with-vr guidance for the repo +notevi-only.rules codex execpolicy rules (allow notevi, forbid + readers) +AGENTS.md how-to-navigate-with-notevi guidance for the repo ── one-time setup ────────────────────────────────────────────────────────── -put both on PATH: go build -o ~/.local/bin/vr . (repo root) - cd vrsite && go build -o ~/.local/bin/vrsite . - (vr is what agents call; vrsite is how you read the trace) +put it on PATH: go build -o ~/.local/bin/notevi . (repo root) + + one binary now: agents call `notevi read/grep/note`, and you + read the trace back with `notevi web` ── running a constrained CLAUDE investigation ────────────────────────────── From the target jj repo dir: - claude -p --settings <vr-repo>/constrain/claude-headless-settings.json \ - --allowedTools 'Bash(vr)' 'Bash(vr:*)' \ + claude -p --settings <notevi-repo>/constrain/claude-headless-settings.json \ + --allowedTools 'Bash(notevi)' 'Bash(notevi:*)' \ < prompt.txt > report.md - (<vr-repo> is wherever this repo lives; the settings file's hook path is + (<notevi-repo> is wherever this repo lives; the settings file's hook path is absolute into this directory, so it must be spelled out in full) - --allowedTools on the CLI is required headless: allow rules inside @@ -36,42 +38,45 @@ From the target jj repo dir: eaten by the flag's list parsing. ── running a constrained CODEX investigation ─────────────────────────────── - cp vr-only.rules ~/.codex/rules/ # activate (GLOBAL: constrains - # every codex session while there) + cp notevi-only.rules ~/.codex/rules/ # activate (GLOBAL: constrains + # every codex session while there) codex exec -s danger-full-access "$(cat prompt.txt)" > report.md - rm ~/.codex/rules/vr-only.rules # deactivate when done + rm ~/.codex/rules/notevi-only.rules # deactivate when done - danger-full-access is required: workspace-write blocks jj metadata writes, - including the sidecar rewrite and `vr read`'s working-copy snapshot. + including the sidecar rewrite and `notevi read`'s working-copy snapshot. - enforcement is pre-exec by codex's execpolicy engine, even through `zsh -lc` wrappers; validate rules with: - codex execpolicy check --rules vr-only.rules -- cat foo.txt + codex execpolicy check --rules notevi-only.rules -- cat foo.txt ── shared trace + rendering ──────────────────────────────────────────────── - - vr creates one anonymous sidecar change directly under jj's root() on the - first append. The repo-local alias vr_log names it; git.private-commits - protects it from accidental pushes. No path or environment setup exists. + - notevi creates one anonymous sidecar change directly under jj's root() on + the first append. The repo-local alias notevi_log names it; + git.private-commits protects it from accidental pushes. No path or + environment setup exists. + - A repository last written by the older `vr` tool is renamed in place the + first time notevi touches it; `notevi migrate <repo>...` does it up front. - Multiple agents in the repo share that sidecar. Rewrites are serialized, note ids are assigned under the same lock, and the project @ is untouched. - The sidecar has no bookmark and is local-only. It does not survive a fresh clone; include the jj repository in backups when the trace matters. - In the prompt, tell the agent to leave pinned notes - (vr note -f FILE:START-END -t kind "...") — that is the payload. - - Read traces afterwards from one process. Bare `vrsite` scans below ~ at + (notevi note -f FILE:START-END -t kind "...") — that is the payload. + - Read traces afterwards from one process. Bare `notevi web` scans below ~ at startup and when Refresh is pressed, then offers every repo with a sidecar: - vrsite + notevi web Its explicit buttons can start an empty private sidecar in a discovered jj repo or initialize colocated jj in a discovered Git repo. The project index is only in memory; those two requested metadata changes are the only writes. - To bypass the dashboard and serve one repository directly: - vrsite -repo <repo> -title "..." + notevi web -repo <repo> -title "..." or take a static copy to hand around (one change, no server features): - vrsite -repo <repo> -out site -title "..." - `vrsite -h` explains both, and what a log needs to be worth reading. + notevi web -repo <repo> -out site -title "..." + `notevi web -h` explains both, and what a log needs to be worth reading. ── known holes (accepted) ────────────────────────────────────────────────── Scripting runtimes (python/node/perl) can still open files — uncomment their -rules in vr-only.rules / extend the hook to close, at the cost of breaking +rules in notevi-only.rules / extend the hook to close, at the cost of breaking legitimate scripts. Neither harness constrains its own non-shell internals beyond what the deny rules cover. Codex's rules file is global-only; there is no per-project rules mechanism (probed, none exists as of codex 0.145). diff --git a/constrain/claude-headless-settings.json b/constrain/claude-headless-settings.json index 30aff9b..b22704c 100644 --- a/constrain/claude-headless-settings.json +++ b/constrain/claude-headless-settings.json @@ -1,7 +1,7 @@ { "permissions": { "deny": ["Read", "Grep", "Glob"], - "allow": ["Bash(vr)", "Bash(vr:*)"] + "allow": ["Bash(notevi)", "Bash(notevi:*)"] }, "hooks": { "PreToolUse": [ @@ -10,7 +10,7 @@ "hooks": [ { "type": "command", - "command": "/home/goblin/00-projects/01-tools/vr-agent-logger/constrain/vr-only-guard.sh" + "command": "/home/goblin/00-projects/01-tools/notevi/constrain/notevi-only-guard.sh" } ] } diff --git a/constrain/claude-settings.json b/constrain/claude-settings.json index 524ccbd..639befb 100644 --- a/constrain/claude-settings.json +++ b/constrain/claude-settings.json @@ -1,7 +1,7 @@ { "permissions": { "deny": ["Read", "Grep", "Glob"], - "allow": ["Bash(vr)", "Bash(vr:*)"] + "allow": ["Bash(notevi)", "Bash(notevi:*)"] }, "hooks": { "PreToolUse": [ @@ -10,7 +10,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/vr-only-guard.sh" + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notevi-only-guard.sh" } ] } diff --git a/constrain/vr-only-guard.sh b/constrain/notevi-only-guard.sh index 0de698f..729f4d4 100755 --- a/constrain/vr-only-guard.sh +++ b/constrain/notevi-only-guard.sh @@ -1,6 +1,6 @@ #!/bin/sh # Claude Code PreToolUse hook (matcher: Bash): deny shell commands that read -# files without going through vr. A guardrail, not a jail — it catches the +# files without going through notevi. A guardrail, not a jail — it catches the # common readers at command position, not every conceivable bypass. cmd=$(jq -r '.tool_input.command // empty') @@ -8,12 +8,12 @@ readers='cat|head|tail|less|more|sed|awk|cut|rg|grep|egrep|fgrep|find|fd|strings pattern='(^|[;&|(`]|\$\()[[:space:]]*('$readers')([[:space:]]|$)' if printf '%s' "$cmd" | grep -qE "$pattern"; then - echo "blocked: read/search files only through vr (run 'vr -doc' for usage)" >&2 + echo "blocked: read/search files only through notevi (run 'notevi -doc' for usage)" >&2 exit 2 fi vcs='jj[[:space:]]+(file[[:space:]]+show|diff)|git[[:space:]]+(show|diff|grep|cat-file|blame|log)' if printf '%s' "$cmd" | grep -qE "(^|[;&|(\`])[[:space:]]*($vcs)"; then - echo "blocked: use 'vr read -r REV FILE' / 'vr grep -r REV' instead of raw jj/git reads" >&2 + echo "blocked: use 'notevi read -r REV FILE' / 'notevi grep -r REV' instead of raw jj/git reads" >&2 exit 2 fi exit 0 diff --git a/constrain/vr-only.rules b/constrain/notevi-only.rules index 54d3392..f618f18 100644 --- a/constrain/vr-only.rules +++ b/constrain/notevi-only.rules @@ -1,12 +1,12 @@ -# codex execpolicy: force file reading through vr (which logs every read). -# Activate: cp vr-only.rules ~/.codex/rules/ -# Deactivate: rm ~/.codex/rules/vr-only.rules +# codex execpolicy: force file reading through notevi (which logs every read). +# Activate: cp notevi-only.rules ~/.codex/rules/ +# Deactivate: rm ~/.codex/rules/notevi-only.rules # The engine parses through `/bin/zsh -lc '...'` wrappers, so these match the # inner command. Editing tools (apply_patch) are unaffected — this only # constrains reading. Known hole: scripting runtimes can still open files; # uncomment the last block to close it at the cost of breaking legit scripts. -prefix_rule(pattern=["vr"], decision="allow") +prefix_rule(pattern=["notevi"], decision="allow") prefix_rule(pattern=["cat"], decision="forbidden") prefix_rule(pattern=["head"], decision="forbidden") @@ -1,28 +1,30 @@ -vr — read code and take notes; every call is logged in a private jj sidecar. +notevi — read code and take notes; every call is logged in a private jj sidecar. read a file (line-numbered, relative to a jj revision): - vr read FILE whole file - vr read FILE:120-180 line range (or FILE:120 for one line) - vr read -r REV FILE the file as it is in another jj change + notevi read FILE whole file + notevi read FILE:120-180 line range (or FILE:120 for one line) + notevi read -r REV FILE the file as it is in another jj change search (regex): - vr grep PATTERN [PATH] search the working copy - vr grep -r REV PATTERN PATH search another change (scope with PATH) + notevi grep PATTERN [PATH] search the working copy + notevi grep -r REV PATTERN PATH search another change (scope with PATH) take a note while reading (ids count up from 1 per session): - vr note -f FILE:10-42 TEXT... about a line range - vr note -t struct -f FILE:10-42 TEXT... about a struct/enum/const/fn/... - vr note -r REV -f FILE TEXT... about code in another change - vr note -reply 12 TEXT... reply to log-wide note #12 + notevi note -f FILE:10-42 TEXT... about a line range + notevi note -t struct -f FILE:10-42 TEXT... about a struct/enum/const/fn/... + notevi note -r REV -f FILE TEXT... about code in another change + notevi note -reply 12 TEXT... reply to log-wide note #12 -vr query -op note shows both the session-local id and the log-wide id used by --reply and vrsite /note/N permalinks. +notevi query -op note shows both the session-local id and the log-wide id used +by -reply and by the /note/N permalinks in `notevi web`. REV is any jj revset; default @ (the working copy). -The repo-local revset alias vr_log names the log change. vr creates it lazily -as an anonymous child of root(); it never appears in the project working copy. -Every new entry records the full target change id and exact target commit id. -An existing repo-root vr-log.jsonl is read and imported on the first append; -vr leaves that legacy working-tree file for you to remove after migration. +The repo-local revset alias notevi_log names the log change. notevi creates it +lazily as an anonymous child of root(); it never appears in the project working +copy. Every new entry records the full target change id and exact target commit +id. A sidecar left by the older vr tool is renamed in place the first time +notevi writes to it, and an existing repo-root vr-log.jsonl is read and imported +on the first append; notevi leaves that legacy working-tree file for you to +remove after migration. The sidecar has no bookmark and is deliberately local-only: a fresh clone will not contain it, so include the jj repository in backups when notes matter. @@ -1,3 +1,23 @@ -module 0x4200.cafe/vr +module 0x4200.cafe/notevi -go 1.26 +go 1.26.5 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/tree-sitter/go-tree-sitter v0.25.0 + github.com/tree-sitter/tree-sitter-bash v0.25.1 + github.com/tree-sitter/tree-sitter-c v0.24.2 + github.com/tree-sitter/tree-sitter-cpp v0.23.4 + github.com/tree-sitter/tree-sitter-go v0.25.0 + github.com/tree-sitter/tree-sitter-javascript v0.25.0 + github.com/tree-sitter/tree-sitter-json v0.24.8 + github.com/tree-sitter/tree-sitter-python v0.25.0 + github.com/tree-sitter/tree-sitter-rust v0.24.2 + github.com/tree-sitter/tree-sitter-typescript v0.23.2 + github.com/yuin/goldmark v1.8.5 +) + +require ( + github.com/mattn/go-pointer v0.0.1 // indirect + github.com/tree-sitter-grammars/tree-sitter-zig v1.1.2 // indirect +) @@ -1,9 +1,14 @@ -// vr — file reader / note taker for coding agents, logging every call. +// notevi — read code, take notes, read the trace back; every call is logged. // -// Each operation appends one JSON line to a private jj sidecar recording who read what: +// Named for the bem-te-vi, the kiskadee that announces itself all day and keeps +// a bright crown patch hidden under plain feathers: the notes are there the +// whole time, tucked out of the project's way in a private jj sidecar. +// +// Each operation appends one JSON line to that sidecar recording who read what: // session id, agent, model, reasoning effort, working dir, full jj change and // commit ids, file and line range. Reads and greps are relative to a jj // revision (default @, the working copy) — the tool assumes a jj repo. +// "notevi web" serves the same log as a browsable site. // // Agent identity comes from the caller's environment (Claude Code exports // CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when @@ -29,7 +34,8 @@ import ( "strings" "time" - "0x4200.cafe/vr/sidecar" + "0x4200.cafe/notevi/sidecar" + "0x4200.cafe/notevi/web" ) type Entry struct { @@ -76,6 +82,11 @@ func main() { fmt.Print(queryDoc) return } + // web serves the log rather than appending to it, and brings its own flags. + if os.Args[1] == "web" { + web.Main(os.Args[2:]) + return + } detect() switch os.Args[1] { case "read": @@ -86,36 +97,43 @@ func main() { cmdNote(os.Args[2:]) case "query": cmdQuery(os.Args[2:]) + case "migrate": + cmdMigrate(os.Args[2:]) default: usage() } } func usage() { - fmt.Fprint(os.Stderr, `vr — file reader / note taker for coding agents; logs every call. + fmt.Fprint(os.Stderr, `notevi — read code and take notes; logs every call, hides them in a sidecar. usage: - vr read [-r REV] FILE[:START[-END]] + notevi read [-r REV] FILE[:START[-END]] print a file line-numbered, whole or a line range, at a jj revision - vr grep [-r REV] PATTERN [PATH] + notevi grep [-r REV] PATTERN [PATH] search the working copy (rg) or another revision (jj file list/show); scope non-@ greps with PATH, they read every file through jj - vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... + notevi note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... record a note about a file range or a kind of thing (struct, enum, const, ...), optionally replying to a log-wide note number; ids count up from 1 per session - vr query [-session S] [-op OP] [-file SUBSTR] [-n N] + notevi query [-session S] [-op OP] [-file SUBSTR] [-n N] print logged entries, oldest first + notevi web [-repo DIR] [-out DIR] ... + browse the trace: repo tree, coverage, notes; "notevi web -h" for more + notevi migrate [DIR...] + rename a pre-notevi sidecar in place; run once per old repository REV is any jj revset, default @ (working copy). The JSONL log lives in the -private jj sidecar named by the repo-local revset alias vr_log. -"vr -doc" prints the short agent-facing usage doc; "vr -doc-query" the query one. +private jj sidecar named by the repo-local revset alias notevi_log. +"notevi -doc" prints the short agent-facing usage doc; "notevi -doc-query" the +query one. `) os.Exit(2) } func fatal(format string, a ...any) { - fmt.Fprintf(os.Stderr, "vr: "+format+"\n", a...) + fmt.Fprintf(os.Stderr, "notevi: "+format+"\n", a...) os.Exit(1) } @@ -252,7 +270,7 @@ func jjOut(args ...string) string { } // --ignore-working-copy skips the snapshot: change ids are stable across -// snapshots, and this keeps `vr note`/`vr grep` working in sandboxes that +// snapshots, and this keeps `notevi note`/`notevi grep` working in sandboxes that // block .git writes (codex workspace-write). read/grep -r snapshot anyway // via their own jj file show/list calls. func revisionIDs(rev string) (change, commit string) { @@ -290,7 +308,7 @@ func cmdRead(args []string) { rev := fs.String("r", "@", "jj revision to read at") fs.Parse(args) if fs.NArg() != 1 { - fatal("usage: vr read [-r REV] FILE[:START[-END]]") + fatal("usage: notevi read [-r REV] FILE[:START[-END]]") } file, start, end := parseFileRange(fs.Arg(0)) @@ -320,7 +338,7 @@ func cmdGrep(args []string) { rev := fs.String("r", "@", "jj revision to search at") fs.Parse(args) if fs.NArg() < 1 || fs.NArg() > 2 { - fatal("usage: vr grep [-r REV] PATTERN [PATH]") + fatal("usage: notevi grep [-r REV] PATTERN [PATH]") } pattern, path := fs.Arg(0), fs.Arg(1) @@ -372,7 +390,7 @@ func cmdNote(args []string) { fs.Parse(args) text := strings.Join(fs.Args(), " ") if text == "" { - fatal("usage: vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") + fatal("usage: notevi note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") } if *reply < 0 { fatal("reply note must be positive") @@ -392,7 +410,7 @@ func cmdNote(args []string) { fmt.Printf("note %d\n", e.NoteID) } -// hasNote uses the same numbering as vrsite permalinks: note operations count +// hasNote uses the same numbering as the web permalinks: note operations count // from one over the append-only log, independent of their writers' sessions. func hasNote(entries []Entry, id int) bool { n := 0 @@ -486,6 +504,40 @@ func cmdQuery(args []string) { } } +// cmdMigrate renames pre-notevi sidecars. It takes directories rather than only +// working on the current one so that a machine full of old repositories can be +// brought over in one run. +func cmdMigrate(args []string) { + fs := flag.NewFlagSet("migrate", flag.ExitOnError) + fs.Parse(args) + dirs := fs.Args() + if len(dirs) == 0 { + dirs = []string{gDir} + } + failed := false + for _, dir := range dirs { + s, err := sidecar.Open(dir) + if err != nil { + fmt.Fprintf(os.Stderr, "notevi: %s: %v\n", dir, err) + failed = true + continue + } + migrated, err := s.Migrate() + switch { + case err != nil: + fmt.Fprintf(os.Stderr, "notevi: %s: %v\n", s.Root(), err) + failed = true + case migrated: + fmt.Printf("%s: renamed to %s\n", s.Root(), s) + default: + fmt.Printf("%s: nothing to rename\n", s.Root()) + } + } + if failed { + os.Exit(1) + } +} + func fileRange(e Entry) string { s := e.File if e.Start > 0 { diff --git a/query-doc.txt b/query-doc.txt index 8d4f58a..f3f4b97 100644 --- a/query-doc.txt +++ b/query-doc.txt @@ -1,14 +1,14 @@ -vr query — inspect this jj repo's read/note sidecar (oldest first). +notevi query — inspect this jj repo's read/note sidecar (oldest first). - vr query everything - vr query -n 20 last 20 entries - vr query -op note only notes (or: read, grep) - vr query -file accounts_db entries touching a file path substring - vr query -session fa7cb1bf one session (id substring is enough) + notevi query everything + notevi query -n 20 last 20 entries + notevi query -op note only notes (or: read, grep) + notevi query -file accounts_db entries touching a file path substring + notevi query -session fa7cb1bf one session (id substring is enough) Filters combine. The sidecar content is JSON lines and can also be read with: - jj file show -r 'exactly(vr_log, 1)' -- vr-log.jsonl + jj file show -r 'exactly(notevi_log, 1)' -- notevi-log.jsonl Its append history is: - jj evolog -r 'exactly(vr_log, 1)' --reversed -p --git + jj evolog -r 'exactly(notevi_log, 1)' --reversed -p --git Note rows show the session-local id first and the log-wide id used by -`vr note -reply N` and vrsite permalinks in brackets. +`notevi note -reply N` and by `notevi web` permalinks in brackets. diff --git a/sidecar/store.go b/sidecar/store.go index 76e626f..ce6c56f 100644 --- a/sidecar/store.go +++ b/sidecar/store.go @@ -1,4 +1,4 @@ -// Package sidecar stores vr's append-only JSONL log in a private jj change. +// Package sidecar stores notevi's append-only JSONL log in a private jj change. // // The change is an anonymous direct child of root(), so it is visible and // durable in the jj repository without becoming part of project history or the @@ -18,18 +18,34 @@ import ( ) const ( - Alias = "vr_log" - LogFile = "vr-log.jsonl" - Description = "private: vr log" + Alias = "notevi_log" + LogFile = "notevi-log.jsonl" + Description = "private: notevi log" + lockFile = "notevi.lock" ) -var ErrNoLog = errors.New("vr log has not been initialized") +// naming is the set of repo-visible names one generation of the tool gave the +// sidecar: the revset alias, the file inside the change, and the change's own +// description. +type naming struct{ alias, logFile, description string } -// Store is a repository's vr log. Open is read-only with respect to jj; the +var current = naming{Alias, LogFile, Description} + +// legacy is what the tool called itself before the notevi rebrand. Every read +// path still recognizes it so an un-migrated repository opens; Migrate renames +// it in place so nothing in the repository keeps the old name. +var legacy = naming{"vr_log", "vr-log.jsonl", "private: vr log"} + +const legacyLockFile = "vr-agent-logger.lock" + +var ErrNoLog = errors.New("notevi log has not been initialized") + +// Store is a repository's notevi log. Open is read-only with respect to jj; the // sidecar change and its config are created lazily by Append. type Store struct { - root string - lockPath string + root string + configDir string + lockPath string } // Open finds the jj workspace containing dir. @@ -43,32 +59,40 @@ func Open(dir string) (*Store, error) { if err != nil { return nil, err } + configDir := filepath.Dir(strings.TrimSpace(configPath)) return &Store{ - root: root, - lockPath: filepath.Join(filepath.Dir(strings.TrimSpace(configPath)), "vr-agent-logger.lock"), + root: root, + configDir: configDir, + lockPath: filepath.Join(configDir, lockFile), }, nil } +// Descriptions lists every change subject that has ever named a notevi sidecar, +// current first. Callers that discover sidecars by description need the older +// ones too, so an un-migrated repository is still recognized as having a log. +func Descriptions() []string { return []string{current.description, legacy.description} } + func (s *Store) Root() string { return s.root } func (s *Store) String() string { return fmt.Sprintf("jj sidecar %s:%s", Alias, LogFile) } -// Read returns the current log. Before the first sidecar write it falls back -// to a repo-root legacy log, which makes migration transparent. +// Read returns the current log. It reads a sidecar that still carries the +// pre-rebrand names without migrating it, and before the first sidecar write it +// falls back to a repo-root legacy log, which makes migration transparent. func (s *Store) Read() ([]byte, error) { var data []byte err := s.withLock(syscall.LOCK_SH, func() error { - rev, found, _, err := s.resolveExisting() + rev, n, found, _, err := s.locate() if err != nil { return err } if found { - data, err = s.readRevision(rev) + data, err = s.readRevision(rev, n.logFile) return err } - data, err = os.ReadFile(filepath.Join(s.root, LogFile)) + data, err = os.ReadFile(filepath.Join(s.root, legacy.logFile)) if errors.Is(err, os.ErrNotExist) { return ErrNoLog } @@ -77,12 +101,12 @@ func (s *Store) Read() ([]byte, error) { return data, err } -// Generation is a cheap identity for the current contents, used by vrsite to -// notice appends. A sidecar rewrite gets a new commit id. +// Generation is a cheap identity for the current contents, used by the web app +// to notice appends. A sidecar rewrite gets a new commit id. func (s *Store) Generation() (string, error) { var generation string err := s.withLock(syscall.LOCK_SH, func() error { - rev, found, _, err := s.resolveExisting() + rev, _, found, _, err := s.locate() if err != nil { return err } @@ -90,7 +114,7 @@ func (s *Store) Generation() (string, error) { generation, err = s.logValue(rev, "commit_id") return err } - info, err := os.Stat(filepath.Join(s.root, LogFile)) + info, err := os.Stat(filepath.Join(s.root, legacy.logFile)) if errors.Is(err, os.ErrNotExist) { generation = "empty" return nil @@ -104,7 +128,7 @@ func (s *Store) Generation() (string, error) { return generation, err } -// Append serializes sidecar rewrites across vr processes. makePayload runs +// Append serializes sidecar rewrites across notevi processes. makePayload runs // while holding that lock and receives the complete current log, allowing note // numbering and reply validation to be atomic with the append. func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { @@ -113,17 +137,17 @@ func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { if err != nil { return err } - sidecarData, err := s.readRevision(rev) + sidecarData, err := s.readRevision(rev, LogFile) if err != nil { return err } current := sidecarData if len(current) == 0 { - legacy, legacyErr := os.ReadFile(filepath.Join(s.root, LogFile)) - if legacyErr == nil { - current = legacy - } else if !errors.Is(legacyErr, os.ErrNotExist) { - return legacyErr + old, oldErr := os.ReadFile(filepath.Join(s.root, legacy.logFile)) + if oldErr == nil { + current = old + } else if !errors.Is(oldErr, os.ErrNotExist) { + return oldErr } } payload, err := makePayload(current) @@ -131,7 +155,7 @@ func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { return err } if len(payload) == 0 { - return errors.New("refusing to append an empty vr log payload") + return errors.New("refusing to append an empty notevi log payload") } var appendData []byte @@ -145,10 +169,97 @@ func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { if appendData[len(appendData)-1] != '\n' { appendData = append(appendData, '\n') } - return s.runAppend(appendData) + return s.runAppend(rev, appendData) }) } +// Migrate renames a pre-rebrand sidecar in place: the file inside the change, +// the change description, the repo-local revset alias, and the alias mentioned +// in git.private-commits. It reports whether anything was renamed and is a +// no-op on a repository that is already current or has no sidecar at all. +func (s *Store) Migrate() (migrated bool, err error) { + err = s.withLock(syscall.LOCK_EX, func() error { + rev, n, found, configured, err := s.locate() + if err != nil { + return err + } + if !found || n != legacy { + // Still adopt an unconfigured current sidecar, so that a repo whose + // jj config was lost (moving a workspace orphans it) is repaired. + if found && !configured { + return s.configure(rev) + } + return nil + } + if err := s.rename(rev); err != nil { + return err + } + migrated = true + return nil + }) + return migrated, err +} + +// rename performs the legacy -> current renaming for an already-located sidecar. +// The move is two runs, not one `mv`: see ensureLogFile. +func (s *Store) rename(rev string) error { + listed, err := s.jj("file", "list", "-r", rev, "--", legacy.logFile) + if err != nil { + return err + } + if strings.TrimSpace(listed) != "" { + if err := s.ensureLogFile(rev); err != nil { + return err + } + if _, err := s.jjRun(rev, "cat "+legacy.logFile+" >> "+LogFile+" && rm "+legacy.logFile); err != nil { + return err + } + } + if _, err := s.jj("describe", "-r", rev, "-m", Description); err != nil { + return err + } + if err := s.configure(rev); err != nil { + return err + } + if _, err := s.jj("config", "get", "revset-aliases."+legacy.alias); err == nil { + if _, err := s.jj("config", "unset", "--repo", "revset-aliases."+legacy.alias); err != nil { + return err + } + } + os.Remove(filepath.Join(s.configDir, legacyLockFile)) //nolint:errcheck + return nil +} + +// configure points the repo-local alias at rev and refreshes the push guard. +func (s *Store) configure(rev string) error { + if _, err := s.jj("config", "set", "--repo", "revset-aliases."+Alias, strconv.Quote(rev)); err != nil { + return err + } + return s.guard() +} + +// guard keeps git.private-commits naming the current alias, rewriting a legacy +// mention rather than leaving both names in the expression. +func (s *Store) guard() error { + private, err := s.jj("config", "get", "git.private-commits") + if err != nil { + return err + } + expr := strings.TrimSpace(private) + next := expr + if containsRevsetName(next, legacy.alias) { + next = replaceRevsetName(next, legacy.alias, Alias) + } + if !containsRevsetName(next, Alias) { + next = fmt.Sprintf("(%s) | %s", next, Alias) + } + if next == expr { + return nil + } + _, err = s.jj("config", "set", "--repo", "git.private-commits", strconv.Quote(next)) + return err +} + func (s *Store) withLock(kind int, fn func() error) error { if err := os.MkdirAll(filepath.Dir(s.lockPath), 0o755); err != nil { return err @@ -166,57 +277,64 @@ func (s *Store) withLock(kind int, fn func() error) error { } func (s *Store) ensure() (string, error) { - rev, found, configured, err := s.resolveExisting() + rev, n, found, configured, err := s.locate() if err != nil { return "", err } + if found && n == legacy { + // The first append to a pre-rebrand repository renames it, so no + // repository keeps writing under the old name once notevi touches it. + return rev, s.rename(rev) + } if !found { if _, err := s.jj("new", "--no-edit", "root()", "-m", Description); err != nil { return "", err } - ids, err := s.descriptionMatches() + ids, err := s.descriptionMatches(Description) if err != nil { return "", err } if len(ids) != 1 { return "", fmt.Errorf("created %q sidecar but found %d matching root children", Description, len(ids)) } - rev = ids[0] + rev, configured = ids[0], false } if !configured { - if _, err := s.jj("config", "set", "--repo", "revset-aliases."+Alias, strconv.Quote(rev)); err != nil { - return "", err - } + return rev, s.configure(rev) } - private, err := s.jj("config", "get", "git.private-commits") - if err != nil { - return "", err + return rev, s.guard() +} + +// locate finds this repository's sidecar under the current names, falling back +// to the pre-rebrand ones, and reports which generation named it. +func (s *Store) locate() (rev string, n naming, found, configured bool, err error) { + rev, found, configured, err = s.resolveExisting(current) + if err != nil || found { + return rev, current, found, configured, err } - if !containsRevsetName(private, Alias) { - expr := fmt.Sprintf("(%s) | %s", strings.TrimSpace(private), Alias) - if _, err := s.jj("config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil { - return "", err - } + rev, found, configured, err = s.resolveExisting(legacy) + if !found { + return rev, current, false, false, err } - return rev, nil + return rev, legacy, found, configured, err } -func (s *Store) resolveExisting() (rev string, found, configured bool, err error) { - configuredValue, configErr := s.jj("config", "get", "revset-aliases."+Alias) +func (s *Store) resolveExisting(n naming) (rev string, found, configured bool, err error) { + configuredValue, configErr := s.jj("config", "get", "revset-aliases."+n.alias) if configErr == nil { - rev, err = s.logValue("exactly("+Alias+", 1)", "change_id") + rev, err = s.logValue("exactly("+n.alias+", 1)", "change_id") if err != nil { - return "", false, true, fmt.Errorf("repo-local revset alias %s is invalid: %w", Alias, err) + return "", false, true, fmt.Errorf("repo-local revset alias %s is invalid: %w", n.alias, err) } - valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", Alias, strconv.Quote(Description)) + valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", n.alias, strconv.Quote(n.description)) if _, err := s.logValue(valid, "change_id"); err != nil { - return "", false, true, fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w", - Alias, strings.TrimSpace(configuredValue), err) + return "", false, true, fmt.Errorf("repo-local revset alias %s=%q does not name notevi's sidecar: %w", + n.alias, strings.TrimSpace(configuredValue), err) } return rev, true, true, nil } - ids, err := s.descriptionMatches() + ids, err := s.descriptionMatches(n.description) if err != nil { return "", false, false, err } @@ -226,12 +344,12 @@ func (s *Store) resolveExisting() (rev string, found, configured bool, err error case 1: return ids[0], true, false, nil default: - return "", false, false, fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), Description) + return "", false, false, fmt.Errorf("found %d root children described %q; cannot choose a notevi sidecar", len(ids), n.description) } } -func (s *Store) descriptionMatches() ([]string, error) { - revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(Description)) +func (s *Store) descriptionMatches(description string) ([]string, error) { + revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(description)) out, err := s.jj("log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") if err != nil { return nil, err @@ -255,20 +373,44 @@ func (s *Store) logValue(revset, template string) (string, error) { return value, nil } -func (s *Store) readRevision(rev string) ([]byte, error) { - listed, err := s.jj("file", "list", "-r", rev, "--", LogFile) +func (s *Store) readRevision(rev, logFile string) ([]byte, error) { + listed, err := s.jj("file", "list", "-r", rev, "--", logFile) if err != nil { return nil, err } if strings.TrimSpace(listed) == "" { return nil, nil } - out, err := s.jj("file", "show", "-r", rev, "--", LogFile) + out, err := s.jj("file", "show", "-r", rev, "--", logFile) return []byte(out), err } -func (s *Store) runAppend(data []byte) error { - f, err := os.CreateTemp(filepath.Dir(s.lockPath), "vr-log-append-*") +// ensureLogFile creates the log file, empty, if the sidecar does not have it. +// +// This is load-bearing, not tidiness. jj only auto-tracks a *new* file in the +// run working copy while it is under snapshot.max-new-file-size, and the run +// working copy does not take that limit from the command line, so writing a +// whole log under a name the change has never held is silently dropped — the +// commit is rewritten with the file missing and jj reports success. Writing the +// file empty first is always under the limit; from then on every byte is a +// modification of a tracked file, which snapshots at any size. +func (s *Store) ensureLogFile(rev string) error { + listed, err := s.jj("file", "list", "-r", rev, "--", LogFile) + if err != nil { + return err + } + if strings.TrimSpace(listed) != "" { + return nil + } + _, err = s.jjRun(rev, ": > "+LogFile) + return err +} + +func (s *Store) runAppend(rev string, data []byte) error { + if err := s.ensureLogFile(rev); err != nil { + return err + } + f, err := os.CreateTemp(s.configDir, "notevi-log-append-*") if err != nil { return err } @@ -281,13 +423,20 @@ func (s *Store) runAppend(data []byte) error { if err := f.Close(); err != nil { return err } - _, err = s.jj("--config", "snapshot.auto-track=\"all()\"", - "--config", "snapshot.max-new-file-size=\"1GiB\"", - "run", "--clean", "--root", "-r", "exactly("+Alias+", 1)", "--", - "sh", "-c", "cat \"$1\" >> "+LogFile, "sh", tmp) + _, err = s.jjRun(rev, "cat \"$1\" >> "+LogFile, tmp) return err } +// jjRun rewrites the sidecar change by running script against a clean checkout +// of it. rev is a change id or alias; the working copy is never involved. +func (s *Store) jjRun(rev, script string, scriptArgs ...string) (string, error) { + args := []string{"--config", "snapshot.auto-track=\"all()\"", + "--config", "snapshot.max-new-file-size=\"1GiB\"", + "run", "--clean", "--root", "-r", "exactly((" + rev + "), 1)", "--", + "sh", "-c", script, "sh"} + return s.jj(append(args, scriptArgs...)...) +} + func (s *Store) jj(args ...string) (string, error) { return jjOutput(s.root, args...) } @@ -324,6 +473,26 @@ func containsRevsetName(expr, name string) bool { return false } +// replaceRevsetName swaps whole revset names, so that rewriting an expression +// mentioning vr_log cannot corrupt an unrelated name that merely contains it. +func replaceRevsetName(expr, name, replacement string) string { + var b strings.Builder + for i := 0; i < len(expr); { + if expr[i:min(i+len(name), len(expr))] == name { + before := i == 0 || !isNameByte(expr[i-1]) + after := i+len(name) == len(expr) || !isNameByte(expr[i+len(name)]) + if before && after { + b.WriteString(replacement) + i += len(name) + continue + } + } + b.WriteByte(expr[i]) + i++ + } + return b.String() +} + func isNameByte(b byte) bool { return b == '_' || b == '-' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' } diff --git a/sidecar/store_test.go b/sidecar/store_test.go index 85f50e1..8ce3192 100644 --- a/sidecar/store_test.go +++ b/sidecar/store_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -72,19 +73,19 @@ func TestAppendCreatesPrivateUnrelatedSidecar(t *testing.T) { func TestLegacyLogIsReadThenImportedOnFirstAppend(t *testing.T) { repo := testRepo(t) - legacy := []byte("legacy\n") - if err := os.WriteFile(filepath.Join(repo, LogFile), legacy, 0o644); err != nil { + data := []byte("legacy\n") + if err := os.WriteFile(filepath.Join(repo, legacy.logFile), data, 0o644); err != nil { t.Fatal(err) } store, err := Open(repo) if err != nil { t.Fatal(err) } - if got, err := store.Read(); err != nil || string(got) != string(legacy) { + if got, err := store.Read(); err != nil || string(got) != string(data) { t.Fatalf("pre-migration read = %q, %v", got, err) } if err := store.Append(func(current []byte) ([]byte, error) { - if string(current) != string(legacy) { + if string(current) != string(data) { t.Fatalf("migration callback saw %q", current) } return []byte("sidecar\n"), nil @@ -96,6 +97,131 @@ func TestLegacyLogIsReadThenImportedOnFirstAppend(t *testing.T) { } } +// A repository written by the pre-rebrand tool must keep working, and must stop +// carrying the old name once notevi has touched it. +func TestPreRebrandSidecarIsRenamedInPlace(t *testing.T) { + repo := testRepo(t) + rev := writeLegacySidecar(t, repo, "old\n") + + store, err := Open(repo) + if err != nil { + t.Fatal(err) + } + if got, err := store.Read(); err != nil || string(got) != "old\n" { + t.Fatalf("read of an un-migrated sidecar = %q, %v", got, err) + } + + migrated, err := store.Migrate() + if err != nil { + t.Fatal(err) + } + if !migrated { + t.Fatal("Migrate reported nothing to do for a legacy sidecar") + } + + if got := strings.TrimSpace(testJJ(t, repo, "config", "get", "revset-aliases."+Alias)); got != rev { + t.Fatalf("alias %s = %q, want the original change %q", Alias, got, rev) + } + if out, err := exec.Command("jj", "--ignore-working-copy", "-R", repo, + "config", "get", "revset-aliases."+legacy.alias).CombinedOutput(); err == nil { + t.Fatalf("legacy alias %s survived: %s", legacy.alias, out) + } + private := testJJ(t, repo, "config", "get", "git.private-commits") + if !containsRevsetName(private, Alias) || containsRevsetName(private, legacy.alias) { + t.Fatalf("private commits = %q, want %s and not %s", private, Alias, legacy.alias) + } + files := testJJ(t, repo, "file", "list", "-r", "exactly("+Alias+", 1)") + if !strings.Contains(files, LogFile) || strings.Contains(files, legacy.logFile) { + t.Fatalf("sidecar files = %q, want only %s", files, LogFile) + } + subject := strings.TrimSpace(testJJ(t, repo, "log", "-G", + "-r", "exactly("+Alias+", 1)", "-T", "description")) + if subject != Description { + t.Fatalf("sidecar description = %q, want %q", subject, Description) + } + + // The trace survived the rename and the store keeps appending to it. + if err := store.Append(func(current []byte) ([]byte, error) { + if string(current) != "old\n" { + t.Fatalf("append after migration saw %q", current) + } + return []byte("new\n"), nil + }); err != nil { + t.Fatal(err) + } + if got, err := store.Read(); err != nil || string(got) != "old\nnew\n" { + t.Fatalf("migrated log = %q, %v", got, err) + } + if again, err := store.Migrate(); err != nil || again { + t.Fatalf("second Migrate = %v, %v; want no-op", again, err) + } +} + +// A real trace is hundreds of KB. jj silently declines to auto-track a *new* +// file in the run working copy past a size limit it does not take from the +// command line, so a rename that writes the whole log under a name the change +// has never held loses it — with jj reporting success. Small fixtures do not +// catch that; this one does. +func TestLargeLogSurvivesRenameAndAppend(t *testing.T) { + repo := testRepo(t) + var seed strings.Builder + for i := range 5000 { + fmt.Fprintf(&seed, `{"time":"2026-07-26T10:00:00Z","op":"read","file":"src/%d.rs","start":1,"end":400}`+"\n", i) + } + if seed.Len() < 256<<10 { + t.Fatalf("fixture is only %d bytes; too small to exercise the limit", seed.Len()) + } + writeLegacySidecar(t, repo, seed.String()) + + store, err := Open(repo) + if err != nil { + t.Fatal(err) + } + if _, err := store.Migrate(); err != nil { + t.Fatal(err) + } + got, err := store.Read() + if err != nil { + t.Fatal(err) + } + if string(got) != seed.String() { + t.Fatalf("renamed log is %d bytes, want %d", len(got), seed.Len()) + } + if err := store.Append(func([]byte) ([]byte, error) { return []byte(`{"op":"note"}`), nil }); err != nil { + t.Fatal(err) + } + if got, err = store.Read(); err != nil || len(got) <= seed.Len() { + t.Fatalf("append after a large rename left %d bytes, %v", len(got), err) + } +} + +// writeLegacySidecar builds the sidecar exactly as the pre-rebrand tool did and +// returns its change id. +func writeLegacySidecar(t *testing.T, repo, content string) string { + t.Helper() + testJJ(t, repo, "new", "--no-edit", "root()", "-m", legacy.description) + rev := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-T", "change_id", + "-r", fmt.Sprintf("root()+ & subject(exact:%q)", legacy.description))) + tmp := filepath.Join(t.TempDir(), "seed") + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + // Touch first, then fill: the same two steps the store uses, so a large + // fixture really lands in the sidecar instead of quietly seeding nothing. + testJJ(t, repo, "--config", `snapshot.auto-track="all()"`, + "run", "--clean", "--root", "-r", "exactly("+rev+", 1)", "--", + "sh", "-c", ": > "+legacy.logFile) + testJJ(t, repo, "--config", `snapshot.auto-track="all()"`, + "run", "--clean", "--root", "-r", "exactly("+rev+", 1)", "--", + "sh", "-c", "cat \"$1\" >> "+legacy.logFile, "sh", tmp) + if got := testJJ(t, repo, "file", "show", "-r", "exactly("+rev+", 1)", "--", legacy.logFile); got != content { + t.Fatalf("legacy fixture is %d bytes in the sidecar, want %d", len(got), len(content)) + } + testJJ(t, repo, "config", "set", "--repo", "revset-aliases."+legacy.alias, strconv.Quote(rev)) + testJJ(t, repo, "config", "set", "--repo", "git.private-commits", strconv.Quote("(none()) | "+legacy.alias)) + return rev +} + func TestConcurrentAppendsAreSerialized(t *testing.T) { repo := testRepo(t) store, err := Open(repo) diff --git a/vrsite/go.mod b/vrsite/go.mod deleted file mode 100644 index 08e34bc..0000000 --- a/vrsite/go.mod +++ /dev/null @@ -1,26 +0,0 @@ -module 0x4200.cafe/vrsite - -go 1.26.5 - -require ( - 0x4200.cafe/vr v0.0.0 - github.com/BurntSushi/toml v1.6.0 - github.com/tree-sitter/go-tree-sitter v0.25.0 - github.com/tree-sitter/tree-sitter-bash v0.25.1 - github.com/tree-sitter/tree-sitter-c v0.24.2 - github.com/tree-sitter/tree-sitter-cpp v0.23.4 - github.com/tree-sitter/tree-sitter-go v0.25.0 - github.com/tree-sitter/tree-sitter-javascript v0.25.0 - github.com/tree-sitter/tree-sitter-json v0.24.8 - github.com/tree-sitter/tree-sitter-python v0.25.0 - github.com/tree-sitter/tree-sitter-rust v0.24.2 - github.com/tree-sitter/tree-sitter-typescript v0.23.2 - github.com/yuin/goldmark v1.8.5 -) - -require ( - github.com/mattn/go-pointer v0.0.1 // indirect - github.com/tree-sitter-grammars/tree-sitter-zig v1.1.2 // indirect -) - -replace 0x4200.cafe/vr => .. diff --git a/vrsite/app_test.go b/web/app_test.go index 876e253..8416099 100644 --- a/vrsite/app_test.go +++ b/web/app_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "crypto/sha256" @@ -14,7 +14,7 @@ import ( "testing" "time" - "0x4200.cafe/vr/sidecar" + "0x4200.cafe/notevi/sidecar" ) func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" } @@ -403,8 +403,8 @@ func TestServerPages(t *testing.T) { {"/style.css", `data-files="hidden"`}, {"/themes.css", `:root[data-theme="zed/one-light"]`}, {"/themes.js", "VRSITE_THEMES"}, - {"/app.js", "vrsite-mode"}, - {"/app.js", "vrsite-files-hidden"}, + {"/app.js", "notevi-mode"}, + {"/app.js", "notevi-files-hidden"}, {"/htmx.min.js", "htmx"}, } { w := get(t, h, tc.path, false) @@ -896,7 +896,7 @@ func TestServerPicksUpLogAppendsFromOtherProcesses(t *testing.T) { if err != nil { t.Fatal(err) } - line := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s9","agent":"vr","change":"` + + line := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s9","agent":"notevi","change":"` + change + `","file":"hello.txt","note_id":1,"text":"from another process"}` if _, err := f.WriteString(line + "\n"); err != nil { t.Fatal(err) @@ -907,6 +907,6 @@ func TestServerPicksUpLogAppendsFromOtherProcesses(t *testing.T) { os.Chtimes(logPath, future, future) if !strings.Contains(get(t, h, "/notes", false).Body.String(), "from another process") { - t.Error("an append by vr was not picked up") + t.Error("an append by notevi was not picked up") } } diff --git a/vrsite/assets/app.js b/web/assets/app.js index 854959a..5dd6ca0 100644 --- a/vrsite/assets/app.js +++ b/web/assets/app.js @@ -1,4 +1,4 @@ -// app.js — the client half of vrsite. Everything here works in the static +// app.js — the client half of notevi web. Everything here works in the static // export too; htmx only takes over the parts that need a server (filters, // comments, live activity, jj queries). "use strict"; @@ -11,7 +11,7 @@ const live = root.dataset.live === "1"; // tree markers), so the whole UI switches between them instead of layering. function setMode(m) { root.dataset.mode = m; - localStorage.setItem("vrsite-mode", m); + localStorage.setItem("notevi-mode", m); for (const b of document.querySelectorAll(".mode")) b.setAttribute("aria-pressed", String(b.dataset.mode === m)); } @@ -71,7 +71,7 @@ if (pick) { {id: "dark", name: pick.dataset.dark, src: "built with · dark"}, {id: "light", name: pick.dataset.light, src: "built with · light"}, ]; - let cur = localStorage.getItem("vrsite-theme") || "system"; + let cur = localStorage.getItem("notevi-theme") || "system"; let active = null, filled = false, pending = null; const wear = id => { @@ -84,7 +84,7 @@ if (pick) { const b = builtin.find(t => t.id === id); if (b) return b.name; const t = (window.VRSITE_THEMES || []).find(t => t.id === id); - return t ? t.name : localStorage.getItem("vrsite-theme-name") || + return t ? t.name : localStorage.getItem("notevi-theme-name") || id.replace(/^[^/]*\//, "").replace(/[-_]/g, " "); }; btn.textContent = label(cur); @@ -200,8 +200,8 @@ if (pick) { if (menu.hidden) return; if (keep && active) { cur = active.dataset.id; - localStorage.setItem("vrsite-theme", cur); - localStorage.setItem("vrsite-theme-name", label(cur)); + localStorage.setItem("notevi-theme", cur); + localStorage.setItem("notevi-theme-name", label(cur)); btn.textContent = label(cur); for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); active.classList.add("cur"); @@ -254,7 +254,7 @@ if (pick) { // ── nav tree ────────────────────────────────────────────────────────────── const nav = document.getElementById("nav"); const tree = document.getElementById("tree"); -const state = JSON.parse(localStorage.getItem("vrsite-tree") || "{}"); +const state = JSON.parse(localStorage.getItem("notevi-tree") || "{}"); // The file tree can get out of the way without losing repository context or // the navigation footer. Keep the choice across page loads in this browser. @@ -265,9 +265,9 @@ if (fileList && fileToggle) { root.dataset.files = hidden ? "hidden" : "shown"; fileToggle.setAttribute("aria-expanded", String(!hidden)); fileToggle.textContent = hidden ? "show files" : "hide files"; - localStorage.setItem("vrsite-files-hidden", hidden ? "1" : "0"); + localStorage.setItem("notevi-files-hidden", hidden ? "1" : "0"); }; - setFilesHidden(localStorage.getItem("vrsite-files-hidden") === "1"); + setFilesHidden(localStorage.getItem("notevi-files-hidden") === "1"); fileToggle.addEventListener("click", () => setFilesHidden(root.dataset.files !== "hidden")); } @@ -289,7 +289,7 @@ if (nav && tree) { tree.addEventListener("toggle", e => { if (tree.classList.contains("filtering")) return; // filter drives open state state[e.target.dataset.path] = e.target.open ? 1 : 0; - localStorage.setItem("vrsite-tree", JSON.stringify(state)); + localStorage.setItem("notevi-tree", JSON.stringify(state)); }, true); }).catch(() => { tree.innerHTML = '<p class="meta">tree needs http — use the <a href="' + diff --git a/vrsite/assets/bash.scm b/web/assets/bash.scm index 4c7926a..4c7926a 100644 --- a/vrsite/assets/bash.scm +++ b/web/assets/bash.scm diff --git a/web/assets/bemtevi.svg b/web/assets/bemtevi.svg new file mode 100644 index 0000000..e8f7367 --- /dev/null +++ b/web/assets/bemtevi.svg @@ -0,0 +1,31 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="bem-te-vi"> + <title>bem-te-vi</title> + <!-- + Placeholder mark: a stylized bem-te-vi (great kiskadee). Transparent + background, no external references, legible down to 16px. The orange sliver + at the crown is the patch the bird keeps hidden under its black cap — the + same joke the tool's name makes about its notes. + --> + <defs> + <path id="nv-body" d="M18 32C18 24 27 20 35 21 43 22 49 30 50 39L62 51 60 57 46 49C39 54 23 53 18 42Z"/> + <clipPath id="nv-bodyclip"><use href="#nv-body"/></clipPath> + <clipPath id="nv-headclip"><circle cx="21" cy="24" r="12"/></clipPath> + </defs> + + <use href="#nv-body" fill="#8A5A34"/> + <g clip-path="url(#nv-bodyclip)"> + <ellipse cx="24" cy="46" rx="21" ry="16" fill="#F5C518"/> + <ellipse cx="41" cy="39" rx="10" ry="5.5" transform="rotate(24 41 39)" fill="#7A4E2C"/> + </g> + + <circle cx="21" cy="24" r="12" fill="#F4F1E8"/> + <g clip-path="url(#nv-headclip)"> + <rect x="7" y="10" width="30" height="8" fill="#23201C"/> + <ellipse cx="21" cy="13.2" rx="5" ry="1.9" fill="#F08B1D"/> + <rect x="7" y="18" width="30" height="4" fill="#F4F1E8"/> + <rect x="7" y="22" width="30" height="6" fill="#23201C"/> + </g> + <circle cx="16" cy="24.5" r="2.4" fill="#F4F1E8"/> + <circle cx="16" cy="24.5" r="1.3" fill="#23201C"/> + <path d="M12 23.5.5 28 12 32.5Z" fill="#23201C"/> +</svg> diff --git a/vrsite/assets/c.scm b/web/assets/c.scm index 789bf93..789bf93 100644 --- a/vrsite/assets/c.scm +++ b/web/assets/c.scm diff --git a/vrsite/assets/cpp.scm b/web/assets/cpp.scm index e939a7a..e939a7a 100644 --- a/vrsite/assets/cpp.scm +++ b/web/assets/cpp.scm diff --git a/vrsite/assets/go.scm b/web/assets/go.scm index 56f676c..56f676c 100644 --- a/vrsite/assets/go.scm +++ b/web/assets/go.scm diff --git a/vrsite/assets/htmx.min.js b/web/assets/htmx.min.js index c62505f..c62505f 100644 --- a/vrsite/assets/htmx.min.js +++ b/web/assets/htmx.min.js diff --git a/vrsite/assets/javascript-params.scm b/web/assets/javascript-params.scm index db300e3..db300e3 100644 --- a/vrsite/assets/javascript-params.scm +++ b/web/assets/javascript-params.scm diff --git a/vrsite/assets/javascript.scm b/web/assets/javascript.scm index a5c8f01..a5c8f01 100644 --- a/vrsite/assets/javascript.scm +++ b/web/assets/javascript.scm diff --git a/vrsite/assets/json.scm b/web/assets/json.scm index e82e125..e82e125 100644 --- a/vrsite/assets/json.scm +++ b/web/assets/json.scm diff --git a/vrsite/assets/jsx.scm b/web/assets/jsx.scm index 708eba2..708eba2 100644 --- a/vrsite/assets/jsx.scm +++ b/web/assets/jsx.scm diff --git a/vrsite/assets/one.json b/web/assets/one.json index 69ff0de..cb4ca24 100644 --- a/vrsite/assets/one.json +++ b/web/assets/one.json @@ -1,6 +1,6 @@ { "name": "One", - "author": "Zed Industries (vendored subset for vrsite fallback)", + "author": "Zed Industries (vendored subset for notevi fallback)", "themes": [ { "name": "One Dark", diff --git a/vrsite/assets/python.scm b/web/assets/python.scm index f64e673..f64e673 100644 --- a/vrsite/assets/python.scm +++ b/web/assets/python.scm diff --git a/vrsite/assets/rust.scm b/web/assets/rust.scm index d23fa2c..d23fa2c 100644 --- a/vrsite/assets/rust.scm +++ b/web/assets/rust.scm diff --git a/vrsite/assets/style.css.tmpl b/web/assets/style.css.tmpl index 5ea2a40..d555950 100644 --- a/vrsite/assets/style.css.tmpl +++ b/web/assets/style.css.tmpl @@ -73,6 +73,8 @@ nav > header { } nav > header a { color: var(--fg); } nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* the bem-te-vi; inline so the brand keeps ellipsing its title */ +nav .brand .mark { width: 1.15em; height: 1.15em; vertical-align: -0.22em; margin-right: 0.4em; } #filetoggle { display: block; margin: 0 0 6px auto; background: none; color: var(--muted); border: 0; @@ -430,8 +432,8 @@ aside summary { cursor: pointer; color: var(--muted); } .t-tool .role { color: var(--note); } .t-result .what { color: var(--muted); } .t-result.bad .what { color: var(--note); } -.t-vr { border-left-color: var(--cov); background: color-mix(in srgb, var(--cov) 7%, transparent); } -.t-vr .role { color: var(--cov); font-weight: 600; } +.t-notevi { border-left-color: var(--cov); background: color-mix(in srgb, var(--cov) 7%, transparent); } +.t-notevi .role { color: var(--cov); font-weight: 600; } /* A note is what you came to the conversation to find, so it breaks the run of log lines: the note color's rail, a tinted band, a card instead of a line. scroll-margin clears the sticky header — the anchored one is diff --git a/vrsite/assets/typescript.scm b/web/assets/typescript.scm index 96cf9c3..96cf9c3 100644 --- a/vrsite/assets/typescript.scm +++ b/web/assets/typescript.scm diff --git a/vrsite/assets/zig.scm b/web/assets/zig.scm index a85383a..a85383a 100644 --- a/vrsite/assets/zig.scm +++ b/web/assets/zig.scm diff --git a/vrsite/bench-browser.mjs b/web/bench-browser.mjs index 97b3a86..1e373a3 100644 --- a/vrsite/bench-browser.mjs +++ b/web/bench-browser.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Browser render benchmark for vrsite pages: drives headless Chrome over CDP +// Browser render benchmark for notevi web pages: drives headless Chrome over CDP // (no npm deps; node >= 22 for native WebSocket). Serves the site dir, loads // each page N times, reports parse/paint/load timings, node count, and the // cost of jumping to the bottom of the file. @@ -41,7 +41,7 @@ await new Promise(r => server.listen(0, r)); const port = server.address().port; // ── chrome + CDP ────────────────────────────────────────────────────────── -const profile = path.join(tmpdir(), "vrsite-bench-profile"); +const profile = path.join(tmpdir(), "notevi-bench-profile"); const chrome = spawn(CHROME, [ "--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--disable-extensions", "--window-size=1600,1000", "about:blank", diff --git a/vrsite/bench_test.go b/web/bench_test.go index babf3d4..094eb47 100644 --- a/vrsite/bench_test.go +++ b/web/bench_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "bytes" diff --git a/vrsite/css.go b/web/css.go index f1df936..e2637bc 100644 --- a/vrsite/css.go +++ b/web/css.go @@ -5,7 +5,7 @@ // Everything switchable is a custom property on :root. That is what lets // themes.css hold nothing but one block per theme (writeThemesCSS) and the // picker repaint the page by setting a single attribute. -package main +package web import ( "encoding/json" @@ -152,6 +152,6 @@ func writeThemesJS(w io.Writer, themes []*Theme) { if err != nil { b = []byte("[]") } - fmt.Fprintf(w, "// %d themes, written by vrsite from this machine's zed and helix installs\n", len(cards)) + fmt.Fprintf(w, "// %d themes, written by notevi from this machine's zed and helix installs\n", len(cards)) fmt.Fprintf(w, "window.VRSITE_THEMES=%s;\n", strings.ReplaceAll(string(b), "},{", "},\n{")) } diff --git a/vrsite/highlight_test.go b/web/highlight_test.go index e6897d1..9cb8e36 100644 --- a/vrsite/highlight_test.go +++ b/web/highlight_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "strings" @@ -3,7 +3,7 @@ // These helpers never modify project history or its working copy // (--ignore-working-copy means they never even snapshot it). Sidecar mutation // is isolated in the shared sidecar package. -package main +package web import ( "context" diff --git a/vrsite/log.go b/web/log.go index a0a4f3e..b45eec5 100644 --- a/vrsite/log.go +++ b/web/log.go @@ -1,4 +1,4 @@ -package main +package web import ( "fmt" diff --git a/vrsite/markdown.go b/web/markdown.go index 31581ae..476e67c 100644 --- a/vrsite/markdown.go +++ b/web/markdown.go @@ -10,7 +10,7 @@ // - fenced code goes through the same tree-sitter highlighter as the code // view, so a snippet in a note is painted by the reader's theme exactly // like the file it was copied from. -package main +package web import ( "bytes" diff --git a/vrsite/markdown_test.go b/web/markdown_test.go index 72f2cca..2078bab 100644 --- a/vrsite/markdown_test.go +++ b/web/markdown_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "net/http/httptest" @@ -126,7 +126,7 @@ func TestMarkdownShowsAngleBracketsAsText(t *testing.T) { } } -// A note is written by whoever ran vr, so the renderer is the last thing +// A note is written by whoever ran notevi, so the renderer is the last thing // standing between the log and the page. func TestMarkdownDropsDangerousLinks(t *testing.T) { m := testMarkdown(t) diff --git a/vrsite/model.go b/web/model.go index 10f3f1d..5e90ee1 100644 --- a/vrsite/model.go +++ b/web/model.go @@ -1,9 +1,9 @@ // model.go — the log projected into a browsable model. // -// Everything the site shows is derived here from the vr log alone: agents and +// Everything the site shows is derived here from the notevi log alone: agents and // their colors, per-file coverage bitmasks, notes, and live session activity. // The private jj sidecar is the only state. -package main +package web import ( "encoding/json" @@ -437,7 +437,7 @@ func (s *Site) Facets() *Facets { return fa } -// nextNoteID mirrors vr's numbering: ids count up from 1 within a session. +// nextNoteID mirrors notevi's numbering: ids count up from 1 within a session. func nextNoteID(entries []Entry, session string) int { max := 0 for _, e := range entries { diff --git a/vrsite/page.go b/web/page.go index 1570d45..0425589 100644 --- a/vrsite/page.go +++ b/web/page.go @@ -4,7 +4,7 @@ // ".html" suffixes, no server-side features) and as the live web app // (absolute routes, htmx-driven filters, comments and jj queries). Pages carry // Live so a template can ask for the difference in one place. -package main +package web import ( "html/template" diff --git a/vrsite/permalink_test.go b/web/permalink_test.go index c3d572f..44f9142 100644 --- a/vrsite/permalink_test.go +++ b/web/permalink_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "os" diff --git a/vrsite/projects.go b/web/projects.go index 5808a92..1783a76 100644 --- a/vrsite/projects.go +++ b/web/projects.go @@ -1,11 +1,11 @@ -// projects.go — the stateless, multi-repository front door for vrsite. +// projects.go — the stateless, multi-repository front door for the notevi site. // // The hub is an index and dispatcher, not another database. It reconstructs -// its project list from .jj/.git markers and vr sidecars at startup or on an +// its project list from .jj/.git markers and notevi sidecars at startup or on an // explicit refresh. Per-project pages are still served by server, and the only // writes are the two actions named by their buttons: initialize jj metadata or -// create the normal private vr sidecar/config. -package main +// create the normal private notevi sidecar/config. +package web import ( "bytes" @@ -27,7 +27,7 @@ import ( "syscall" "time" - "0x4200.cafe/vr/sidecar" + "0x4200.cafe/notevi/sidecar" ) type projectKind string @@ -187,7 +187,7 @@ func discoverProjects(root string) ([]*Project, string, error) { for _, p := range groups { if p.Kind != projectGit { sort.Strings(p.Workspaces) - active, err := hasVRSidecar(p.Path) + active, err := hasSidecar(p.Path) if err != nil { p.Err = err.Error() } else if active { @@ -242,16 +242,23 @@ func nameProject(p *Project, root string) { } } +// sidecarChanges looks for every description a notevi sidecar has ever had, so +// that a repository the tool has not written to since the rebrand is still +// listed as having a log rather than offered as a fresh start. func sidecarChanges(repo string) ([]string, error) { - revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(sidecar.Description)) - out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") - if err != nil { - return nil, err + var ids []string + for _, description := range sidecar.Descriptions() { + revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(description)) + out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") + if err != nil { + return nil, err + } + ids = append(ids, strings.Fields(string(out))...) } - return strings.Fields(string(out)), nil + return ids, nil } -func hasVRSidecar(repo string) (bool, error) { +func hasSidecar(repo string) (bool, error) { ids, err := sidecarChanges(repo) if err != nil { return false, err @@ -262,7 +269,7 @@ func hasVRSidecar(repo string) (bool, error) { case 1: return true, nil default: - return false, fmt.Errorf("found %d private vr sidecars", len(ids)) + return false, fmt.Errorf("found %d private notevi sidecars", len(ids)) } } @@ -313,6 +320,7 @@ func (h *projectHub) handler() http.Handler { mux.HandleFunc("GET /themes.js", h.themeAsset) mux.HandleFunc("GET /app.js", h.asset) mux.HandleFunc("GET /htmx.min.js", h.asset) + mux.HandleFunc("GET /bemtevi.svg", h.asset) return mux } @@ -364,7 +372,7 @@ func (h *projectHub) startAction(w http.ResponseWriter, r *http.Request) { return } h.actionMu.Lock() - err := ensureVRSidecar(p.Path) + err := ensureSidecar(p.Path) h.actionMu.Unlock() if err != nil { homeRedirect(w, r, "", err) @@ -401,7 +409,7 @@ func (h *projectHub) initGitAction(w http.ResponseWriter, r *http.Request) { homeRedirect(w, r, "", err) return } - homeRedirect(w, r, "jj initialized; the repository is ready for a vr sidecar", nil) + homeRedirect(w, r, "jj initialized; the repository is ready for a notevi sidecar", nil) } func localPost(w http.ResponseWriter, r *http.Request) bool { @@ -487,7 +495,7 @@ func (h *projectHub) runtime(id string) (*projectRuntime, error) { return nil, err } title := p.Name - if h.title != "" && h.title != "vr projects" { + if h.title != "" && h.title != hubDefaultTitle { title += " — " + h.title } sv := newServerAt(log, p.Path, title, h.user, "/p/"+id+"/", h.theme, h.light, h.themes) @@ -503,12 +511,21 @@ func (h *projectHub) runtime(id string) (*projectRuntime, error) { return rt, nil } -func ensureVRSidecar(repo string) error { +func ensureSidecar(repo string) error { + // Rename a pre-rebrand sidecar first — before taking the lock below, which + // Migrate takes too — so everything after this only sees the current names. + store, err := sidecar.Open(repo) + if err != nil { + return err + } + if _, err := store.Migrate(); err != nil { + return err + } config, err := jjRun(repo, "config", "path", "--repo") if err != nil { return err } - lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "vr-agent-logger.lock") + lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "notevi.lock") if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { return err } @@ -532,7 +549,7 @@ func ensureVRSidecar(repo string) error { } valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", sidecar.Alias, strconv.Quote(sidecar.Description)) if _, err := jjLogValue(repo, valid, "change_id"); err != nil { - return fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w", + return fmt.Errorf("repo-local revset alias %s=%q does not name notevi's sidecar: %w", sidecar.Alias, strings.TrimSpace(string(configuredValue)), err) } id = resolved @@ -546,7 +563,7 @@ func ensureVRSidecar(repo string) error { case 1: id = ids[0] default: - return fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), sidecar.Description) + return fmt.Errorf("found %d root children described %q; cannot choose a notevi sidecar", len(ids), sidecar.Description) } } if id == "" { @@ -670,7 +687,7 @@ func (h *projectHub) asset(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Content-Type", assetType(name)) w.Header().Set("Cache-Control", "max-age=300") w.Write(b) } @@ -689,9 +706,9 @@ func serveProjects(addr string, hub *projectHub) { } } hub.mu.RUnlock() - fmt.Printf("vrsite: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n", + fmt.Printf("notevi web: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n", addr, active, ready, git, hub.scanRoot) - fmt.Println("vrsite: project discovery is in memory; refresh rescans the filesystem") + fmt.Println("notevi web: project discovery is in memory; refresh rescans the filesystem") if err := http.ListenAndServe(addr, hub.handler()); err != nil { fatal("%v", err) } diff --git a/vrsite/projects_test.go b/web/projects_test.go index 79fc6bc..9f911c3 100644 --- a/vrsite/projects_test.go +++ b/web/projects_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "encoding/json" @@ -11,7 +11,7 @@ import ( "strings" "testing" - "0x4200.cafe/vr/sidecar" + "0x4200.cafe/notevi/sidecar" ) func projectTestEnv(t *testing.T, home string) { @@ -82,7 +82,7 @@ func initGitAt(t *testing.T, dir string) { func testProjectHub(t *testing.T, home string) *projectHub { t.Helper() dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") - h, err := newProjectHub(home, "vr projects", "tester", nil, dark, light, testThemes(dark, light)) + h, err := newProjectHub(home, hubDefaultTitle, "tester", nil, dark, light, testThemes(dark, light)) if err != nil { t.Fatal(err) } @@ -214,7 +214,7 @@ func TestProjectHubStartsAnEmptyPrivateSidecar(t *testing.T) { if opened.Code != http.StatusOK || !strings.Contains(opened.Body.String(), "ready") { t.Fatalf("new empty project page = %d: %s", opened.Code, opened.Body.String()) } - active, err := hasVRSidecar(repo) + active, err := hasSidecar(repo) if err != nil || !active { t.Fatalf("sidecar = %v, %v", active, err) } @@ -249,11 +249,11 @@ func TestEnsureVRSidecarRefusesAConflictingAlias(t *testing.T) { initJJAt(t, repo) runAt(t, repo, "jj", "config", "set", "--repo", "revset-aliases."+sidecar.Alias, `"root()"`) - err := ensureVRSidecar(repo) - if err == nil || !strings.Contains(err.Error(), "does not name vr's sidecar") { + err := ensureSidecar(repo) + if err == nil || !strings.Contains(err.Error(), "does not name notevi's sidecar") { t.Fatalf("ensure with conflicting alias = %v", err) } - if active, err := hasVRSidecar(repo); err != nil || active { + if active, err := hasSidecar(repo); err != nil || active { t.Fatalf("conflicting alias created a sidecar: %v, %v", active, err) } alias := strings.TrimSpace(runAt(t, repo, "jj", "config", "get", "revset-aliases."+sidecar.Alias)) @@ -309,7 +309,7 @@ func TestProjectHubRefusesCrossOriginMutation(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("cross-origin start = %d", w.Code) } - if active, err := hasVRSidecar(repo); err != nil || active { + if active, err := hasSidecar(repo); err != nil || active { t.Fatalf("cross-origin request created sidecar: %v, %v", active, err) } } diff --git a/vrsite/render.go b/web/render.go index 91e07b9..0bfbdce 100644 --- a/vrsite/render.go +++ b/web/render.go @@ -1,6 +1,6 @@ // render.go — source to HTML: tree-sitter highlighting, coverage classes and // note anchors baked into one <ol> per chunk. -package main +package web import ( "bytes" diff --git a/vrsite/serve.go b/web/serve.go index 746abac..93b3f05 100644 --- a/vrsite/serve.go +++ b/web/serve.go @@ -1,10 +1,10 @@ -// serve.go — vrsite as a live web application. +// serve.go — notevi web as a live web application. // // The server owns nothing. Three rules keep it that way, and app_test.go holds // each of them: // // - The log sidecar is the only thing written. Notes typed in the browser are -// appended to it exactly as vr appends them; project history and the main +// appended to it exactly as notevi appends them; project history and the main // working copy are only ever read (see jj.go). // - Memory is a cache, never a source. The model, the per-change // projections, file lists and rendered pages are all derived from the log @@ -19,7 +19,7 @@ // Pages are rendered per request from the same templates the static export // uses; htmx swaps the parts that change on their own — filtered note lists, // the activity of agents currently reading, jj queries. -package main +package web import ( "bytes" @@ -350,6 +350,7 @@ func (sv *server) handler() http.Handler { mux.HandleFunc("GET /themes.js", sv.themeAsset) mux.HandleFunc("GET /app.js", sv.asset) mux.HandleFunc("GET /htmx.min.js", sv.asset) + mux.HandleFunc("GET /bemtevi.svg", sv.asset) return mux } @@ -744,21 +745,29 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Content-Type", assetType(name)) w.Header().Set("Cache-Control", "max-age=300") w.Write(b) } +// assetType covers what embedded assets/ actually holds: scripts and the mark. +func assetType(name string) string { + if strings.HasSuffix(name, ".svg") { + return "image/svg+xml" + } + return "text/javascript; charset=utf-8" +} + func serve(addr string, log logStore, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { sv := newServer(log, repo, title, user, theme, light, themes) sv.tx = newTranscripts(roots) site, _, def := sv.snap() - fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n", + fmt.Printf("notevi web: http://%s — %d notes, %d agents, repo %s at %s\n", addr, len(site.Notes), len(site.Agents), repo, short(def)) - fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", + fmt.Printf("notevi web: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix")) - fmt.Printf("vrsite: comments are appended to %s; the project working copy is never written to\n", log) - fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) + fmt.Printf("notevi web: comments are appended to %s; the project working copy is never written to\n", log) + fmt.Printf("notevi web: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) if err := http.ListenAndServe(addr, sv.handler()); err != nil { fatal("%v", err) } diff --git a/vrsite/static.go b/web/static.go index 0a8eec0..3f3e766 100644 --- a/vrsite/static.go +++ b/web/static.go @@ -3,7 +3,7 @@ // Everything that needs a server (comments, live activity, jj queries) is // omitted; the trace/notes toggle, the file finder and the note filters all // keep working, since they run in the browser. -package main +package web import ( "bytes" @@ -62,7 +62,7 @@ func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, th site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), change) commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id"))) - tmp, err := os.MkdirTemp("", "vrsite-") + tmp, err := os.MkdirTemp("", "notevi-") if err != nil { fatal("%v", err) } @@ -88,7 +88,7 @@ func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, th stubs++ } } - fmt.Printf("vrsite: %d files (%d traced, %d not rendered), %d notes, %d agents in %s -> %s\n", + fmt.Printf("notevi web: %d files (%d traced, %d not rendered), %d notes, %d agents in %s -> %s\n", len(site.All), len(site.Order), stubs, len(site.Notes), len(site.Agents), time.Since(start).Round(time.Millisecond), out) } @@ -137,7 +137,7 @@ func writeSite(site *Site, out, srcDir string, themes *themeSet) { fatal("css: %v", err) } cf.Close() - for _, name := range []string{"app.js"} { + for _, name := range []string{"app.js", "bemtevi.svg"} { b, err := tfs.ReadFile("assets/" + name) if err != nil { fatal("%v", err) diff --git a/vrsite/templates/frag-activity.tmpl b/web/templates/frag-activity.tmpl index 071c556..071c556 100644 --- a/vrsite/templates/frag-activity.tmpl +++ b/web/templates/frag-activity.tmpl diff --git a/vrsite/templates/frag-change.tmpl b/web/templates/frag-change.tmpl index b33d746..b33d746 100644 --- a/vrsite/templates/frag-change.tmpl +++ b/web/templates/frag-change.tmpl diff --git a/vrsite/templates/frag-jj.tmpl b/web/templates/frag-jj.tmpl index 61040dc..61040dc 100644 --- a/vrsite/templates/frag-jj.tmpl +++ b/web/templates/frag-jj.tmpl diff --git a/vrsite/templates/frag-note.tmpl b/web/templates/frag-note.tmpl index 3b04e11..3b04e11 100644 --- a/vrsite/templates/frag-note.tmpl +++ b/web/templates/frag-note.tmpl diff --git a/vrsite/templates/layout.tmpl b/web/templates/layout.tmpl index a11ef16..969cae6 100644 --- a/vrsite/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -4,20 +4,21 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}}</title> +<link rel="icon" type="image/svg+xml" href="{{.Href "asset" "bemtevi.svg"}}"> <link rel="stylesheet" href="{{.Href "asset" "style.css"}}"> <script> // the theme is worn before the first paint. A theme that is not one of the // two the site was built with lives in themes.css, so pull that in too — // from here, after style.css, so its :root block is the one that wins. -{const d = document.documentElement, t = localStorage.getItem("vrsite-theme"); +{const d = document.documentElement, t = localStorage.getItem("notevi-theme"); if (t && t !== "system") d.dataset.theme = t; if (t && !/^(system|dark|light)$/.test(t)) { const l = document.createElement("link"); l.rel = "stylesheet"; l.id = "themescss"; l.href = "{{.Href "asset" "themes.css"}}"; document.head.append(l); } - d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace"; - d.dataset.files = localStorage.getItem("vrsite-files-hidden") === "1" ? "hidden" : "shown";} + d.dataset.mode = localStorage.getItem("notevi-mode") || "trace"; + d.dataset.files = localStorage.getItem("notevi-files-hidden") === "1" ? "hidden" : "shown";} </script> {{if .Live}}<script src="{{.Href "asset" "htmx.min.js"}}"></script>{{end}} <script src="{{.Href "asset" "app.js"}}" defer></script> @@ -26,7 +27,7 @@ <nav aria-label="repository files" id="nav" data-root="{{.Root}}" data-current="{{.Current}}" data-tree="{{.Href "tree"}}"> <header> - <a class="brand" href="{{.Href ""}}">{{.Site.Title}}</a> + <a class="brand" href="{{.Href ""}}"><img class="mark" src="{{.Href "asset" "bemtevi.svg"}}" alt="" width="20" height="20">{{.Site.Title}}</a> <div class="modes" role="group" aria-label="focus"> <button type="button" class="mode" data-mode="trace" title="reading coverage (t)">trace</button> <button type="button" class="mode" data-mode="notes" title="notes and comments (t)">notes</button> diff --git a/vrsite/templates/page-change.tmpl b/web/templates/page-change.tmpl index 8641da7..8641da7 100644 --- a/vrsite/templates/page-change.tmpl +++ b/web/templates/page-change.tmpl diff --git a/vrsite/templates/page-file.tmpl b/web/templates/page-file.tmpl index 39a8c44..39a8c44 100644 --- a/vrsite/templates/page-file.tmpl +++ b/web/templates/page-file.tmpl diff --git a/vrsite/templates/page-files.tmpl b/web/templates/page-files.tmpl index 3f158fe..3f158fe 100644 --- a/vrsite/templates/page-files.tmpl +++ b/web/templates/page-files.tmpl diff --git a/vrsite/templates/page-index.tmpl b/web/templates/page-index.tmpl index 93cad6b..93cad6b 100644 --- a/vrsite/templates/page-index.tmpl +++ b/web/templates/page-index.tmpl diff --git a/vrsite/templates/page-jj.tmpl b/web/templates/page-jj.tmpl index 24df596..24df596 100644 --- a/vrsite/templates/page-jj.tmpl +++ b/web/templates/page-jj.tmpl diff --git a/vrsite/templates/page-note.tmpl b/web/templates/page-note.tmpl index 8cd0e64..8cd0e64 100644 --- a/vrsite/templates/page-note.tmpl +++ b/web/templates/page-note.tmpl diff --git a/vrsite/templates/page-notes.tmpl b/web/templates/page-notes.tmpl index ebd5865..ebd5865 100644 --- a/vrsite/templates/page-notes.tmpl +++ b/web/templates/page-notes.tmpl diff --git a/vrsite/templates/page-session.tmpl b/web/templates/page-session.tmpl index 9a35e8d..1155b67 100644 --- a/vrsite/templates/page-session.tmpl +++ b/web/templates/page-session.tmpl @@ -1,4 +1,4 @@ -{{/* One session's conversation, with its vr log woven in by time. */}} +{{/* One session's conversation, with its notevi log woven in by time. */}} {{define "page-session"}} {{$c := .Conv}} <header class="filehead"> @@ -16,7 +16,7 @@ {{else}} <p class="meta hint">no transcript found for this session — searched {{if $c.Roots}}{{range $i, $r := $c.Roots}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}{{else}}nowhere: <code>-transcripts</code> is empty{{end}} · - showing the vr log alone</p> + showing the notevi log alone</p> {{end}} {{if $c.Err}}<p class="err">{{$c.Err}}</p>{{end}} </header> @@ -41,9 +41,9 @@ <div class="notewrap">{{template "note" (dict "N" .Note "P" $)}}</div> {{end}} </div> - {{else if eq .Role "vr"}} - <div class="turn t-vr"> - <span class="role">vr</span><time>{{.Clock}}</time> + {{else if eq .Role "notevi"}} + <div class="turn t-notevi"> + <span class="role">notevi</span><time>{{.Clock}}</time> {{if .File}}<a class="what" href="{{$.Href "code" .File}}">{{.Text}}</a> {{else}}<span class="what">{{.Text}}</span>{{end}} </div> diff --git a/vrsite/templates/projects.tmpl b/web/templates/projects.tmpl index 3e3c670..ce75454 100644 --- a/vrsite/templates/projects.tmpl +++ b/web/templates/projects.tmpl @@ -4,25 +4,26 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}}</title> +<link rel="icon" type="image/svg+xml" href="/bemtevi.svg"> <link rel="stylesheet" href="/style.css"> <script> // Match project pages: wear the saved theme before first paint, and load the // installed-theme stylesheet now only when that saved choice requires it. -{const d = document.documentElement, t = localStorage.getItem("vrsite-theme"); +{const d = document.documentElement, t = localStorage.getItem("notevi-theme"); if (t && t !== "system") d.dataset.theme = t; if (t && !/^(system|dark|light)$/.test(t)) { const l = document.createElement("link"); l.rel = "stylesheet"; l.id = "themescss"; l.href = "/themes.css"; document.head.append(l); } - d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";} + d.dataset.mode = localStorage.getItem("notevi-mode") || "trace";} </script> <script src="/app.js" defer></script> </head> <body class="projects"> <nav aria-label="projects" id="nav"> - <header><a class="brand" href="/">{{.Title}}</a></header> - <p class="meta">one process · every local vr sidecar</p> + <header><a class="brand" href="/"><img class="mark" src="/bemtevi.svg" alt="" width="20" height="20">{{.Title}}</a></header> + <p class="meta">one process · every local notevi sidecar</p> <input id="projectfind" class="find" type="search" placeholder="find project…" aria-label="find project" autocomplete="off" spellcheck="false"> <form method="post" action="/projects/refresh" class="project-refresh"> <button type="submit">refresh repositories</button> @@ -51,7 +52,7 @@ <main class="projects-main"> <header class="filehead"> <h1>Projects</h1> - <p class="meta">Choose a vr log, start one in a jj repository, or colocate jj with an existing Git repository.</p> + <p class="meta">Choose a notevi log, start one in a jj repository, or colocate jj with an existing Git repository.</p> </header> {{if .Message}}<p class="ok project-message">{{.Message}}</p>{{end}} {{if .Err}}<p class="err project-message">{{.Err}}</p>{{end}} @@ -69,12 +70,12 @@ <a class="project-action" href="/p/{{.ID}}/">open project</a> </article> {{end}} - {{if not .Active}}<p class="meta hint">no vr sidecars found yet</p>{{end}} + {{if not .Active}}<p class="meta hint">no notevi sidecars found yet</p>{{end}} </div> </section> <section> - <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a vr sidecar</span></h2> + <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a notevi sidecar</span></h2> <div class="project-grid"> {{range .Ready}} <article class="project-card" data-project="{{.Name}} {{.Relative}}"> diff --git a/vrsite/theme.go b/web/theme.go index 91eedbe..135af43 100644 --- a/vrsite/theme.go +++ b/web/theme.go @@ -4,7 +4,7 @@ // The two themes named on the command line are what the site is built with; // themes.go finds the rest of this machine's themes for the picker, and they // arrive here as the same *Theme. -package main +package web import ( "encoding/json" diff --git a/vrsite/themes.go b/web/themes.go index f185fab..fd46799 100644 --- a/vrsite/themes.go +++ b/web/themes.go @@ -5,7 +5,7 @@ // Discovery is best-effort: a theme that will not parse is skipped, never // fatal. What the site is built with stays what -theme and -light-theme say — // these only widen what the picker can offer. -package main +package web import ( "bytes" diff --git a/vrsite/themes_test.go b/web/themes_test.go index 4ef1cca..f50f969 100644 --- a/vrsite/themes_test.go +++ b/web/themes_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "os" diff --git a/vrsite/transcript.go b/web/transcript.go index 713fe36..0678b80 100644 --- a/vrsite/transcript.go +++ b/web/transcript.go @@ -1,6 +1,6 @@ // 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 +// 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. @@ -9,7 +9,7 @@ // 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 +package web import ( "encoding/json" @@ -32,16 +32,16 @@ const ( ) // 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. +// or — woven in from the notevi 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 + 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 // vr turns: the file the op touched - Note *Note // vr note turns: the note itself, rendered as a card + 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 } @@ -135,7 +135,7 @@ func under(root, path string) bool { // 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 +// <root>/YYYY/MM/DD/rollout-<time>-<thread>.jsonl and notevi'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 { @@ -486,7 +486,7 @@ func clip(s string, n int) string { return strings.TrimRight(string(r[:n]), " \t") + "…" } -// ── one session: the transcript with the vr log woven in ────────────────── +// ── one session: the transcript with the notevi 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. @@ -507,7 +507,7 @@ type Conv struct { Incomplete bool // even "show all" had to stop } -// sessionTurns are the vr log's own moments for a session: what it read, +// sessionTurns are the notevi log's own moments for a session: what it read, // grepped and noted. func (s *Site) sessionTurns(id string) []Turn { var out []Turn @@ -515,12 +515,12 @@ func (s *Site) sessionTurns(id string) []Turn { if e.Session != id || e.Op == "note" { continue } - out = append(out, Turn{Role: "vr", Kind: e.Op, When: parseTime(e.Time), + out = append(out, Turn{Role: "notevi", 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}) + out = append(out, Turn{Role: "notevi", Kind: "note", When: n.At, File: n.File, Note: n}) } } return out diff --git a/vrsite/transcript_test.go b/web/transcript_test.go index 5e83ef4..1c3b3db 100644 --- a/vrsite/transcript_test.go +++ b/web/transcript_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "fmt" @@ -13,7 +13,7 @@ import ( // record per message with content blocks, codex wraps everything in a payload // and keeps the model's history in response_items. const claudeJSONL = `{"type":"user","timestamp":"2026-07-20T09:57:00.000Z","sessionId":"s1","cwd":"/repo","message":{"role":"user","content":"read lib.rs and tell me about the cache"}} -{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"vr read src/lib.rs:1-30","description":"read the file"}}]}} +{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"notevi read src/lib.rs:1-30","description":"read the file"}}]}} {"type":"user","timestamp":"2026-07-20T09:59:10.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"1\tuse std::collections::HashMap;\n2\t\n3\tstruct Cache;\n"}]}} {"type":"mode","timestamp":"2026-07-20T09:59:11.000Z","mode":"default"} {"type":"assistant","timestamp":"2026-07-20T09:59:40.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/repo/src/main.rs"}}]}} @@ -26,7 +26,7 @@ const codexJSONL = `{"timestamp":"2026-07-20T12:00:00.000Z","type":"session_meta {"timestamp":"2026-07-20T12:00:02.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/repo</cwd>\n</environment_context>"}]}} {"timestamp":"2026-07-20T12:00:03.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the cache"}]}} {"timestamp":"2026-07-20T12:00:04.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"start with lib.rs"}],"encrypted_content":"gAAAA"}} -{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"vr read src/lib.rs\",\"workdir\":\"/repo\"}"}} +{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"notevi read src/lib.rs\",\"workdir\":\"/repo\"}"}} {"timestamp":"2026-07-20T12:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc123\nOutput:\n1\tstruct Cache;\n"}} {"timestamp":"2026-07-20T12:00:07.000Z","type":"event_msg","payload":{"type":"agent_message","message":"the cache is two-level"}} {"timestamp":"2026-07-20T12:00:08.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_2","input":"*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n"}} @@ -69,7 +69,7 @@ func TestParseClaudeTranscript(t *testing.T) { "user read lib.rs and tell me about the cache", "thinking start with the struct", "assistant Reading it now.", - "tool Bash vr read src/lib.rs:1-30", + "tool Bash notevi read src/lib.rs:1-30", "result Bash 1\tuse std::collections::HashMap; … +2 lines", "tool Read /repo/src/main.rs", "result Read no such file", @@ -99,7 +99,7 @@ func TestParseCodexTranscript(t *testing.T) { want := []string{ "user review the cache", "thinking start with lib.rs", - "tool exec_command vr read src/lib.rs", + "tool exec_command notevi read src/lib.rs", "result exec_command Chunk ID: abc123 … +2 lines", "tool apply_patch *** Begin Patch … +4 lines", "result apply_patch Exit code: 0", @@ -259,11 +259,11 @@ func TestSessionPageInterleavesLogAndTranscript(t *testing.T) { for _, want := range []string{ "read lib.rs and tell me about the cache", // the conversation "It is a two-level map.", - "vr read src/lib.rs:1-30", // a tool call, summarized - "read hello.txt:1-2", // the vr log, woven in - "second line matters", // the note itself - `data-anchor="note-1"`, // and it is what the page lands on - `class="turn t-note here"`, // marked as the anchored moment + "notevi read src/lib.rs:1-30", // a tool call, summarized + "read hello.txt:1-2", // the notevi log, woven in + "second line matters", // the note itself + `data-anchor="note-1"`, // and it is what the page lands on + `class="turn t-note here"`, // marked as the anchored moment } { if !strings.Contains(body, want) { t.Errorf("session page is missing %q", want) @@ -293,7 +293,7 @@ func TestSessionPageWithoutATranscript(t *testing.T) { t.Error("the page does not say where it looked") } if !strings.Contains(body, "read hello.txt:1-2") || !strings.Contains(body, "second line matters") { - t.Error("the vr timeline is missing from the fallback") + t.Error("the notevi timeline is missing from the fallback") } } diff --git a/vrsite/tree.go b/web/tree.go index e7891c6..85e5536 100644 --- a/vrsite/tree.go +++ b/web/tree.go @@ -1,5 +1,5 @@ // tree.go — the repository tree, one fragment shared by every page. -package main +package web import ( "fmt" diff --git a/vrsite/main.go b/web/web.go index e43a8eb..ad7bd9c 100644 --- a/vrsite/main.go +++ b/web/web.go @@ -1,7 +1,7 @@ -// vrsite — the reading room for vr trace logs. +// Package web is notevi's reading room: "notevi web" served or exported. // -// 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 +// 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. @@ -14,7 +14,7 @@ // 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 +package web import ( "embed" @@ -26,30 +26,36 @@ import ( "strings" "time" - "0x4200.cafe/vr/sidecar" + "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, "vrsite: "+format+"\n", a...) + fmt.Fprintf(os.Stderr, "notevi web: "+format+"\n", a...) os.Exit(1) } -const usageText = `vrsite — the reading room for vr trace logs. +const usageText = `notevi web — the reading room for notevi 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. +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: - vrsite [-scan-root DIR] [-addr HOST:PORT] [-user NAME] + notevi web [-scan-root DIR] [-addr HOST:PORT] [-user NAME] serve every discovered project from one dashboard; scans ~ by default - vrsite -repo DIR [-addr HOST:PORT] [-user NAME] + notevi web -repo DIR [-addr HOST:PORT] [-user NAME] serve one repository directly, preserving the original single-project mode - vrsite [-repo DIR] -out DIR [-watch] + 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 @@ -58,12 +64,12 @@ 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 vr and tell them to read with it; constrain/AGENTS.md in - the vr repo is a drop-in brief that does, and constrain/ can enforce it + 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 vrsite reads the code at those changes, so a log from another +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, @@ -84,30 +90,32 @@ through it, and the browser remembers what you settled on. flags: ` -func main() { +// Main runs the "notevi web" subcommand; args are everything after "web". +func Main(args []string) { home, err := os.UserHomeDir() if err != nil { home = "." } - repo := flag.String("repo", "", "serve one jj repo directly (with -out, default: current directory)") - scanRoot := flag.String("scan-root", home, "live dashboard root to scan for jj and Git repositories") - 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(), ","), + 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 := 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() + 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() } - flag.Parse() + fs.Parse(args) if *debounce < 0 { fatal("debounce must not be negative") } @@ -122,8 +130,8 @@ func main() { // path. A bare live invocation is the new, stateless project dashboard. if *repo == "" && *out == "" { hubTitle := *title - if hubTitle == "vr trace" { - hubTitle = "vr projects" + if hubTitle == defaultTitle { + hubTitle = hubDefaultTitle } hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes) if err != nil { @@ -154,7 +162,7 @@ func main() { if !*watch { return } - fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", log, *debounce) + fmt.Printf("notevi web: watching %s (rebuild after %s without changes)\n", log, *debounce) watchLog(log, *debounce, build) } @@ -203,7 +211,7 @@ func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struc next, err := log.Generation() if err != nil { if msg := err.Error(); msg != lastStatErr { - fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", log, err) + fmt.Fprintf(os.Stderr, "notevi web: watch %s: %v\n", log, err) lastStatErr = msg } continue @@ -225,10 +233,10 @@ func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struc } timer.Reset(debounce) } - fmt.Printf("vrsite: change detected; rebuild scheduled after %s without changes\n", debounce) + fmt.Printf("notevi web: change detected; rebuild scheduled after %s without changes\n", debounce) case <-timerC: - fmt.Println("vrsite: rebuilding after trace changes settled") + fmt.Println("notevi web: rebuilding after trace changes settled") timer = nil timerC = nil rebuild() diff --git a/vrsite/main_test.go b/web/web_test.go index b7c93c3..3986cdc 100644 --- a/vrsite/main_test.go +++ b/web/web_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "os" |
