diff options
Diffstat (limited to 'web/model.go')
| -rw-r--r-- | web/model.go | 621 |
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) + } +} |
