diff options
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 133 |
1 files changed, 80 insertions, 53 deletions
@@ -1,9 +1,9 @@ // 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. +// Each operation appends one JSON line to a private jj 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. // // Agent identity comes from the caller's environment (Claude Code exports // CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when @@ -18,6 +18,7 @@ import ( "bytes" _ "embed" "encoding/json" + "errors" "flag" "fmt" "os" @@ -27,6 +28,8 @@ import ( "strconv" "strings" "time" + + "0x4200.cafe/vr/sidecar" ) type Entry struct { @@ -39,6 +42,7 @@ type Entry struct { Dir string `json:"dir"` Rev string `json:"rev,omitempty"` Change string `json:"change,omitempty"` + Commit string `json:"commit,omitempty"` File string `json:"file,omitempty"` Start int `json:"start,omitempty"` End int `json:"end,omitempty"` @@ -51,6 +55,7 @@ type Entry struct { } var gSession, gAgent, gModel, gReasoning, gDir string +var gStore *sidecar.Store //go:embed doc.txt var doc string @@ -90,20 +95,20 @@ 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]] + vr read [-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] + vr 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 [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... + vr 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 [-log FILE] [-session S] [-op OP] [-file SUBSTR] [-n N] + vr query [-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. +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. `) os.Exit(2) @@ -114,13 +119,6 @@ func fatal(format string, a ...any) { 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() { @@ -257,8 +255,14 @@ func jjOut(args ...string) string { // 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")) +func revisionIDs(rev string) (change, commit string) { + out := jjOut("log", "-r", "exactly(("+rev+"), 1)", "--no-graph", "--ignore-working-copy", + "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"") + lines := strings.Fields(out) + if len(lines) != 2 { + fatal("revision %q did not resolve to one change and commit", rev) + } + return lines[0], lines[1] } // parseFileRange splits "file.go:10-20" / "file.go:10" / "file.go". @@ -283,11 +287,10 @@ func parseFileRange(s string) (file string, start, end int) { 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]]") + fatal("usage: vr read [-r REV] FILE[:START[-END]]") } file, start, end := parseFileRange(fs.Arg(0)) @@ -308,16 +311,16 @@ func cmdRead(args []string) { 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}) + change, commit := revisionIDs(*rev) + writeLog(Entry{Op: "read", Rev: *rev, Change: change, Commit: commit, 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]") + fatal("usage: vr grep [-r REV] PATTERN [PATH]") } pattern, path := fs.Arg(0), fs.Arg(1) @@ -356,12 +359,12 @@ func cmdGrep(args []string) { } } } - writeLog(*logPath, Entry{Op: "grep", Rev: *rev, Change: changeID(*rev), Pattern: pattern, File: path, Matches: matches}) + change, commit := revisionIDs(*rev) + writeLog(Entry{Op: "grep", Rev: *rev, Change: change, Commit: commit, 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, ...)") @@ -369,21 +372,23 @@ func cmdNote(args []string) { fs.Parse(args) text := strings.Join(fs.Args(), " ") if text == "" { - fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") + fatal("usage: vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") } if *reply < 0 { fatal("reply note must be positive") } - if *reply > 0 && !hasNote(readLog(*logPath), *reply) { - fatal("no log-wide note #%d in %s", *reply, *logPath) - } - - e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), ReplyTo: *reply, Type: *kind, Text: text} + change, commit := revisionIDs(*rev) + e := Entry{Op: "note", Rev: *rev, Change: change, Commit: commit, ReplyTo: *reply, Type: *kind, Text: text} if *file != "" { e.File, e.Start, e.End = parseFileRange(*file) } - e.NoteID = nextNoteID(*logPath) - writeLog(*logPath, e) + writeLogWith(func(entries []Entry) (Entry, error) { + if *reply > 0 && !hasNote(entries, *reply) { + return Entry{}, fmt.Errorf("no log-wide note #%d in %s", *reply, store()) + } + e.NoteID = nextNoteID(entries) + return e, nil + }) fmt.Printf("note %d\n", e.NoteID) } @@ -402,9 +407,9 @@ func hasNote(entries []Entry, id int) bool { return false } -func nextNoteID(logPath string) int { +func nextNoteID(entries []Entry) int { max := 0 - for _, e := range readLog(logPath) { + for _, e := range entries { if e.Op == "note" && e.Session == gSession && e.NoteID > max { max = e.NoteID } @@ -414,14 +419,13 @@ func nextNoteID(logPath string) int { 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) + entries := readLog() type result struct { Entry logNoteID int @@ -493,13 +497,9 @@ func fileRange(e Entry) string { return s } -// ── log file ────────────────────────────────────────────────────────────── +// ── jj sidecar log ──────────────────────────────────────────────────────── -func readLog(path string) []Entry { - b, err := os.ReadFile(path) - if err != nil { - return nil - } +func parseLog(b []byte) []Entry { var entries []Entry for _, line := range strings.Split(string(b), "\n") { if line == "" { @@ -513,16 +513,43 @@ func readLog(path string) []Entry { 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) +func store() *sidecar.Store { + if gStore == nil { + var err error + gStore, err = sidecar.Open(gDir) + if err != nil { + fatal("open jj sidecar: %v", err) + } + } + return gStore +} + +func readLog() []Entry { + b, err := store().Read() + if errors.Is(err, sidecar.ErrNoLog) { + return nil + } if err != nil { - fatal("open log: %v", err) + fatal("read %s: %v", store(), err) } - defer f.Close() - if _, err := f.Write(append(b, '\n')); err != nil { - fatal("write log: %v", err) + return parseLog(b) +} + +func writeLog(e Entry) { + writeLogWith(func([]Entry) (Entry, error) { return e, nil }) +} + +func writeLogWith(build func([]Entry) (Entry, error)) { + err := store().Append(func(current []byte) ([]byte, error) { + e, err := build(parseLog(current)) + if err != nil { + return nil, err + } + e.Time = time.Now().Format(time.RFC3339) + e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir + return json.Marshal(e) + }) + if err != nil { + fatal("write %s: %v", store(), err) } } |
