summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-24 11:39:36 -0300
committerGabriel Schneider <[email protected]>2026-07-30 15:18:55 -0300
commitcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch)
treec3e89426ff044ffe339dd3a77e7f3b7115cba636 /main.go
downloadnotevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz
notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip
Diffstat (limited to 'main.go')
-rw-r--r--main.go491
1 files changed, 491 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..e0ac598
--- /dev/null
+++ b/main.go
@@ -0,0 +1,491 @@
+// vr — file reader / note taker for coding agents, logging every call.
+//
+// Each operation appends one JSON line to a log file recording who read what:
+// session id, agent, model, reasoning effort, working dir, jj change id, file
+// and line range. Reads and greps are relative to a jj revision (default @, the
+// working copy) — the tool assumes it runs inside a jj repo.
+//
+// Agent identity comes from the caller's environment (Claude Code exports
+// CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when
+// absent, we walk the process tree with ps — the agent-id.py technique, macOS
+// edition — matching harness names in ancestor cmdlines. Unlike /proc on
+// Linux, macOS redacts other processes' env (KERN_PROCARGS2 is caller-only,
+// ps -E shows nothing even for your own children), so the session id can only
+// come from our own env, i.e. from harnesses that export it.
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Entry struct {
+ Time string `json:"time"`
+ Op string `json:"op"`
+ Session string `json:"session,omitempty"`
+ Agent string `json:"agent,omitempty"`
+ Model string `json:"model,omitempty"`
+ Reasoning string `json:"reasoning,omitempty"`
+ Dir string `json:"dir"`
+ Rev string `json:"rev,omitempty"`
+ Change string `json:"change,omitempty"`
+ File string `json:"file,omitempty"`
+ Start int `json:"start,omitempty"`
+ End int `json:"end,omitempty"`
+ Pattern string `json:"pattern,omitempty"`
+ Matches int `json:"matches,omitempty"`
+ NoteID int `json:"note_id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+var gSession, gAgent, gModel, gReasoning, gDir string
+
+//go:embed doc.txt
+var doc string
+
+//go:embed query-doc.txt
+var queryDoc string
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ }
+ gDir, _ = os.Getwd()
+ switch strings.TrimLeft(os.Args[1], "-") {
+ case "doc":
+ fmt.Print(doc)
+ return
+ case "doc-query":
+ fmt.Print(queryDoc)
+ return
+ }
+ detect()
+ switch os.Args[1] {
+ case "read":
+ cmdRead(os.Args[2:])
+ case "grep":
+ cmdGrep(os.Args[2:])
+ case "note":
+ cmdNote(os.Args[2:])
+ case "query":
+ cmdQuery(os.Args[2:])
+ default:
+ usage()
+ }
+}
+
+func usage() {
+ fmt.Fprint(os.Stderr, `vr — file reader / note taker for coding agents; logs every call.
+
+usage:
+ vr read [-log FILE] [-r REV] FILE[:START[-END]]
+ print a file line-numbered, whole or a line range, at a jj revision
+ vr grep [-log FILE] [-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 [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] TEXT...
+ record a note about a file range or a kind of thing (struct, enum,
+ const, ...); ids count up from 1 per session
+ vr query [-log FILE] [-session S] [-op OP] [-file SUBSTR] [-n N]
+ print logged entries, oldest first
+
+REV is any jj revset, default @ (working copy). The log is JSON lines,
+default ./vr-log.jsonl, overridable with -log or $VR_LOG.
+"vr -doc" prints the short agent-facing usage doc; "vr -doc-query" the query one.
+`)
+ os.Exit(2)
+}
+
+func fatal(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "vr: "+format+"\n", a...)
+ os.Exit(1)
+}
+
+func defaultLog() string {
+ if v := os.Getenv("VR_LOG"); v != "" {
+ return v
+ }
+ return "vr-log.jsonl"
+}
+
+// ── agent / session / model detection ─────────────────────────────────────
+
+func detect() {
+ envClaude := os.Getenv("CLAUDECODE") == "1" || os.Getenv("CLAUDE_CODE_SESSION_ID") != ""
+ envCodex := os.Getenv("CODEX_THREAD_ID") != "" || os.Getenv("CODEX_SANDBOX") != ""
+ envHermes := os.Getenv("HERMES_SESSION_ID") != ""
+
+ switch {
+ case envClaude && !envCodex && !envHermes:
+ gAgent = "claude"
+ case envCodex && !envClaude && !envHermes:
+ gAgent = "codex"
+ case envHermes && !envClaude && !envCodex:
+ gAgent = "hermes"
+ default:
+ // none, or several — nested harnesses inherit each other's env
+ // (codex launched from a claude session carries both sets of vars),
+ // so let the nearest ancestor in the process tree decide
+ walkProcessTree()
+ }
+
+ switch {
+ case strings.Contains(gAgent, "claude"):
+ if v := os.Getenv("AI_AGENT"); v != "" {
+ gAgent = v // claude-code_<version>_agent
+ }
+ gSession = os.Getenv("CLAUDE_CODE_SESSION_ID")
+ gModel = os.Getenv("ANTHROPIC_MODEL")
+ gReasoning = os.Getenv("CLAUDE_EFFORT")
+ // Claude Code doesn't put the model in the env; it lives in settings.
+ if gModel == "" || gReasoning == "" {
+ home, _ := os.UserHomeDir()
+ b, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json"))
+ if err == nil {
+ var s struct {
+ Model string `json:"model"`
+ Effort string `json:"effortLevel"`
+ }
+ json.Unmarshal(b, &s)
+ if gModel == "" {
+ gModel = s.Model
+ }
+ if gReasoning == "" {
+ gReasoning = s.Effort
+ }
+ }
+ }
+ case gAgent == "codex":
+ gSession = os.Getenv("CODEX_THREAD_ID")
+ gModel, gReasoning = codexConfig()
+ case gAgent == "hermes":
+ gSession = os.Getenv("HERMES_SESSION_ID")
+ }
+}
+
+// codexConfig reads the top-level model and model_reasoning_effort from
+// ~/.codex/config.toml (keys before the first [section]; profiles ignored).
+func codexConfig() (model, effort string) {
+ home, _ := os.UserHomeDir()
+ b, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml"))
+ if err != nil {
+ return
+ }
+ modelRe := regexp.MustCompile(`^model\s*=\s*"([^"]+)"`)
+ effortRe := regexp.MustCompile(`^model_reasoning_effort\s*=\s*"([^"]+)"`)
+ for _, line := range strings.Split(string(b), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "[") {
+ break
+ }
+ if m := modelRe.FindStringSubmatch(line); m != nil {
+ model = m[1]
+ }
+ if m := effortRe.FindStringSubmatch(line); m != nil {
+ effort = m[1]
+ }
+ }
+ return
+}
+
+var agentPatterns = []struct {
+ name string
+ re *regexp.Regexp
+}{
+ {"hermes", regexp.MustCompile(`(?i)hermes[_-]?(cli|gateway)`)},
+ {"codex", regexp.MustCompile(`(?i)codex`)},
+ {"claude", regexp.MustCompile(`(?i)claude`)},
+}
+
+var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, "ksh": true, "nu": true}
+
+func walkProcessTree() {
+ pid := os.Getppid()
+ for depth := 0; pid > 1 && depth < 20; depth++ {
+ // a shell's cmdline embeds arbitrary user command text (which can
+ // mention any harness by name), so never pattern-match shells
+ comm := strings.TrimPrefix(ps(pid, "-o", "comm="), "-")
+ if !shells[filepath.Base(comm)] {
+ cmdline := ps(pid, "-ww", "-o", "command=")
+ for _, p := range agentPatterns {
+ if p.re.MatchString(cmdline) {
+ gAgent = p.name
+ return
+ }
+ }
+ }
+ ppid, err := strconv.Atoi(ps(pid, "-o", "ppid="))
+ if err != nil || ppid == pid {
+ return
+ }
+ pid = ppid
+ }
+}
+
+func ps(pid int, args ...string) string {
+ out, _ := exec.Command("ps", append(args, "-p", strconv.Itoa(pid))...).Output()
+ return strings.TrimSpace(string(out))
+}
+
+// ── jj helpers ────────────────────────────────────────────────────────────
+
+func jjOut(args ...string) string {
+ out, err := exec.Command("jj", args...).Output()
+ if err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ os.Stderr.Write(ee.Stderr)
+ }
+ fatal("jj %s failed: %v", strings.Join(args, " "), err)
+ }
+ return string(out)
+}
+
+// --ignore-working-copy skips the snapshot: change ids are stable across
+// snapshots, and this keeps `vr note`/`vr 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 changeID(rev string) string {
+ return strings.TrimSpace(jjOut("log", "-r", rev, "--no-graph", "--ignore-working-copy", "-T", "change_id"))
+}
+
+// parseFileRange splits "file.go:10-20" / "file.go:10" / "file.go".
+func parseFileRange(s string) (file string, start, end int) {
+ i := strings.LastIndex(s, ":")
+ if i < 0 {
+ return s, 0, 0
+ }
+ m := regexp.MustCompile(`^(\d+)(?:-(\d+))?$`).FindStringSubmatch(s[i+1:])
+ if m == nil {
+ return s, 0, 0
+ }
+ start, _ = strconv.Atoi(m[1])
+ end = start
+ if m[2] != "" {
+ end, _ = strconv.Atoi(m[2])
+ }
+ return s[:i], start, end
+}
+
+// ── subcommands ───────────────────────────────────────────────────────────
+
+func cmdRead(args []string) {
+ fs := flag.NewFlagSet("read", flag.ExitOnError)
+ logPath := fs.String("log", defaultLog(), "log file to append to")
+ rev := fs.String("r", "@", "jj revision to read at")
+ fs.Parse(args)
+ if fs.NArg() != 1 {
+ fatal("usage: vr read [-log FILE] [-r REV] FILE[:START[-END]]")
+ }
+ file, start, end := parseFileRange(fs.Arg(0))
+
+ content := jjOut("file", "show", "-r", *rev, "--", file)
+ lines := strings.Split(content, "\n")
+ if n := len(lines); n > 0 && lines[n-1] == "" {
+ lines = lines[:n-1]
+ }
+ if start == 0 {
+ start, end = 1, len(lines)
+ }
+ if start > len(lines) {
+ fatal("%s has only %d lines", file, len(lines))
+ }
+ if end > len(lines) {
+ end = len(lines)
+ }
+ for i := start; i <= end; i++ {
+ fmt.Printf("%d\t%s\n", i, lines[i-1])
+ }
+ writeLog(*logPath, Entry{Op: "read", Rev: *rev, Change: changeID(*rev), File: file, Start: start, End: end})
+}
+
+func cmdGrep(args []string) {
+ fs := flag.NewFlagSet("grep", flag.ExitOnError)
+ logPath := fs.String("log", defaultLog(), "log file to append to")
+ rev := fs.String("r", "@", "jj revision to search at")
+ fs.Parse(args)
+ if fs.NArg() < 1 || fs.NArg() > 2 {
+ fatal("usage: vr grep [-log FILE] [-r REV] PATTERN [PATH]")
+ }
+ pattern, path := fs.Arg(0), fs.Arg(1)
+
+ matches := 0
+ if *rev == "@" {
+ rgArgs := []string{"-n", "--no-heading", pattern}
+ if path != "" {
+ rgArgs = append(rgArgs, path)
+ }
+ out, err := exec.Command("rg", rgArgs...).Output()
+ os.Stdout.Write(out)
+ if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 1 { // 1 = no matches
+ os.Stderr.Write(ee.Stderr)
+ fatal("rg failed: %v", err)
+ }
+ matches = bytes.Count(out, []byte("\n"))
+ } else {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ fatal("bad pattern: %v", err)
+ }
+ listArgs := []string{"file", "list", "-r", *rev}
+ if path != "" {
+ listArgs = append(listArgs, "--", path)
+ }
+ for _, f := range strings.Split(strings.TrimRight(jjOut(listArgs...), "\n"), "\n") {
+ if f == "" {
+ continue
+ }
+ content := strings.TrimSuffix(jjOut("file", "show", "-r", *rev, "--", f), "\n")
+ for i, line := range strings.Split(content, "\n") {
+ if re.MatchString(line) {
+ fmt.Printf("%s:%d:%s\n", f, i+1, line)
+ matches++
+ }
+ }
+ }
+ }
+ writeLog(*logPath, Entry{Op: "grep", Rev: *rev, Change: changeID(*rev), Pattern: pattern, File: path, Matches: matches})
+}
+
+func cmdNote(args []string) {
+ fs := flag.NewFlagSet("note", flag.ExitOnError)
+ logPath := fs.String("log", defaultLog(), "log file to append to")
+ rev := fs.String("r", "@", "jj revision the note refers to")
+ file := fs.String("f", "", "FILE[:START[-END]] the note is about")
+ kind := fs.String("t", "", "kind of thing the note is about (struct, enum, const, ...)")
+ fs.Parse(args)
+ text := strings.Join(fs.Args(), " ")
+ if text == "" {
+ fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] TEXT...")
+ }
+
+ e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), Type: *kind, Text: text}
+ if *file != "" {
+ e.File, e.Start, e.End = parseFileRange(*file)
+ }
+ e.NoteID = nextNoteID(*logPath)
+ writeLog(*logPath, e)
+ fmt.Printf("note %d\n", e.NoteID)
+}
+
+func nextNoteID(logPath string) int {
+ max := 0
+ for _, e := range readLog(logPath) {
+ if e.Op == "note" && e.Session == gSession && e.NoteID > max {
+ max = e.NoteID
+ }
+ }
+ return max + 1
+}
+
+func cmdQuery(args []string) {
+ fs := flag.NewFlagSet("query", flag.ExitOnError)
+ logPath := fs.String("log", defaultLog(), "log file to read")
+ session := fs.String("session", "", "filter: session id substring")
+ op := fs.String("op", "", "filter: read | grep | note")
+ file := fs.String("file", "", "filter: file path substring")
+ n := fs.Int("n", 0, "show only the last N entries")
+ fs.Parse(args)
+
+ entries := readLog(*logPath)
+ var kept []Entry
+ for _, e := range entries {
+ if *session != "" && !strings.Contains(e.Session, *session) {
+ continue
+ }
+ if *op != "" && e.Op != *op {
+ continue
+ }
+ if *file != "" && !strings.Contains(e.File, *file) {
+ continue
+ }
+ kept = append(kept, e)
+ }
+ if *n > 0 && len(kept) > *n {
+ kept = kept[len(kept)-*n:]
+ }
+ for _, e := range kept {
+ ts := e.Time
+ if t, err := time.Parse(time.RFC3339, e.Time); err == nil {
+ ts = t.Format("01-02 15:04:05")
+ }
+ head := fmt.Sprintf("%s %-8.8s %-5s %-8.8s", ts, e.Session, e.Op, e.Change)
+ switch e.Op {
+ case "read":
+ fmt.Printf("%s %s\n", head, fileRange(e))
+ case "grep":
+ loc := ""
+ if e.File != "" {
+ loc = " in " + e.File
+ }
+ fmt.Printf("%s %q%s (%d matches)\n", head, e.Pattern, loc, e.Matches)
+ case "note":
+ tag := ""
+ if e.Type != "" {
+ tag = " (" + e.Type + ")"
+ }
+ loc := ""
+ if e.File != "" {
+ loc = " " + fileRange(e)
+ }
+ fmt.Printf("%s #%d%s%s — %s\n", head, e.NoteID, tag, loc, e.Text)
+ default:
+ fmt.Printf("%s\n", head)
+ }
+ }
+}
+
+func fileRange(e Entry) string {
+ s := e.File
+ if e.Start > 0 {
+ s += ":" + strconv.Itoa(e.Start)
+ if e.End > e.Start {
+ s += "-" + strconv.Itoa(e.End)
+ }
+ }
+ return s
+}
+
+// ── log file ──────────────────────────────────────────────────────────────
+
+func readLog(path string) []Entry {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ var entries []Entry
+ for _, line := range strings.Split(string(b), "\n") {
+ if line == "" {
+ continue
+ }
+ var e Entry
+ if json.Unmarshal([]byte(line), &e) == nil {
+ entries = append(entries, e)
+ }
+ }
+ return entries
+}
+
+func writeLog(path string, e Entry) {
+ e.Time = time.Now().Format(time.RFC3339)
+ e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir
+ b, _ := json.Marshal(e)
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ fatal("open log: %v", err)
+ }
+ defer f.Close()
+ if _, err := f.Write(append(b, '\n')); err != nil {
+ fatal("write log: %v", err)
+ }
+}