~bigbes/sr-ht-dolt

231b7764d90a18c5f1bc6608fff9bd0534bb603d — Eugene Blikh 5 days ago 7a77409
web: give bd memories their own view
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, "&lt;b&gt;otherwise&lt;/b&gt;")

	// The freshness line the beads family shares.
	assert.Contains(t, body, `class="beads-freshness"`)
	assert.Contains(t, body, "main &middot; 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&amp;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> &middot; 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"}}&amp;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">&middot;</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 &mdash; 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>
      &middot; <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Commit}}"><code>{{.Commit | shortsha}}</code></a>
      {{- if .Author}} &middot; {{.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 &ldquo;{{.Data.Search}}&rdquo;.</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