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 /vrsite/model.go | |
| 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]>
Diffstat (limited to 'vrsite/model.go')
| -rw-r--r-- | vrsite/model.go | 621 |
1 files changed, 0 insertions, 621 deletions
diff --git a/vrsite/model.go b/vrsite/model.go deleted file mode 100644 index 10f3f1d..0000000 --- a/vrsite/model.go +++ /dev/null @@ -1,621 +0,0 @@ -// model.go — the log projected into a browsable model. -// -// Everything the site shows is derived here from the vr 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 - -import ( - "encoding/json" - "fmt" - "html/template" - "sort" - "strings" - "time" -) - -// Coverage is a uint8 bitmask, so only the first maxAgents agents get a color; -// later ones fall back to the muted "no bit" rendering. -const maxAgents = 8 - -type Entry struct { - Time string `json:"time,omitempty"` - Op string `json:"op"` - Session string `json:"session,omitempty"` - Agent string `json:"agent,omitempty"` - Model string `json:"model,omitempty"` - Reason string `json:"reasoning,omitempty"` - Dir string `json:"dir,omitempty"` - 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"` - Pattern string `json:"pattern,omitempty"` - Matches int `json:"matches,omitempty"` - NoteID int `json:"note_id,omitempty"` - ReplyTo int `json:"reply_to,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` -} - -type Agent struct { - Name, Short, Session, Model, Reason string - Bit uint8 - Reads, Greps, Notes int -} - -type Note struct { - ID int // position in the log, for anchors - NoteID int // the agent's own per-session number - Agent *Agent - Session, Model, Reason string - File, Kind, Text, Change, Commit, Rev string - Start, End int - ReplyTo int - Replies, Mentions []*Note // notes that point back to this one - At time.Time - When, Ago string -} - -// ThreadNode is the tree-shaped rendering of the note DAG. A note reached by -// more than one path is emitted fully once and then as a compact reference, so -// the focused page keeps valid DOM ids and cannot recurse through malformed -// cyclic logs. -type ThreadNode struct { - Note *Note - Via string // "reply" | "mention"; empty for the focused root - Children []*ThreadNode - Reference bool -} - -type File struct { - Path, Change string - Traced bool - Stub string // set when content is not rendered (binary, huge) - Cov []uint8 // 1-based line -> agent bitmask - NoteAt map[int]int // line -> index into Notes - Reads, Greps int - Patterns []string - Notes []*Note - Total, Covered, Pct int - Mask uint8 - Body template.HTML -} - -type Site struct { - Title string - Repo string - Change string // the change most entries were logged at - Only string // the change this view is scoped to ("" = the whole log) - Agents []*Agent - Files map[string]*File // traced files - Order []string // traced paths, sorted - All []*File // every file in the repo at the site change - Notes []*Note // in scope - Entries []Entry // the whole log, scope or no scope - Theme *Theme // dark - Light *Theme - - // counted over the whole log, so a scoped view can still say what it is - // not showing - NotesTotal int - NotesAt map[string]int // change -> notes recorded there - Votes map[string]int // change -> entries recorded there - Ranked []string // changes, most-logged first -} - -// buildModel projects log entries onto agents, files and notes. It never -// touches the repo, so it is cheap enough to redo on every log change. -// -// only, when set, keeps just what was recorded at that change: a read logged -// against another change points at line numbers that have since moved, so a -// view of one change must not inherit them. Two things stay whole-log -// regardless — agent colors, so they do not shuffle when the scope changes, -// and note ids, so a link to a note survives it. -func buildModel(title string, entries []Entry, only string) *Site { - s := &Site{Title: title, Only: only, Files: map[string]*File{}, Entries: entries, - NotesAt: map[string]int{}, Votes: map[string]int{}} - agents := map[string]*Agent{} - noteN := 0 - for _, e := range entries { - if e.Agent == "" { - e.Agent = "unknown" - } - a := agents[e.Agent] - if a == nil { - a = &Agent{Name: e.Agent, Short: strings.SplitN(e.Agent, "_", 2)[0], - Session: e.Session, Model: e.Model, Reason: e.Reason} - if len(s.Agents) < maxAgents { - a.Bit = 1 << len(s.Agents) - } - agents[e.Agent] = a - s.Agents = append(s.Agents, a) - } - if e.Change != "" { - s.Votes[e.Change]++ - } - if e.Op == "note" { - noteN++ - s.NotesTotal++ - s.NotesAt[e.Change]++ - } - if only != "" && e.Change != only { - continue - } - switch e.Op { - case "read": - a.Reads++ - f := s.file(e.File, e.Change) - f.Reads++ - f.cover(e.Start, e.End, a.Bit) - case "grep": - a.Greps++ - if f, ok := s.Files[e.File]; ok || e.File != "" { - if f == nil { // grep on a file we later read - f = s.file(e.File, e.Change) - } - f.Greps++ - f.Patterns = append(f.Patterns, fmt.Sprintf("%s (%d)", e.Pattern, e.Matches)) - } - case "note": - a.Notes++ - at := parseTime(e.Time) - n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session, - Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change, - Commit: e.Commit, Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, - Kind: e.Type, Text: e.Text, - At: at, When: stamp(at), Ago: ago(at)} - s.Notes = append(s.Notes, n) - if e.File != "" { - f := s.file(e.File, e.Change) - f.Notes = append(f.Notes, n) - } - } - } - for c := range s.Votes { - s.Ranked = append(s.Ranked, c) - } - // most-logged first; ties by id so the pick is stable across rebuilds - sort.Slice(s.Ranked, func(i, j int) bool { - a, b := s.Ranked[i], s.Ranked[j] - if s.Votes[a] != s.Votes[b] { - return s.Votes[a] > s.Votes[b] - } - return a < b - }) - if len(s.Ranked) > 0 { - s.Change = s.Ranked[0] - } - // drop grep-only "files" that are really path filters (dirs, non-files) - for p, f := range s.Files { - if f.Reads == 0 && len(f.Notes) == 0 { - delete(s.Files, p) - } - } - for p := range s.Files { - s.Order = append(s.Order, p) - } - sort.Strings(s.Order) - linkNotes(s.Notes) - return s -} - -func (s *Site) file(path, change string) *File { - f := s.Files[path] - if f == nil { - f = &File{Path: path, NoteAt: map[int]int{}, Traced: true} - s.Files[path] = f - } - if change != "" { - f.Change = change - } - return f -} - -func (f *File) cover(start, end int, bit uint8) { - if start < 1 { - return - } - if end < start { - end = start - } - for len(f.Cov) <= end { - f.Cov = append(f.Cov, 0) - } - for i := start; i <= end; i++ { - f.Cov[i] |= bit - } - f.Mask |= bit -} - -// clone copies the per-render state so one *File from the model can be -// rendered concurrently at several revisions. Cov is read-only during render. -func (f *File) clone() *File { - c := *f - c.NoteAt = map[int]int{} - c.Notes = append([]*Note(nil), f.Notes...) - return &c -} - -// filesAt joins a revision's path list with the traced files, so untraced -// files still appear in the tree. Traced *File values are shared read-only. -func (s *Site) filesAt(paths []string, change string) []*File { - var all []*File - for _, p := range paths { - if p == "" { - continue - } - f := s.Files[p] - if f == nil { - f = &File{Path: p, Change: change, NoteAt: map[int]int{}} - } else { - f.Traced = true - } - all = append(all, f) - } - sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path }) - return all -} - -// masks lists every bitmask combination the colored agents can produce. -func (s *Site) masks() []uint8 { - n := len(s.Agents) - if n > maxAgents { - n = maxAgents - } - var out []uint8 - for m := 1; m < 1<<n; m++ { - out = append(out, uint8(m)) - } - return out -} - -// ── notes: filtering and facets ─────────────────────────────────────────── - -type NoteFilter struct { - Text string // substring of the note body - File string // substring of the file path - Agent string - Model string - Session string - Kind string - Change string - From string // YYYY-MM-DD, inclusive - To string // YYYY-MM-DD, inclusive -} - -func (f NoteFilter) empty() bool { return f == NoteFilter{} } - -func (f NoteFilter) match(n *Note) bool { - has := func(hay, needle string) bool { - return needle == "" || strings.Contains(strings.ToLower(hay), strings.ToLower(needle)) - } - if !has(n.Text, f.Text) || !has(n.File, f.File) { - return false - } - eq := func(got, want string) bool { return want == "" || got == want } - if !eq(n.Agent.Name, f.Agent) || !eq(n.Model, f.Model) || !eq(n.Session, f.Session) || - !eq(n.Kind, f.Kind) || !eq(n.Change, f.Change) { - return false - } - if f.From != "" && (n.At.IsZero() || n.At.Format(dateFmt) < f.From) { - return false - } - if f.To != "" && (n.At.IsZero() || n.At.Format(dateFmt) > f.To) { - return false - } - return true -} - -const dateFmt = "2006-01-02" - -// FilterNotes returns matching notes, newest first (undated notes last). -func (s *Site) FilterNotes(f NoteFilter) []*Note { - var out []*Note - for _, n := range s.Notes { - if f.match(n) { - out = append(out, n) - } - } - sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) }) - return out -} - -// NoteByID finds a note by the id a permalink names. Ids count over the whole -// log and the log only ever grows, so the link a reader was given goes on -// meaning the same note however the site is later scoped or rebuilt. -func (s *Site) NoteByID(id int) *Note { - for _, n := range s.Notes { - if n.ID == id { - return n - } - } - return nil -} - -// linkNotes derives the graph without adding another log operation. Replies -// are explicit reply_to edges; mentions are ordinary local Markdown links to -// /note/N (or an equivalent relative path). If a reply also links its parent, -// the explicit relationship wins and the child is not counted twice. -func linkNotes(notes []*Note) { - byID := make(map[int]*Note, len(notes)) - for _, n := range notes { - byID[n.ID] = n - } - links := newNoteLinkParser() - for _, child := range notes { - if parent := byID[child.ReplyTo]; parent != nil && parent != child { - parent.Replies = append(parent.Replies, child) - } - for _, id := range noteLinks(links, child.Text) { - parent := byID[id] - if parent == nil || parent == child || id == child.ReplyTo { - continue - } - parent.Mentions = append(parent.Mentions, child) - } - } -} - -// NoteThread expands replies and mentions together, oldest first. The global -// seen set turns a DAG into one readable Hacker News-style thread while still -// leaving a reference at every additional incoming edge. -func (s *Site) NoteThread(id int) *ThreadNode { - root := s.NoteByID(id) - if root == nil { - return nil - } - seen := map[int]bool{id: true} - var build func(*Note) *ThreadNode - build = func(n *Note) *ThreadNode { - node := &ThreadNode{Note: n} - for _, edge := range noteEdges(n) { - child := &ThreadNode{Note: edge.Note, Via: edge.Via} - if seen[edge.Note.ID] { - child.Reference = true - } else { - seen[edge.Note.ID] = true - child = build(edge.Note) - child.Via = edge.Via - } - node.Children = append(node.Children, child) - } - return node - } - return build(root) -} - -type noteEdge struct { - Note *Note - Via string -} - -func noteEdges(n *Note) []noteEdge { - seen := map[int]bool{} - edges := make([]noteEdge, 0, len(n.Replies)+len(n.Mentions)) - for _, child := range n.Replies { - seen[child.ID] = true - edges = append(edges, noteEdge{Note: child, Via: "reply"}) - } - for _, child := range n.Mentions { - if !seen[child.ID] { - edges = append(edges, noteEdge{Note: child, Via: "mention"}) - } - } - sort.SliceStable(edges, func(i, j int) bool { return edges[i].Note.ID < edges[j].Note.ID }) - return edges -} - -// Facets are the distinct values the note filters can select, so the form -// only ever offers choices that exist in the log. -type Facets struct { - Agents, Models, Sessions, Kinds, Changes, Files []string -} - -func (s *Site) Facets() *Facets { - fa := &Facets{} - add := func(dst *[]string, seen map[string]bool, v string) { - if v != "" && !seen[v] { - seen[v] = true - *dst = append(*dst, v) - } - } - ag, mo, se, ki, ch, fi := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{} - for _, n := range s.Notes { - add(&fa.Agents, ag, n.Agent.Name) - add(&fa.Models, mo, n.Model) - add(&fa.Sessions, se, n.Session) - add(&fa.Kinds, ki, n.Kind) - add(&fa.Changes, ch, n.Change) - add(&fa.Files, fi, n.File) - } - for _, s := range [][]string{fa.Agents, fa.Models, fa.Sessions, fa.Kinds, fa.Changes, fa.Files} { - sort.Strings(s) - } - return fa -} - -// nextNoteID mirrors vr's numbering: ids count up from 1 within a session. -func nextNoteID(entries []Entry, session string) int { - max := 0 - for _, e := range entries { - if e.Op == "note" && e.Session == session && e.NoteID > max { - max = e.NoteID - } - } - return max + 1 -} - -// ── live activity ───────────────────────────────────────────────────────── - -// A Session is one agent run seen in the log: who it is, what it last did, -// and whether it is still going. This is what "live" shows. -type Session struct { - ID, Agent, Short, Model, Reason, Dir string - Bit uint8 - Last time.Time - Ago, LastOp, File string - Change string - Reads, Greps, Notes int - Live bool -} - -// Sessions groups the log by session (falling back to agent+dir for logs -// without one), newest activity first. Sessions touched within window are -// reported as live. -func (s *Site) Sessions(now time.Time, window time.Duration, limit int) []*Session { - byKey := map[string]*Session{} - var order []*Session - byName := map[string]*Agent{} - for _, a := range s.Agents { - byName[a.Name] = a - } - for _, e := range s.Entries { - name := e.Agent - if name == "" { - name = "unknown" - } - key := e.Session - if key == "" { - key = name + "\x00" + e.Dir - } - v := byKey[key] - if v == nil { - a := byName[name] - v = &Session{ID: e.Session, Agent: name, Dir: e.Dir} - if a != nil { - v.Short, v.Bit = a.Short, a.Bit - } - byKey[key] = v - order = append(order, v) - } - if e.Model != "" { - v.Model, v.Reason = e.Model, e.Reason - } - if e.Dir != "" { - v.Dir = e.Dir - } - if e.Change != "" { - v.Change = e.Change - } - switch e.Op { - case "read": - v.Reads++ - case "grep": - v.Greps++ - case "note": - v.Notes++ - } - if at := parseTime(e.Time); !at.IsZero() && at.After(v.Last) { - v.Last, v.File, v.LastOp = at, e.File, describe(e) - } - } - for _, v := range order { - v.Ago = agoFrom(now, v.Last) - v.Live = !v.Last.IsZero() && now.Sub(v.Last) < window - } - sort.SliceStable(order, func(i, j int) bool { return order[i].Last.After(order[j].Last) }) - if limit > 0 && len(order) > limit { - order = order[:limit] - } - return order -} - -func describe(e Entry) string { - switch e.Op { - case "read": - if e.Start > 0 { - if e.End > e.Start { - return fmt.Sprintf("read %s:%d-%d", e.File, e.Start, e.End) - } - return fmt.Sprintf("read %s:%d", e.File, e.Start) - } - return "read " + e.File - case "grep": - if e.File != "" { - return fmt.Sprintf("grep %q in %s (%d)", e.Pattern, e.File, e.Matches) - } - return fmt.Sprintf("grep %q (%d)", e.Pattern, e.Matches) - case "note": - if e.File != "" { - return "note on " + e.File - } - return "note" - } - return e.Op -} - -// ── sidecar JSONL ───────────────────────────────────────────────────────── - -func parseLog(b []byte) []Entry { - 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 readLog(log logStore) []Entry { - b, err := log.Read() - if err != nil { - fatal("%v", err) - } - return parseLog(b) -} - -func appendEntry(log logStore, e *Entry) error { - return log.Append(func(current []byte) ([]byte, error) { - e.NoteID = nextNoteID(parseLog(current), e.Session) - b, err := json.Marshal(e) - return append(b, '\n'), err - }) -} - -// ── time ────────────────────────────────────────────────────────────────── - -func parseTime(s string) time.Time { - if s == "" { - return time.Time{} - } - t, err := time.Parse(time.RFC3339, s) - if err != nil { - return time.Time{} - } - return t -} - -func stamp(t time.Time) string { - if t.IsZero() { - return "" - } - return t.Local().Format("2006-01-02 15:04") -} - -func ago(t time.Time) string { return agoFrom(time.Now(), t) } - -func agoFrom(now, t time.Time) string { - if t.IsZero() { - return "" - } - d := now.Sub(t) - switch { - case d < 0: - return "just now" - case d < time.Minute: - return fmt.Sprintf("%ds ago", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - default: - return fmt.Sprintf("%dd ago", int(d.Hours())/24) - } -} |
