summaryrefslogtreecommitdiff
path: root/vrsite/model.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/model.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-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.go621
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)
- }
-}