summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 12:36:54 -0300
committerGabriel Schneider <[email protected]>2026-08-02 17:37:58 -0300
commite16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb (patch)
treede0db0c369522b5bf92ff32a8351de2224c5c9dc /main.go
parent151ea12251ed876aa87bb486da1bc337e25369e9 (diff)
downloadnotevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.tar.gz
notevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.zip
Diffstat (limited to 'main.go')
-rw-r--r--main.go133
1 files changed, 80 insertions, 53 deletions
diff --git a/main.go b/main.go
index 6876a8c..1d263cb 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}