diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-24 11:39:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 15:18:55 -0300 |
| commit | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch) | |
| tree | c3e89426ff044ffe339dd3a77e7f3b7115cba636 /main.go | |
| download | notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip | |
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 491 |
1 files changed, 491 insertions, 0 deletions
@@ -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) + } +} |
