// 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< 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) } }