A beads/memory.go => beads/memory.go +431 -0
@@ 0,0 1,431 @@
+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=<slug>, 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 <author>", 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
+ })
+}
A beads/memory_test.go => beads/memory_test.go +404 -0
@@ 0,0 1,404 @@
+package beads
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+)
+
+// --- fixture -----------------------------------------------------------------
+
+// memoryNow is the instant the memory tests measure staleness against. Nothing
+// about it is special beyond being fixed: staleness is a function of the
+// fixture's dates and this clock, never of when the suite ran.
+var memoryNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
+
+// fakeHistory is a MemorySession over canned per-ref content: the config table
+// at each commit, that table's content hash there, and a commit log. It records
+// every ref a row read was issued at, which is how the walk's central claim —
+// that a commit whose table hash is unchanged is skipped without reading a
+// single row — is asserted rather than assumed.
+type fakeHistory struct {
+ commits []browse.CommitInfo // newest first, as Log returns them
+ config map[string]map[string]string // ref → key → value; a missing ref has no config table
+ hashes map[string]string // ref → config's content hash; "" = table absent
+
+ reads []string // refs Rows was called at, in order
+ hashCalls int
+ logCalls int
+}
+
+func (f *fakeHistory) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) {
+ f.reads = append(f.reads, ref)
+ rows, ok := f.config[ref]
+ if !ok || table != "config" {
+ return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
+ }
+ page := &browse.RowPage{Columns: []string{"key", "value"}}
+ keys := make([]string, 0, len(rows))
+ for k := range rows {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys) // a store returns rows in key order; so does this
+ for _, k := range keys {
+ page.Rows = append(page.Rows, []string{k, rows[k]})
+ }
+ page.Total = len(page.Rows)
+ return page, nil
+}
+
+func (f *fakeHistory) Log(_ context.Context, _, _ string, limit int) ([]browse.CommitInfo, string, error) {
+ f.logCalls++
+ if limit < len(f.commits) {
+ return f.commits[:limit], f.commits[limit].Hash, nil
+ }
+ return f.commits, "", nil
+}
+
+func (f *fakeHistory) TableHash(_ context.Context, ref, table string) (string, bool, error) {
+ f.hashCalls++
+ if table != "config" {
+ return "", false, nil
+ }
+ h, ok := f.hashes[ref]
+ if !ok || h == "" {
+ return "", false, nil
+ }
+ return h, true, nil
+}
+
+// memoryHistory is the fixture the walk is asserted against: five commits, and a
+// config table that changes at only two of them.
+//
+// c4 (3 hours ago) alpha=v2 beta=b gamma=g hash h-c ← head
+// c3 (2 days ago) alpha=v2 beta=b gamma=g hash h-c wrote alpha
+// c2 (71 days ago) alpha=v1 beta=b gamma=g hash h-b
+// c1 (99 days ago) alpha=v1 beta=b gamma=g hash h-b wrote beta
+// c0 (200 days ago) gamma=g hash h-a the root
+//
+// So alpha resolves to c3, beta to c1, and gamma — never changed anywhere in the
+// history — to the root commit that introduced it.
+func memoryHistory() *fakeHistory {
+ at := func(d time.Duration) time.Time { return memoryNow.Add(-d) }
+ head := map[string]string{
+ "kv.memory.alpha": "second version",
+ "kv.memory.beta": "beta text",
+ "kv.memory.gamma": "gamma text",
+ "issue_prefix": "demo", // not a memory: the prefix filter drops it
+ }
+ older := map[string]string{
+ "kv.memory.alpha": "first version",
+ "kv.memory.beta": "beta text",
+ "kv.memory.gamma": "gamma text",
+ "issue_prefix": "demo",
+ }
+ root := map[string]string{
+ "kv.memory.gamma": "gamma text",
+ "issue_prefix": "demo",
+ }
+ return &fakeHistory{
+ commits: []browse.CommitInfo{
+ {Hash: "c4", Author: "bigbes", Date: at(3 * time.Hour)},
+ {Hash: "c3", Author: "bigbes", Date: at(2 * 24 * time.Hour)},
+ {Hash: "c2", Author: "bigbes", Date: at(71 * 24 * time.Hour)},
+ {Hash: "c1", Author: "alice", Date: at(99 * 24 * time.Hour)},
+ {Hash: "c0", Author: "alice", Date: at(200 * 24 * time.Hour)},
+ },
+ config: map[string]map[string]string{
+ "main": head, "c4": head, "c3": head,
+ "c2": older, "c1": older,
+ "c0": root,
+ },
+ hashes: map[string]string{
+ "main": "h-c", "c4": "h-c", "c3": "h-c",
+ "c2": "h-b", "c1": "h-b",
+ "c0": "h-a",
+ },
+ }
+}
+
+func buildMemories(t *testing.T, sess MemorySession, query string) *MemoryView {
+ t.Helper()
+ q, err := url.ParseQuery(query)
+ require.NoError(t, err)
+ v, err := BuildMemories(context.Background(), sess, "main", q, memoryNow)
+ require.NoError(t, err)
+ return v
+}
+
+func slugsOf(v *MemoryView) []string {
+ out := make([]string, 0, len(v.Memories))
+ for _, m := range v.Memories {
+ out = append(out, m.Slug)
+ }
+ return out
+}
+
+// --- the projection ----------------------------------------------------------
+
+// Only the kv.memory.* rows are memories, and the slug is the key without that
+// prefix. The rest of config is the tracker's settings and must not appear.
+func TestMemoriesPrefixFiltering(t *testing.T) {
+ v := buildMemories(t, memoryHistory(), "")
+
+ assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(v))
+ assert.Equal(t, 3, v.Total, "issue_prefix is not a memory")
+ assert.Equal(t, MemorySortSlug, v.Sort)
+ assert.False(t, v.WalkTruncated)
+}
+
+// A value carries both newline spellings — real ones when it was written from a
+// file, literal backslash-n when it was typed into a shell string — and both
+// have to become the same paragraphs.
+func TestMemoriesNormaliseBothNewlineSpellings(t *testing.T) {
+ const escaped = `First paragraph.\n\nSecond paragraph, line one.\nLine two.`
+ real := "First paragraph.\n\nSecond paragraph, line one.\nLine two."
+ crlf := "First paragraph.\r\n\r\nSecond paragraph, line one.\r\nLine two."
+
+ sess := memoryHistory()
+ for _, ref := range []string{"main", "c4", "c3"} {
+ sess.config[ref] = map[string]string{
+ "kv.memory.escaped": escaped,
+ "kv.memory.real": real,
+ "kv.memory.crlf": crlf,
+ }
+ }
+ v := buildMemories(t, sess, "")
+ require.Len(t, v.Memories, 3)
+
+ want := []string{"First paragraph.", "Second paragraph, line one.\nLine two."}
+ for _, m := range v.Memories {
+ assert.Equal(t, want, m.Paragraphs, "memory %q", m.Slug)
+ assert.Equal(t, "First paragraph.\n\nSecond paragraph, line one.\nLine two.", m.Text,
+ "memory %q", m.Slug)
+ assert.NotContains(t, m.Text, `\n`, "the literal escape must not survive into the text")
+ }
+}
+
+// ?q= is a substring of the slug or of the text, case-insensitively.
+func TestMemoriesSearchFilter(t *testing.T) {
+ bySlug := buildMemories(t, memoryHistory(), "q=ALPH")
+ assert.Equal(t, []string{"alpha"}, slugsOf(bySlug))
+ assert.Equal(t, 3, bySlug.Total, "Total counts the tracker, not the page")
+ assert.Equal(t, "ALPH", bySlug.Search)
+
+ byText := buildMemories(t, memoryHistory(), "q=second+version")
+ assert.Equal(t, []string{"alpha"}, slugsOf(byText))
+
+ none := buildMemories(t, memoryHistory(), "q=nothing+matches+this")
+ assert.Empty(t, none.Memories)
+ assert.Equal(t, 3, none.Total)
+}
+
+// ?key= renders one memory; an unknown slug renders none rather than everything.
+func TestMemoriesSingleKey(t *testing.T) {
+ one := buildMemories(t, memoryHistory(), "key=beta")
+ require.Len(t, one.Memories, 1)
+ assert.Equal(t, "beta", one.Memories[0].Slug)
+ assert.Equal(t, "beta", one.Key)
+
+ missing := buildMemories(t, memoryHistory(), "key=nosuch")
+ assert.Empty(t, missing.Memories)
+ assert.Equal(t, 3, missing.Total)
+}
+
+// Slug order is the default; age order is oldest first, which is the review
+// queue. An unknown ?sort= value falls back to slug rather than to nothing.
+func TestMemoriesSortOrders(t *testing.T) {
+ assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "")))
+ assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=slug")))
+ assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=sideways")))
+
+ // gamma (root, 200 days) → beta (c1, 99 days) → alpha (c3, 2 days).
+ byAge := buildMemories(t, memoryHistory(), "sort=age")
+ assert.Equal(t, []string{"gamma", "beta", "alpha"}, slugsOf(byAge))
+ assert.Equal(t, MemorySortAge, byAge.Sort)
+}
+
+// A tracker with no memories at all — and one with no config table — is an empty
+// view, not an error: the tab exists wherever the beads fingerprint does.
+func TestMemoriesEmptyTracker(t *testing.T) {
+ noMemories := memoryHistory()
+ for ref := range noMemories.config {
+ noMemories.config[ref] = map[string]string{"issue_prefix": "demo"}
+ }
+ v := buildMemories(t, noMemories, "")
+ assert.Empty(t, v.Memories)
+ assert.Equal(t, 0, v.Total)
+ assert.Empty(t, noMemories.reads[1:], "with nothing to date, the walk must not run")
+
+ noTable := memoryHistory()
+ noTable.config = map[string]map[string]string{}
+ empty := buildMemories(t, noTable, "")
+ assert.Empty(t, empty.Memories)
+ assert.Equal(t, 0, empty.Total)
+}
+
+// AppliesMemories is the beads fingerprint plus a config table carrying key and
+// value. It sees shapes and never rows, so an empty config still gets the tab.
+func TestAppliesMemories(t *testing.T) {
+ configTable := browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{
+ {Name: "key", PrimaryKey: true}, {Name: "value"},
+ }}
+ full := append(beadsTables(), configTable)
+
+ assert.True(t, AppliesMemories(full))
+ assert.False(t, AppliesMemories(beadsTables()), "no config table")
+ assert.False(t, AppliesMemories([]browse.TableInfo{configTable}), "config without the beads fingerprint")
+ assert.False(t, AppliesMemories(append(beadsTables(),
+ browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{{Name: "key"}}})),
+ "a config table without a value column is somebody else's config")
+}
+
+// --- the revision walk -------------------------------------------------------
+
+// The walk attributes a memory to the commit that changed its value, skips the
+// commits that did not touch config without reading a row, and — when the
+// history itself runs out — attributes what never changed to the root commit.
+func TestMemoryRevisionWalk(t *testing.T) {
+ sess := memoryHistory()
+ v := buildMemories(t, sess, "")
+ require.Len(t, v.Memories, 3)
+
+ byslug := map[string]Memory{}
+ for _, m := range v.Memories {
+ byslug[m.Slug] = m
+ }
+
+ // alpha changed between c2 and c3, so c3 wrote it — not c4, which merely has
+ // the same value, and not c2, which has the older one.
+ alpha := byslug["alpha"]
+ require.NotNil(t, alpha.Revision)
+ assert.Equal(t, "c3", alpha.Revision.Commit)
+ assert.Equal(t, memoryNow.Add(-2*24*time.Hour), alpha.Revision.Date)
+ assert.Equal(t, "bigbes", alpha.Revision.Author)
+ assert.False(t, alpha.Stale, "two days old")
+
+ // beta appeared at c1 (absent at the root) and was left alone since.
+ beta := byslug["beta"]
+ require.NotNil(t, beta.Revision)
+ assert.Equal(t, "c1", beta.Revision.Commit)
+ assert.Equal(t, "alice", beta.Revision.Author)
+ assert.True(t, beta.Stale, "99 days old")
+
+ // gamma never changed anywhere in the history: the root is what wrote it.
+ gamma := byslug["gamma"]
+ require.NotNil(t, gamma.Revision)
+ assert.Equal(t, "c0", gamma.Revision.Commit)
+ assert.True(t, gamma.Stale, "200 days old")
+ assert.False(t, v.WalkTruncated, "the whole history fits inside the walk")
+
+ // The cost claim: rows were read at the head and at the two commits whose
+ // config hash differs from their newer neighbour's. c3 and c1 are byte-equal
+ // to the commit above them and were skipped without a read; c4 is the head
+ // itself, already read once.
+ assert.Equal(t, []string{"main", "c2", "c0"}, sess.reads)
+ assert.Equal(t, 5, sess.hashCalls, "one O(1) table hash per commit")
+ assert.Equal(t, 1, sess.logCalls, "one log call for the whole walk")
+}
+
+// A key that never changes inside the walk's budget gets no date at all rather
+// than a date the walk cannot support — and the view says the walk was cut off,
+// which is the only way a memory can carry no revision.
+func TestMemoryRevisionUnresolvedWithinWalk(t *testing.T) {
+ sess := &fakeHistory{
+ config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}},
+ hashes: map[string]string{"main": "h"},
+ }
+ // One commit more than the walk examines, none of which touched config.
+ for i := 0; i <= memoryWalkMax; i++ {
+ h := fmt.Sprintf("k%03d", i)
+ sess.commits = append(sess.commits, browse.CommitInfo{
+ Hash: h, Author: "bigbes", Date: memoryNow.Add(-time.Duration(i) * time.Hour),
+ })
+ sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"}
+ sess.hashes[h] = "h"
+ }
+
+ v := buildMemories(t, sess, "")
+ require.Len(t, v.Memories, 1)
+ assert.Nil(t, v.Memories[0].Revision, "no date the walk cannot support")
+ assert.True(t, v.WalkTruncated)
+ assert.Equal(t, memoryWalkMax, v.WalkMax)
+ // Still older than the threshold? The walk's own oldest commit is 500 hours
+ // back, which is short of 60 days, so the question is not asked either.
+ assert.False(t, v.Memories[0].Stale)
+
+ assert.Equal(t, []string{"main"}, sess.reads,
+ "500 commits that did not touch config must cost no row read at all")
+ assert.Equal(t, memoryWalkMax, sess.hashCalls)
+}
+
+// The undated arm of staleness: with no revision, the walk's oldest commit is a
+// floor on the memory's age, and a floor already past the threshold supports the
+// question.
+func TestMemoryStaleWithoutARevision(t *testing.T) {
+ sess := &fakeHistory{
+ config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}},
+ hashes: map[string]string{"main": "h"},
+ }
+ for i := 0; i <= memoryWalkMax; i++ {
+ h := fmt.Sprintf("k%03d", i)
+ sess.commits = append(sess.commits, browse.CommitInfo{
+ Hash: h, Date: memoryNow.Add(-time.Duration(i) * 24 * time.Hour),
+ })
+ sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"}
+ sess.hashes[h] = "h"
+ }
+
+ v := buildMemories(t, sess, "")
+ require.Len(t, v.Memories, 1)
+ assert.Nil(t, v.Memories[0].Revision)
+ assert.True(t, v.Memories[0].Stale, "the oldest commit walked is 499 days back")
+}
+
+// A commit that created the config table is attributed correctly: walking past
+// it, the table is simply absent, and absent is an ordinary answer rather than
+// an error.
+func TestMemoryWalkPastTheTablesCreation(t *testing.T) {
+ sess := &fakeHistory{
+ commits: []browse.CommitInfo{
+ {Hash: "b2", Author: "bigbes", Date: memoryNow.Add(-time.Hour)},
+ {Hash: "b1", Author: "bigbes", Date: memoryNow.Add(-48 * time.Hour)},
+ {Hash: "b0", Author: "bigbes", Date: memoryNow.Add(-72 * time.Hour)},
+ },
+ config: map[string]map[string]string{
+ "main": {"kv.memory.first": "hello"},
+ "b2": {"kv.memory.first": "hello"},
+ "b1": {"kv.memory.first": "hello"},
+ // b0 predates the config table entirely: no entry at all.
+ },
+ hashes: map[string]string{"main": "h1", "b2": "h1", "b1": "h1"},
+ }
+
+ v := buildMemories(t, sess, "")
+ require.Len(t, v.Memories, 1)
+ require.NotNil(t, v.Memories[0].Revision)
+ assert.Equal(t, "b1", v.Memories[0].Revision.Commit,
+ "the commit that created config is the one that wrote the memory")
+ assert.False(t, v.WalkTruncated)
+}
+
+// A history that cannot be read is an error, not a page of memories with every
+// date quietly missing — that page is indistinguishable from a tracker whose
+// memories are all older than the walk.
+func TestMemoriesFailWhenTheHistoryCannotBeRead(t *testing.T) {
+ sess := &failingLog{fakeHistory: memoryHistory()}
+ _, err := BuildMemories(context.Background(), sess, "main", url.Values{}, memoryNow)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "read history")
+}
+
+type failingLog struct{ *fakeHistory }
+
+func (*failingLog) Log(context.Context, string, string, int) ([]browse.CommitInfo, string, error) {
+ return nil, "", fmt.Errorf("browse: walk commits: corrupt chunk")
+}
M web/deps.go => web/deps.go +5 -0
@@ 122,6 122,11 @@ type BrowseSession interface {
Branches(ctx context.Context) ([]browse.Branch, error)
Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error)
Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error)
+ // TableHash is the content hash of one table at a ref, ok=false when the
+ // table does not exist there. It reads no rows, and only the Memory view
+ // asks for it: its revision walk uses the hash to skip every commit that did
+ // not touch config, so a board never pays for a history walk it does not do.
+ TableHash(ctx context.Context, refStr, table string) (string, bool, error)
Rows(ctx context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error)
CommitSummary(ctx context.Context, hashStr string) (*browse.CommitDiff, error)
Close() error
A web/memory.go => web/memory.go +62 -0
@@ 0,0 1,62 @@
+package web
+
+import (
+ "context"
+ "net/url"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// memoryView renders the memories `bd remember` writes: config rows keyed
+// kv.memory.<slug>, each with the revision its value was last written at. The
+// row itself is (key, value) and carries no timestamp, so the date comes out of
+// the history — see beads.BuildMemories for the walk that finds it.
+//
+// The reading is not here. It lives in the beads package, which the MCP
+// surface's list_memories shares; this type is only the View adapter — slug,
+// label, template, and the hand-off of the request's ref and query.
+type memoryView struct{}
+
+// init registers the Memory view, and registers it *after* Milestones, which is
+// where the design puts the tab: registration order is tab order (views.go).
+//
+// Getting that order is not free here, and the extra line is the whole reason
+// this comment exists. A package's init functions run in lexical file-name
+// order, and "memory.go" sorts before "milestones.go" — so registering only
+// memoryView would put this tab between Beads and Milestones. Registering
+// milestonesView first claims the slot ahead of it; milestones.go's own init
+// then re-registers the same view, which RegisterView resolves in place (same
+// Name), leaving the order alone. Both calls name real views, so a rename or a
+// removal on either side is a compile error rather than a silently reordered
+// tab bar.
+func init() {
+ RegisterView(&milestonesView{})
+ RegisterView(&memoryView{})
+}
+
+func (*memoryView) Name() string { return "memory" }
+func (*memoryView) Label() string { return "Memory" }
+func (*memoryView) Template() string { return "memory.html" }
+
+// Applies is the beads fingerprint plus a config table carrying key and value;
+// see beads.AppliesMemories. Applies sees table shapes and never rows, so a
+// tracker that has never had a memory written into it still gets the tab and
+// renders an empty state — the same contract Milestones has.
+func (*memoryView) Applies(tables []browse.TableInfo) bool { return beads.AppliesMemories(tables) }
+
+// Build reads the memories and dates them from the history. The result is a
+// *beads.MemoryView, handed to memory.html as its .Data.
+//
+// The clock is passed in rather than read inside the projection: staleness is
+// the one thing on this page that depends on when it was rendered, and timeNow
+// is the same swappable clock the freshness line uses, so a test pins both at
+// once.
+func (*memoryView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
+ data, err := beads.BuildMemories(ctx, sess, ref, query, timeNow())
+ if err != nil {
+ return nil, err
+ }
+ return data, nil
+}
A web/memory_test.go => web/memory_test.go +292 -0
@@ 0,0 1,292 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// The projection — the prefix filter, the newline spellings, ?q=, the sort
+// orders and the revision walk — is tested in the beads package. What is left
+// here is the tab, the page, and the one thing this view renders that no other
+// does: how old a memory is, in days.
+
+// memoryCommits are the three commits the fixture's history has. Only the head
+// and midHash ever touched config; rootHash predates the memories.
+const (
+ memoryMidHash = "9f8e7d6c5b4a3928374655647382910a"
+ memoryRootHash = "0a1b2c3d4e5f60718293a4b5c6d7e8f9"
+)
+
+// memoryFixture is a tracker with two memories, written at two different
+// commits, over a three-commit history:
+//
+// head (4 minutes ago) handoff + metered hash h2 wrote handoff
+// mid (midAge ago) metered hash h1 wrote metered
+// root (200 days ago) no memories hash h0
+//
+// midAge is a parameter because the stale? marker is a question about exactly
+// that number.
+func memoryFixture(midAge time.Duration) *fakeSession {
+ config := func(pairs ...[2]string) *browse.RowPage {
+ page := &browse.RowPage{Columns: []string{"key", "value"}}
+ for _, p := range pairs {
+ page.Rows = append(page.Rows, []string{p[0], p[1]})
+ }
+ page.Total = len(page.Rows)
+ return page
+ }
+ // The handoff memory carries the literal "\n" escape agents type into shell
+ // strings, and a fragment that must not reach the page as markup.
+ handoff := [2]string{"kv.memory.handoff-2026-08-12",
+ `State after the 2026-08-12 round.\n\nSupersedes the two 2026-08-10 handoff memories, which asserted <b>otherwise</b>.`}
+ metered := [2]string{"kv.memory.metered-link-calibration",
+ "Owner is frequently on metered mobile internet.\n\nCalibrate downloads accordingly."}
+ prefix := [2]string{"issue_prefix", "demo"}
+
+ return &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: headHash}},
+ tables: append(beadsTables(), memoryConfigTable()),
+ commits: []browse.CommitInfo{
+ {Hash: headHash, Author: "bigbes", Date: testNow.Add(-4 * time.Minute), Message: "bd: remember (auto-commit) by bigbes"},
+ {Hash: memoryMidHash, Author: "bigbes", Date: testNow.Add(-midAge)},
+ {Hash: memoryRootHash, Author: "alice", Date: testNow.Add(-200 * 24 * time.Hour)},
+ },
+ rowsByRef: map[string]map[string]*browse.RowPage{
+ "main": {"config": config(handoff, metered, prefix)},
+ headHash: {"config": config(handoff, metered, prefix)},
+ memoryMidHash: {"config": config(metered, prefix)},
+ memoryRootHash: {"config": config(prefix)},
+ },
+ tableHashes: map[string]string{
+ "main/config": "h2", headHash + "/config": "h2",
+ memoryMidHash + "/config": "h1", memoryRootHash + "/config": "h0",
+ },
+ }
+}
+
+// memoryConfigTable is the shape AppliesMemories looks for on top of the beads
+// fingerprint.
+func memoryConfigTable() browse.TableInfo {
+ return browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{
+ {Name: "key", PrimaryKey: true}, {Name: "value"},
+ }}
+}
+
+// The tab bar is registration order (views.go), and the design puts Memory after
+// Milestones. This asserts the registry the process actually builds — not a
+// hand-ordered slice a test injected — because init order is lexical file order
+// and "memory.go" sorts before "milestones.go".
+func TestMemoryTabFollowsMilestones(t *testing.T) {
+ pos := map[string]int{}
+ for i, v := range registeredViews {
+ pos[v.Name()] = i
+ }
+ require.Contains(t, pos, "memory")
+ require.Contains(t, pos, "milestones")
+ assert.Less(t, pos["milestones"], pos["memory"], "the Memory tab comes after Milestones")
+ assert.Less(t, pos["beads"], pos["milestones"], "and both after Beads")
+
+ // And the same order on the rendered page, through the real registry.
+ pinClock(t, testNow)
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = memoryFixture(71 * 24 * time.Hour)
+
+ rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
+ body := rec.Body.String()
+ iBeads := strings.Index(body, "/view/beads\"")
+ iMilestones := strings.Index(body, "/view/milestones\"")
+ iMemory := strings.Index(body, "/view/memory\"")
+ require.NotEqual(t, -1, iBeads)
+ require.NotEqual(t, -1, iMilestones)
+ require.NotEqual(t, -1, iMemory)
+ assert.Less(t, iBeads, iMilestones)
+ assert.Less(t, iMilestones, iMemory, "the Memory tab is rendered after Milestones")
+}
+
+func TestMemoryAppliesNeedsAConfigTable(t *testing.T) {
+ v := &memoryView{}
+ assert.True(t, v.Applies(append(beadsTables(), memoryConfigTable())))
+ assert.False(t, v.Applies(beadsTables()), "no config table, no Memory tab")
+ assert.False(t, v.Applies([]browse.TableInfo{memoryConfigTable()}), "config alone is not a beads DB")
+}
+
+func TestMemoryRenders(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = memoryFixture(71 * 24 * time.Hour)
+
+ rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
+ body := rec.Body.String()
+
+ // Both memories, by slug, with the prefix stripped and the tracker's own
+ // settings left where they belong.
+ assert.Contains(t, body, ">handoff-2026-08-12<")
+ assert.Contains(t, body, ">metered-link-calibration<")
+ assert.NotContains(t, body, "kv.memory.")
+ assert.NotContains(t, body, "issue_prefix")
+ assert.Contains(t, body, "2 entries")
+
+ // The revision of each, from the history: the head wrote the handoff, and the
+ // short hash links to the commit page.
+ assert.Contains(t, body, "written <span")
+ assert.Contains(t, body, "4 minutes ago")
+ assert.Contains(t, body, `href="/~alice/db/commit/`+headHash+`"`)
+ assert.Contains(t, body, "<code>qk9j2n4b</code>")
+ assert.Contains(t, body, "71 days ago", "the day-resolution spelling, not 2 months ago")
+ assert.NotContains(t, body, "months ago")
+ assert.Contains(t, body, `href="/~alice/db/commit/`+memoryMidHash+`"`)
+
+ // Paragraphs, from both newline spellings: the literal escape is a break and
+ // never reaches the page as backslash-n.
+ assert.Contains(t, body, "<p>State after the 2026-08-12 round.</p>")
+ assert.Contains(t, body, "<p>Owner is frequently on metered mobile internet.</p>")
+ assert.NotContains(t, body, `\n`)
+
+ // Stored text is text: it is escaped, never rendered as markup.
+ assert.NotContains(t, body, "<b>otherwise</b>")
+ assert.Contains(t, body, "<b>otherwise</b>")
+
+ // The freshness line the beads family shares.
+ assert.Contains(t, body, `class="beads-freshness"`)
+ assert.Contains(t, body, "main · last commit")
+}
+
+// The stale? marker is a question asked at exactly 60 days, and it is a constant
+// rather than a per-request knob: the only way to move it is to edit it.
+func TestMemoryStaleMarkerAtTheBoundary(t *testing.T) {
+ render := func(t *testing.T, midAge time.Duration) string {
+ t.Helper()
+ pinClock(t, testNow)
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = memoryFixture(midAge)
+ rec := h.do("GET", "/~alice/db/view/memory?key=metered-link-calibration", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
+ return rec.Body.String()
+ }
+
+ exactly := render(t, 60*24*time.Hour)
+ assert.Contains(t, exactly, "metered-link-calibration")
+ assert.NotContains(t, exactly, "stale?", "at the mark itself the question is not asked")
+
+ past := render(t, 60*24*time.Hour+time.Second)
+ assert.Contains(t, past, "stale?")
+ assert.Contains(t, past, "60 days ago")
+
+ fresh := render(t, 3*time.Hour)
+ assert.NotContains(t, fresh, "stale?")
+ assert.Contains(t, fresh, "3 hours ago")
+}
+
+// ?key= renders one memory and not the rest; an unknown slug says so rather than
+// falling back to the whole list.
+func TestMemorySingleKey(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = memoryFixture(71 * 24 * time.Hour)
+
+ one := h.do("GET", "/~alice/db/view/memory?key=handoff-2026-08-12", nil, nil)
+ require.Equal(t, http.StatusOK, one.Code)
+ assert.Contains(t, one.Body.String(), "State after the 2026-08-12 round.")
+ assert.NotContains(t, one.Body.String(), "metered mobile internet")
+
+ missing := h.do("GET", "/~alice/db/view/memory?key=nosuch", nil, nil)
+ require.Equal(t, http.StatusOK, missing.Code)
+ assert.Contains(t, missing.Body.String(), "No memory named")
+ assert.NotContains(t, missing.Body.String(), "State after the 2026-08-12 round.")
+}
+
+// A tracker whose config holds no memory still gets the tab (Applies sees shapes
+// and never rows) and renders an empty state that says what writes one.
+func TestMemoryEmptyState(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ sess := memoryFixture(71 * 24 * time.Hour)
+ sess.rowsByRef["main"] = map[string]*browse.RowPage{"config": {
+ Columns: []string{"key", "value"},
+ Rows: [][]string{{"issue_prefix", "demo"}},
+ Total: 1,
+ }}
+ h.browse.sess = sess
+
+ rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
+ body := rec.Body.String()
+ assert.Contains(t, body, "No memories.")
+ assert.Contains(t, body, "bd remember")
+ assert.Contains(t, body, "0 entries")
+ // The tab is there either way, and so is the freshness line.
+ assert.Contains(t, body, "/view/memory\"")
+ assert.Contains(t, body, `class="beads-freshness"`)
+}
+
+// The sort toggle is a link that keeps the rest of the query, and the review
+// queue is oldest first.
+func TestMemorySortToggle(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = memoryFixture(71 * 24 * time.Hour)
+
+ bySlug := h.do("GET", "/~alice/db/view/memory?ref=main", nil, nil)
+ require.Equal(t, http.StatusOK, bySlug.Code)
+ assert.Contains(t, bySlug.Body.String(), "/view/memory?ref=main&sort=age",
+ "the toggle keeps ?ref= rather than re-listing the parameters it knows")
+
+ byAge := h.do("GET", "/~alice/db/view/memory?sort=age", nil, nil)
+ require.Equal(t, http.StatusOK, byAge.Code)
+ body := byAge.Body.String()
+ assert.Less(t, strings.Index(body, "metered-link-calibration"), strings.Index(body, "handoff-2026-08-12"),
+ "age order is oldest first")
+}
+
+// agoDays is the Memory page's spelling of a duration: the same ladder as ago up
+// to a day, and then days all the way — "71 days ago", never "2 months ago",
+// because 60 days is the number this page marks memories at.
+func TestAgoDaysStopsAtDays(t *testing.T) {
+ pinClock(t, testNow)
+
+ cases := []struct {
+ name string
+ d time.Duration
+ want string
+ }{
+ {"the instant itself", 0, "just now"},
+ {"seconds", 45 * time.Second, "just now"},
+ {"exactly a minute", time.Minute, "1 minute ago"},
+ {"minutes", 4 * time.Minute, "4 minutes ago"},
+ {"exactly an hour", time.Hour, "1 hour ago"},
+ {"hours", 3 * time.Hour, "3 hours ago"},
+ {"one second short of a day", 24*time.Hour - time.Second, "23 hours ago"},
+ {"exactly a day", 24 * time.Hour, "1 day ago"},
+ {"the stale mark", 60 * 24 * time.Hour, "60 days ago"},
+ {"where ago says two months", 71 * 24 * time.Hour, "71 days ago"},
+ {"where ago says a year", 365 * 24 * time.Hour, "365 days ago"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ assert.Equal(t, c.want, agoDays(testNow.Add(-c.d)))
+ })
+ }
+
+ // A commit stamped ahead of this host's clock is skew, not a scheduled event.
+ assert.Equal(t, "just now", agoDays(testNow.Add(48*time.Hour)))
+}
M web/templates.go => web/templates.go +29 -0
@@ 108,6 108,8 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap {
// ago is the freshness line's relative time: past-facing, coarse, and never
// negative. See the func for why it is not chrome's reltime.
m["ago"] = ago
+ // agoDays is ago with the ladder stopped at days, for the Memory view.
+ m["agoDays"] = agoDays
return m
}
@@ 151,6 153,33 @@ func ago(t time.Time) string {
}
}
+// agoDays is ago with the unit ladder stopped at days: "just now", "4 minutes
+// ago", "3 hours ago", "71 days ago" — never "2 months ago".
+//
+// It exists because the Memory view asks a different question of the same
+// duration. There, the number is what a reader judges a memory by ("is this note
+// about a service that has since been rewritten?"), and the informative unit for
+// that is days: "2 months ago" and "71 days ago" are the same instant, and only
+// the second one can be compared against the 60-day mark the page marks memories
+// at. ago itself is left alone — the freshness line shares it, and a line about
+// how fresh a page is wants the coarse spelling.
+//
+// It reads the same swappable clock, so it is testable to the boundary rather
+// than by eye, and it is past-facing for the same reason ago is.
+func agoDays(t time.Time) string {
+ d := timeNow().Sub(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return plural(int(d/time.Minute), "minute") + " ago"
+ case d < 24*time.Hour:
+ return plural(int(d/time.Hour), "hour") + " ago"
+ default:
+ return plural(int(d/(24*time.Hour)), "day") + " ago"
+ }
+}
+
// plural names a count in a unit, singular at one.
func plural(n int, unit string) string {
if n == 1 {
A web/templates/memory.html => web/templates/memory.html +116 -0
@@ 0,0 1,116 @@
+{{define "content" -}}
+<style>
+/* Scoped memory styles, inlined in the same flat todo.sr.ht idiom as the beads
+ and milestones views (the scss bundle is not rebuilt in dev): square, hairline
+ borders, monospace ids, muted small type, no radius and no shadow. Colours
+ come from the CSS variables that mirror core.sr.ht's Bootstrap palette in the
+ light default and the prefers-color-scheme: dark variant sourcehut ships. No
+ JavaScript: the sort is a link and the search is a GET form. */
+.memory {
+ --accent: #2f9e44;
+ --bd-bg: #ffffff; --bd-panel: #f2f3f5; --bd-fg: #212529;
+ --bd-muted: #6c757d; --bd-border: #ced4da;
+}
+@media (prefers-color-scheme: dark) {
+ .memory {
+ --bd-bg: #212529; --bd-panel: #343a40; --bd-fg: #dee2e6;
+ --bd-muted: #adb5bd; --bd-border: #6c757d;
+ }
+}
+
+/* freshness line (the shared "beadsHead" partial), in the same muted small type
+ the sibling views give it */
+.memory .beads-freshness { font-size: .78rem; color: var(--bd-muted); margin: -.35rem 0 .7rem; }
+.memory .beads-freshness a { color: var(--bd-muted); }
+.memory .beads-freshness code { font-size: .72rem; color: inherit; }
+
+/* filter bar: flat hairline controls, as on the board */
+.mem-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; margin-bottom: .4rem; }
+.mem-filter input, .mem-filter button {
+ font: inherit; font-size: .82rem; padding: .25rem .45rem; color: var(--bd-fg);
+ background: var(--bd-bg); border: 1px solid var(--bd-border); border-radius: 0;
+}
+.mem-filter input[type="search"] { min-width: 12rem; flex: 1 1 12rem; }
+.mem-filter button { background: var(--bd-panel); cursor: pointer; }
+.mem-filter .mem-clear { font-size: .82rem; color: var(--bd-muted); }
+
+/* sort toggle: the same list in two orders, so it is a pair of words and not a
+ tab — the current one is plain text, the other one a link */
+.mem-bar { display: flex; gap: .35rem; align-items: baseline; font-size: .82rem; color: var(--bd-muted); margin: 0 0 1rem; }
+.mem-bar .count { flex: 1 1 auto; }
+.mem-bar .current { color: var(--bd-fg); }
+
+.mem { border: 1px solid var(--bd-border); margin-bottom: 1rem; }
+.mem-head { display: flex; align-items: baseline; gap: .5rem; padding: .4rem .7rem; background: var(--bd-panel); border-bottom: 1px solid var(--bd-border); }
+.mem-slug { font-family: monospace; font-size: .9rem; font-weight: 700; margin: 0; flex: 0 0 auto; }
+.mem-slug a { color: var(--bd-fg); }
+.mem-stale { flex: 0 0 auto; font-size: .72rem; line-height: 1.4; padding: 0 .35rem; border: 1px solid var(--bd-border); color: var(--bd-muted); }
+.mem-rev { flex: 1 1 auto; text-align: right; font-size: .78rem; color: var(--bd-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.mem-rev a { color: var(--bd-muted); }
+.mem-rev code { font-size: .72rem; color: inherit; }
+
+/* Paragraphs keep the line breaks their author typed: memories are full of
+ bullet lists, and collapsing those into a sentence loses the structure. */
+.mem-body { padding: .5rem .7rem; }
+.mem-body p { white-space: pre-wrap; margin: 0 0 .6rem; }
+.mem-body p:last-child { margin-bottom: 0; }
+
+.mem-empty { color: var(--bd-muted); font-style: italic; }
+</style>
+
+<div class="memory">
+<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> · memory</h2>
+{{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}}
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "memory" "Ref" .Ref)}}
+
+<form class="mem-filter" method="get" action="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/memory">
+ {{if .Ref}}<input type="hidden" name="ref" value="{{.Ref}}">{{end}}
+ {{/* The sort is not a filter, but this form is a GET: without carrying it,
+ searching from the review queue would answer in slug order. */}}
+ {{if eq .Data.Sort "age"}}<input type="hidden" name="sort" value="age">{{end}}
+ <input type="search" name="q" value="{{.Data.Search}}" placeholder="Search memories">
+ <button type="submit">Filter</button>
+ {{/* Clear drops the search and the single-memory selection and keeps the ref
+ and the sort: the sort is how the list is read, not what it is filtered
+ to. */}}
+ {{if or .Data.Search .Data.Key}}<a class="mem-clear" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/memory{{if .Ref}}?ref={{.Ref}}{{if eq .Data.Sort "age"}}&sort=age{{end}}{{else}}{{if eq .Data.Sort "age"}}?sort=age{{end}}{{end}}">Clear</a>{{end}}
+</form>
+
+<div class="mem-bar">
+ <span class="count">{{.Data.Total}} {{if eq .Data.Total 1}}entry{{else}}entries{{end}}</span>
+ <span class="l">sort:</span>
+ {{if eq .Data.Sort "age"}}
+ <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/memory{{withQuery .Data.Query "sort" ""}}">slug</a>
+ {{else}}<span class="current" aria-current="page">slug</span>{{end}}
+ <span class="sep">·</span>
+ {{if eq .Data.Sort "age"}}<span class="current" aria-current="page">age</span>
+ {{else}}<a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/memory{{withQuery .Data.Query "sort" "age"}}">age</a>{{end}}
+</div>
+
+{{if .Data.Memories}}
+{{range .Data.Memories}}
+<article class="mem">
+ <header class="mem-head">
+ <h3 class="mem-slug"><a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/memory?key={{.Slug | urlquery}}">{{.Slug}}</a></h3>
+ {{if .Stale}}<span class="mem-stale" title="Older than 60 days — worth a look. Some memories are meant to be permanent.">stale?</span>{{end}}
+ <span class="mem-rev">
+ {{with .Revision}}written <span title="{{.Date | abstime}}">{{.Date | agoDays}}</span>
+ · <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Commit}}"><code>{{.Commit | shortsha}}</code></a>
+ {{- if .Author}} · {{.Author}}{{end}}
+ {{- else}}written before the last {{$.Data.WalkMax}} commits{{end}}
+ </span>
+ </header>
+ <div class="mem-body">
+ {{range .Paragraphs}}<p>{{.}}</p>{{end}}
+ </div>
+</article>
+{{end}}
+{{else if .Data.Key}}
+<p class="mem-empty">No memory named <code>{{.Data.Key}}</code>.</p>
+{{else if .Data.Search}}
+<p class="mem-empty">No memory matches “{{.Data.Search}}”.</p>
+{{else}}
+<p class="mem-empty">No memories. <code>bd remember</code> writes them into the <code>config</code> table, and they appear here.</p>
+{{end}}
+</div>
+{{- end}}
M web/web_test.go => web/web_test.go +21 -1
@@ 241,6 241,16 @@ type fakeSession struct {
// (as the beads view needs). A named miss falls back to rows. A table absent
// from a non-nil map is reported as ErrTableNotFound, mirroring the store.
rowsByTable map[string]*browse.RowPage
+ // rowsByRef is rowsByTable per ref (ref → table → page), for the Memory
+ // view's revision walk: it reads the same table at several commits and the
+ // whole point is that the content differs between them. A ref absent here
+ // falls through to rowsByTable, so every other fixture is unaffected.
+ rowsByRef map[string]map[string]*browse.RowPage
+ // tableHashes is the content hash of a table at a ref, keyed "<ref>/<table>".
+ // An absent entry is the store's answer for a table that does not exist
+ // there — which for the walk means "unchanged from the equally-absent
+ // neighbour", so a fixture that sets none skips every commit.
+ tableHashes map[string]string
summary *browse.CommitDiff
// logErr, when set, makes Log fail — a store whose history cannot be read,
// which every page reading the log for decoration has to survive.
@@ 258,7 268,17 @@ func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.Commi
func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {
return s.tables, nil
}
-func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
+func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) {
+ h, ok := s.tableHashes[refStr+"/"+table]
+ return h, ok, nil
+}
+func (s *fakeSession) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) {
+ if byTable, ok := s.rowsByRef[ref]; ok {
+ if p, ok := byTable[table]; ok {
+ return p, nil
+ }
+ return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
+ }
if s.rowsByTable != nil {
if p, ok := s.rowsByTable[table]; ok {
return p, nil