package beads import ( "context" "fmt" "net/url" "regexp" "sort" "strings" "time" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" ) // --- the memories bd remember writes ------------------------------------------ // memoryTable is where bd keeps them: the beads config table, as ordinary // key/value rows. memoryPrefix marks the keys that are memories; the rest of the // table is the tracker's settings (issue_prefix, compact_tier2_days, …). const ( memoryTable = "config" memoryPrefix = "kv.memory." ) // memoryWalkMax bounds the revision walk (see walkMemories): at most this many // commits back from the head of the ref. The most active tracker on this // instance had 225 commits three weeks in, so this is roughly two months of // headroom at that rate. A memory not attributed inside the walk carries no date // at all rather than one the walk cannot support. const memoryWalkMax = 500 // MemoryStaleAfter is how old a memory has to be before the page questions it. // It is one constant and not a per-request knob, and what it produces is a // question ("stale?") rather than a verdict: some memories are meant to be // permanent, and only the reader knows which. const MemoryStaleAfter = 60 * 24 * time.Hour // MemorySession is the seam the memory projection reads through. It is wider // than BrowseSession because a memory has no timestamp — the config row is // (key, value) and nothing else — so the date has to come out of the history: // Log for the commits and TableHash to skip the ones that did not touch config. // It is still only what this projection calls, and web's BrowseSession and // mcpsrv's (and *browse.DB itself) satisfy it structurally. type MemorySession interface { BrowseSession // Log lists commits from the head of refStr, newest first. Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) // TableHash is the content hash of a table at a ref, ok=false when the table // does not exist there. It reads no rows, which is what makes the walk cheap. TableHash(ctx context.Context, refStr, table string) (string, bool, error) } // MemoryRevision is when a memory's value last changed: the commit that wrote // it, taken from the history rather than from the row. type MemoryRevision struct { Commit string Date time.Time Author string } // Memory is one `bd remember` entry. type Memory struct { Slug string // the config key with the "kv.memory." prefix stripped Text string // the value, with both newline spellings normalised // Paragraphs is Text split on blank lines. Values are typed into shell // strings, so they arrive with a mix of real newlines and literal "\n" // escapes; a page that dumped the raw value would render either as one blob. Paragraphs []string // Revision is the commit that last changed this key's value, or nil when the // walk could not reach it (see MemoryView.WalkTruncated). Revision *MemoryRevision // Stale reports that the memory is older than MemoryStaleAfter. For a memory // with no Revision it is set only when the walk's own oldest commit is // already past the threshold — that much is known even without a date. Stale bool } // MemoryView is the opaque .Data handed to memory.html. type MemoryView struct { Memories []Memory Total int // memories in the tracker, before ?q= / ?key= narrowing Search string // ?q=, sticky form state Key string // ?key=, a single memory Sort string // MemorySortSlug (default) | MemorySortAge // Query is the request's query as parsed, carried so the sort toggle can // rebuild this exact URL with one key replaced (web's withQuery) instead of // re-listing the parameters it happens to know about. Query url.Values // WalkTruncated says the revision walk stopped at WalkMax commits with the // history still going. It is the only way a Memory can carry no Revision, and // it is what the page says instead of a date. WalkTruncated bool WalkMax int // memoryWalkMax, so the page can name the number it hit } // Memory sort orders. Slug is the default; Age is the review queue. const ( MemorySortSlug = "slug" MemorySortAge = "age" ) // AppliesMemories fingerprints a tracker that can carry memories: the beads // fingerprint plus a config table with key and value columns. Like every // Applies, it sees table shapes and never rows, so a tracker whose config holds // no memory at all still gets the tab and renders an empty state. func AppliesMemories(tables []browse.TableInfo) bool { if !Applies(tables) { return false } for _, t := range tables { if t.Name != memoryTable { continue } var haveKey, haveValue bool for _, c := range t.Columns { switch c.Name { case "key": haveKey = true case "value": haveValue = true } } return haveKey && haveValue } return false } // BuildMemories reads the memories at ref and dates each one from the history. // now is the clock staleness is measured against, passed in rather than read // here: this package renders nothing and reads no hidden clock, and a caller // that pins its own clock (the web view, its tests) gets a deterministic answer. // // A missing config table degrades to no memories — the same treatment the other // optional tables get — but a history that cannot be read is an error, not an // empty answer: the date is what this view is for, and a page that silently // dropped every date would look exactly like a tracker whose memories are all // older than the walk. func BuildMemories(ctx context.Context, sess MemorySession, ref string, query url.Values, now time.Time) (*MemoryView, error) { view := &MemoryView{ Search: strings.TrimSpace(query.Get("q")), Key: strings.TrimSpace(query.Get("key")), Sort: parseMemorySort(query.Get("sort")), Query: query, WalkMax: memoryWalkMax, } rows, _, err := readRowsOptional(ctx, sess, ref, memoryTable) if err != nil { return nil, err } if rows == nil { return view, nil } // The whole config table, narrowed to the memory keys. raw keeps the stored // value under its full key: the walk compares values as they are stored, and // normalising first would make two spellings of the same text look equal. cols := indexCols(rows.Columns) raw := map[string]string{} texts := map[string]string{} for _, r := range rows.Rows { key := cell(cols, r, "key") if !strings.HasPrefix(key, memoryPrefix) { continue } slug := strings.TrimPrefix(key, memoryPrefix) if slug == "" { continue } raw[key] = cell(cols, r, "value") texts[key] = normalizeMemoryText(raw[key]) } view.Total = len(raw) // Narrow before walking: the walk is per-key work, and a ?key= page has no // business dating the other eight memories. tracked := map[string]string{} for key, value := range raw { slug := strings.TrimPrefix(key, memoryPrefix) if view.Key != "" && slug != view.Key { continue } if !matchesMemorySearch(view.Search, slug, texts[key]) { continue } tracked[key] = value } if len(tracked) == 0 { return view, nil } walk, err := walkMemories(ctx, sess, ref, tracked) if err != nil { return nil, err } view.WalkTruncated = walk.truncated view.Memories = make([]Memory, 0, len(tracked)) for key := range tracked { m := Memory{ Slug: strings.TrimPrefix(key, memoryPrefix), Text: texts[key], Paragraphs: memoryParagraphs(texts[key]), } if rev, ok := walk.revisions[key]; ok { m.Revision = &rev m.Stale = now.Sub(rev.Date) > MemoryStaleAfter } else if !walk.oldest.IsZero() { // No date, but a floor: the memory is at least as old as the oldest // commit the walk examined. When that alone is past the threshold the // question is supportable; otherwise it is not asked. m.Stale = now.Sub(walk.oldest) > MemoryStaleAfter } view.Memories = append(view.Memories, m) } sortMemories(view.Memories, view.Sort) return view, nil } // memoryWalk is what walkMemories learned: the commit each key was last written // by, whether the walk ran out of budget before the history ran out, and the // date of the oldest commit it examined. type memoryWalk struct { revisions map[string]MemoryRevision truncated bool oldest time.Time } // walkMemories attributes each tracked key to the commit that last changed its // value, walking the history of ref newest to oldest, at most memoryWalkMax // commits. // // The trick that makes it affordable is the table hash. At each commit the // content hash of config is O(1) and reads no rows; when it equals the hash at // the newer neighbour, config is byte-identical across that step and the newer // commit wrote no memory — skip, read nothing. Only a commit that actually // touched config costs a row read, and then each still-unresolved key whose // value differs from the newer neighbour's was written *by that newer commit*. // // The commit message is not the signal. `bd remember` does write // "bd: remember (auto-commit) by ", but that is a claim by whoever wrote // it; the table hash is the fact. // // A key still unresolved when the history itself runs out was present with this // value at the root commit, so the root is what wrote it — that is a date the // walk supports. A key unresolved because the walk hit its budget gets no date // at all, and truncated says so. // // The walk reads the log's linearization (Log is reverse-topological). Beads // histories are linear chains of auto-commits, which this is exact for; across a // merge, attribution is to the nearest commit in that order. func walkMemories(ctx context.Context, sess MemorySession, ref string, tracked map[string]string) (memoryWalk, error) { out := memoryWalk{revisions: map[string]MemoryRevision{}} commits, next, err := sess.Log(ctx, ref, "", memoryWalkMax) if err != nil { return memoryWalk{}, fmt.Errorf("beads: read history of %q: %w", ref, err) } if len(commits) == 0 { return out, nil } out.oldest = commits[len(commits)-1].Date out.truncated = next != "" // unresolved carries each key's value at the newer neighbour of the commit // being examined; it starts as the value at the head, which is where the // memories themselves were read. unresolved := make(map[string]string, len(tracked)) for k, v := range tracked { unresolved[k] = v } newerHash, err := memoryTableHash(ctx, sess, commits[0].Hash) if err != nil { return memoryWalk{}, err } for i := 1; i < len(commits) && len(unresolved) > 0; i++ { hash, err := memoryTableHash(ctx, sess, commits[i].Hash) if err != nil { return memoryWalk{}, err } if hash == newerHash { continue // config unchanged across this step: nothing to read } older, err := memoryValuesAt(ctx, sess, commits[i].Hash) if err != nil { return memoryWalk{}, err } newer := commits[i-1] for key, newerValue := range unresolved { if older[key] == newerValue { continue } out.revisions[key] = MemoryRevision{ Commit: newer.Hash, Date: newer.Date, Author: newer.Author, } delete(unresolved, key) } for key := range unresolved { unresolved[key] = older[key] } newerHash = hash } if !out.truncated { // The history ended with these keys never changing: the oldest commit // walked is the root, and it carries the value we are looking at. root := commits[len(commits)-1] for key := range unresolved { out.revisions[key] = MemoryRevision{Commit: root.Hash, Date: root.Date, Author: root.Author} } } return out, nil } // memoryTableHash is the config table's content hash at one commit. A table that // does not exist there is "" — an ordinary answer while walking backwards past // the commit that created it, and one that compares correctly against another // commit where it is equally absent. func memoryTableHash(ctx context.Context, sess MemorySession, at string) (string, error) { hash, ok, err := sess.TableHash(ctx, at, memoryTable) if err != nil { return "", fmt.Errorf("beads: hash of %s at %s: %w", memoryTable, at, err) } if !ok { return "", nil } return hash, nil } // memoryValuesAt reads the config table at one commit as key → value. A missing // table is an empty map, which is what it means here: no key had a value yet. func memoryValuesAt(ctx context.Context, sess MemorySession, at string) (map[string]string, error) { rows, _, err := readRowsOptional(ctx, sess, at, memoryTable) if err != nil { return nil, err } out := map[string]string{} if rows == nil { return out, nil } cols := indexCols(rows.Columns) for _, r := range rows.Rows { if key := cell(cols, r, "key"); key != "" { out[key] = cell(cols, r, "value") } } return out, nil } // memoryEscapedNewline matches the two-character escapes that reach the value // because the memory was typed into a shell string: "\r\n" and "\n" written out // as backslash sequences rather than as newlines. var memoryEscapedNewline = regexp.MustCompile(`\\r\\n|\\n`) // memoryBlankLine splits a memory into paragraphs on runs of blank lines. var memoryBlankLine = regexp.MustCompile(`\n[ \t]*\n[ \t\n]*`) // normalizeMemoryText brings a stored value's two newline spellings together. // The same memory arrives with real newlines when it was written from a file or // a heredoc and with literal backslash-n when it was typed into a shell string, // and both spellings turn up in the same tracker. func normalizeMemoryText(v string) string { v = strings.ReplaceAll(v, "\r\n", "\n") v = strings.ReplaceAll(v, "\r", "\n") v = memoryEscapedNewline.ReplaceAllString(v, "\n") return strings.TrimSpace(v) } // memoryParagraphs splits normalised text on blank lines. Line breaks *inside* a // paragraph are kept — memories are full of bullet lists, and joining those into // a sentence is not rendering, it is losing the structure the author typed. func memoryParagraphs(text string) []string { if text == "" { return nil } parts := memoryBlankLine.Split(text, -1) out := make([]string, 0, len(parts)) for _, p := range parts { if strings.TrimSpace(p) == "" { continue } out = append(out, strings.Trim(p, "\n")) } return out } // matchesMemorySearch is the ?q= rule: a case-insensitive substring of the slug // or of the text. An empty query matches everything. func matchesMemorySearch(q, slug, text string) bool { if q == "" { return true } q = strings.ToLower(q) return strings.Contains(strings.ToLower(slug), q) || strings.Contains(strings.ToLower(text), q) } // parseMemorySort reads ?sort=, defaulting (and falling back from an unknown // value) to slug order. func parseMemorySort(v string) string { if strings.ToLower(strings.TrimSpace(v)) == MemorySortAge { return MemorySortAge } return MemorySortSlug } // sortMemories orders the list: by slug, or oldest first for the review queue. // In age order a memory with no revision leads — it is older than anything the // walk could date — and ties fall back to the slug, so the order is total. func sortMemories(ms []Memory, order string) { sort.SliceStable(ms, func(i, j int) bool { a, b := ms[i], ms[j] if order == MemorySortAge { switch { case a.Revision == nil && b.Revision != nil: return true case a.Revision != nil && b.Revision == nil: return false case a.Revision != nil && b.Revision != nil && !a.Revision.Date.Equal(b.Revision.Date): return a.Revision.Date.Before(b.Revision.Date) } } return a.Slug < b.Slug }) }