summaryrefslogtreecommitdiff
path: root/web/model.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/model.go')
-rw-r--r--web/model.go621
1 files changed, 621 insertions, 0 deletions
diff --git a/web/model.go b/web/model.go
new file mode 100644
index 0000000..5e90ee1
--- /dev/null
+++ b/web/model.go
@@ -0,0 +1,621 @@
+// model.go — the log projected into a browsable model.
+//
+// Everything the site shows is derived here from the notevi log alone: agents and
+// their colors, per-file coverage bitmasks, notes, and live session activity.
+// The private jj sidecar is the only state.
+package web
+
+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 notevi'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)
+ }
+}