~bigbes/sr-ht-dolt

dba72f5908d704bbe8b3e8efe35432917192292b — Eugene Blikh 5 days ago b6a0155
mcpsrv: list the memories a tracker holds
4 files changed, 716 insertions(+), 26 deletions(-)

M mcpsrv/beads.go
M mcpsrv/beads_test.go
M mcpsrv/mcpsrv_test.go
A mcpsrv/memories_test.go
M mcpsrv/beads.go => mcpsrv/beads.go +222 -8
@@ 6,6 6,7 @@ import (
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"



@@ 18,9 19,10 @@ import (
// They add three rules to the ones browse.go states for the whole surface:
//
//   - Nothing here reads the schema. Every answer below is a projection of
//     beads.Build / beads.BuildMilestones — the fingerprint, the lane bucketing,
//     the ready rule, the filters, the dependency walk, the humanised history and
//     the milestone rollup are the ones the web board renders, and they are
//     beads.Build / beads.BuildMilestones / beads.BuildMemories — the
//     fingerprint, the lane bucketing, the ready rule, the filters, the
//     dependency walk, the humanised history, the milestone rollup and the
//     memory revision walk are the ones the web board renders, and they are
//     shared on purpose (docs/DESIGN.mcp.md §2: no second reading of any schema).
//     A question this package could answer only by re-reading the tables is a
//     question it does not answer.


@@ 31,7 33,7 @@ import (
//     text is the agent's context window spent on text it did not ask for, which
//     is exactly what this surface exists to save.
//   - A database that is not a tracker is refused *per call*. MCP's tool list is
//     static per server, so these three are advertised for every database on the
//     static per server, so all of them are advertised for every database on the
//     instance; one whose tables do not carry the fingerprint gets a sentence
//     naming the generic tools as the way to read it anyway. That refusal is an
//     ordinary answer about a database the caller can see — never the masked


@@ 40,10 42,13 @@ import (
// What the projection reads and what it therefore cannot say: it reads up to
// beads.Max (2000) rows per table in one pass. list_issues reports that clip
// (table_truncated, table_total), because a board computed over a clipped table
// is a count a caller cannot otherwise check. get_issue and list_milestones get
// no such signal from the projection and so make no claim about one: an id that
// is not in the first beads.Max rows of a large tracker reads as absent there,
// and list_issues is where a caller learns that the tracker is that large.
// is a count a caller cannot otherwise check. get_issue, list_milestones and
// list_memories get no such signal from the projection and so make no claim
// about one: an id that is not in the first beads.Max rows of a large tracker
// reads as absent there, and list_issues is where a caller learns that the
// tracker is that large. list_memories has a clip of an entirely different kind
// and does report it — the revision walk's, which is about the history rather
// than about a table (see memoryJSON.Revision).

// The caps of docs/DESIGN.mcp.md §9.3 for the issue listing. They are applied
// *after* filtering — the limit clips a result set, not a table read — and, like


@@ 370,6 375,101 @@ type listMilestonesOutput struct {
	Total     int `json:"total"`
}

type listMemoriesInput struct {
	databaseRef
	Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read the tracker at; omit it for the database's default branch"`

	// Query is the memory view's ?q= and is handed to the projection as such,
	// rather than applied to the answer here: filtering afterwards would be a
	// second reading of the same rule, and it would also make the tool pay for
	// dating memories it is about to drop (the revision walk runs per key, after
	// the narrowing).
	Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of a memory's slug or of its text; omit it for every memory the tracker holds"`
}

// memoryRevisionJSON is when a memory's value last changed: the commit that
// wrote it, recovered from the history rather than read off the row — a config
// row is (key, value) and carries no timestamp at all
// (docs/DESIGN.views.md §2.1).
type memoryRevisionJSON struct {
	Commit string    `json:"commit"`
	Date   time.Time `json:"date"`
	Author string    `json:"author"`
}

// memoryJSON is one `bd remember` entry: the slug, the text, and what the walk
// could establish about when it was written.
//
// This is the one listing on this surface that carries prose, and it is not an
// exception to the list/detail split of §9.2 — it is the same rule applied. A
// memory *is* its text: there is no detail tool to send a caller to, and a
// listing of slugs alone would answer nothing. `q` is how a caller reads part of
// a large tracker's memories rather than all of them.
type memoryJSON struct {
	// Slug is the config key with bd's "kv.memory." prefix stripped, and Text is
	// the value with both newline spellings normalised — memories are typed into
	// shell strings as often as into files, so the same tracker holds real
	// newlines and literal "\n" escapes side by side.
	Slug string `json:"slug"`
	Text string `json:"text"`

	// Revision is the commit that last wrote this value, or **null** when the
	// revision walk could not reach it. Null is not "unknown for some reason": it
	// occurs only when walk_truncated is true, and it means this memory was not
	// written inside the last walk_max commits — i.e. it is older than that. (Not
	// the converse: a truncated walk can still have dated every memory it was
	// asked about.) Inventing a date the walk cannot support, or dropping the
	// field, would both turn that fact into something a caller cannot see.
	Revision *memoryRevisionJSON `json:"revision"`

	// AgeDays is how long ago that revision was, in whole days, measured against
	// the server's clock when the call was answered. It is null exactly when
	// Revision is.
	//
	// It is carried beside the date rather than left to the caller because it is
	// what Stale is computed from, and a flag whose input is invisible is a flag
	// that has to be trusted.
	AgeDays *int `json:"age_days"`

	// Stale is the projection's question — not a verdict — about a memory older
	// than stale_after_days: some memories are meant to be permanent, and only the
	// reader knows which.
	//
	// It can be true while Revision is null, and that is not a contradiction: when
	// the walk's own oldest commit is already past the threshold, the memory is at
	// least that old, and that much is known without a date.
	Stale bool `json:"stale"`
}

type listMemoriesOutput struct {
	Ref string `json:"ref"`

	// Memories are ordered by slug.
	Memories []memoryJSON `json:"memories"`

	// Total is how many memories the tracker holds before `q` narrowed them — the
	// honest denominator of the list above, and the way a caller tells "this
	// tracker has none" from "your search matched none".
	Total int `json:"total"`

	// WalkTruncated says the revision walk stopped at WalkMax commits with the
	// history still going, which is the only way a memory here carries no
	// revision. Read it with the null revisions above: it is the sentence "older
	// than the last walk_max commits" that the page renders in place of a date.
	WalkTruncated bool `json:"walk_truncated"`

	// WalkMax is how many commits back the walk looks, so the sentence above can
	// name its own number instead of asking a caller to trust a bound it cannot
	// see.
	WalkMax int `json:"walk_max"`

	// StaleAfterDays is the threshold every Stale flag was computed against. It is
	// one constant for the instance rather than a per-call knob, and it is
	// published for the same reason age_days is: a caller that disagrees with the
	// threshold can apply its own to the ages.
	StaleAfterDays int `json:"stale_after_days"`
}

// --- registration -----------------------------------------------------------

// registerBeadsTools installs the tools of docs/DESIGN.mcp.md §9.2.


@@ 445,6 545,34 @@ func (s *Server) registerBeadsTools() {
		out, err := s.listMilestones(ctx, in)
		return nil, out, err
	})

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "list_memories",
		Annotations: readOnlyTool,
		Description: "List the memories a hosted beads tracker holds — what `bd remember` writes — each with its " +
			"text and the revision its value was last written at.\n\n" +
			"Memories are the other half of what a tracker knows: durable notes an agent left for the next " +
			"one, stored as `kv.memory.<slug>` rows in the tracker's `config` table. Read them before " +
			"planning work on a tracker; they are where its conventions, its gotchas and its handoffs live.\n\n" +
			"A memory carries no timestamp — the row is (key, value) and nothing else — so `revision` is " +
			"recovered from the history: the commit whose `config` table first differs is the one that " +
			"wrote the value. That walk looks back at most `walk_max` commits. A memory not written inside " +
			"it has `revision: null` and `age_days: null`, and `walk_truncated` is true: null means \"older " +
			"than the last `walk_max` commits\", never \"date unavailable\".\n\n" +
			"`stale` is a question, not a verdict — it marks a memory older than `stale_after_days`, and " +
			"some memories are meant to be permanent. Both the age and the threshold are in the answer, so " +
			"judge for yourself rather than trusting the flag. A memory can be `stale` with a null " +
			"revision: the walk's oldest commit is already past the threshold.\n\n" +
			"`q` is a case-insensitive substring of a slug or of a memory's text; `total` is how many " +
			"memories the tracker holds before it narrowed them. A tracker whose `config` table holds no " +
			"memory — or that has no `config` table at all — answers an empty list, which is an answer and " +
			"not an error.\n\n" +
			"A database that is not a beads tracker says so and points at the generic tools; a database " +
			"you may not read is reported as not existing.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listMemoriesInput) (*mcp.CallToolResult, listMemoriesOutput, error) {
		out, err := s.listMemories(ctx, in)
		return nil, out, err
	})
}

// --- the handlers -----------------------------------------------------------


@@ 643,6 771,75 @@ func (s *Server) listMilestones(ctx context.Context, in listMilestonesInput) (li
	return out, nil
}

// listMemories answers list_memories: the memories a tracker holds, each dated
// from the history by beads.BuildMemories.
//
// The clock is real (time.Now) and is passed into the projection rather than
// read inside it, exactly as the web view passes its own: staleness is the one
// thing about this answer that depends on when it was computed, and beads/
// reads no hidden clock.
//
// # Why the fingerprint here is still beads.Applies
//
// beads.AppliesMemories exists — the beads fingerprint plus a config table with
// key and value — and it is what decides whether the web board grows a Memory
// tab. It is *not* what this tool refuses on, and the difference matters.
// openTracker's refusal says "this database is not a beads issue tracker", and
// for a tracker whose config table simply is not there that sentence would be
// false: it is a tracker, it has no memories, and "no memories" is an answer the
// projection already gives (a missing config table degrades to an empty view,
// like every other optional table). A tab is a question about a page's layout; a
// tool call is a question about the data, and the data here is "none".
func (s *Server) listMemories(ctx context.Context, in listMemoriesInput) (listMemoriesOutput, error) {
	const tool = "list_memories"
	var out listMemoriesOutput

	sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	// BrowseSession's method set covers beads.MemorySession (Rows, plus Log and
	// TableHash, which the walk needs and only it needs), so the seam is handed
	// over as it is rather than adapted.
	now := time.Now()
	view, err := beads.BuildMemories(ctx, sess, ref, memoryQuery(in.Query), now)
	if err != nil {
		// The ref resolved and the fingerprint matched a moment ago, so what failed
		// here is a table or a history this service could not read. In particular a
		// history that cannot be walked is an error and 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.
		return out, internalError(err, tool)
	}

	out = listMemoriesOutput{
		Ref:            ref,
		Memories:       make([]memoryJSON, 0, len(view.Memories)),
		Total:          view.Total,
		WalkTruncated:  view.WalkTruncated,
		WalkMax:        view.WalkMax,
		StaleAfterDays: int(beads.MemoryStaleAfter / (24 * time.Hour)),
	}
	for _, m := range view.Memories {
		entry := memoryJSON{Slug: m.Slug, Text: m.Text, Stale: m.Stale}
		if m.Revision != nil {
			entry.Revision = &memoryRevisionJSON{
				Commit: m.Revision.Commit,
				Date:   m.Revision.Date,
				Author: m.Revision.Author,
			}
			// Whole days, measured against the same clock the projection judged
			// Stale with — two answers from one reading rather than two.
			days := int(now.Sub(m.Revision.Date) / (24 * time.Hour))
			entry.AgeDays = &days
		}
		out.Memories = append(out.Memories, entry)
	}
	return out, nil
}

// --- resolving a tracker ----------------------------------------------------

// openTracker is the whole preamble of a beads tool: resolve the database as the


@@ 709,6 906,23 @@ func boardQuery(f issueFilter) url.Values {
	return q
}

// memoryQuery renders the tool's one filter as the query the memory projection
// parses, for boardQuery's reason: the substring rule is beads' and not a second
// implementation of it reading the same rows.
//
// The projection's other two parameters are deliberately not offered. ?key= is
// ?q= with an exactness this tool has no use for — a slug is a substring of
// itself — and ?sort= is a page's toggle: an answer with a stated order (slug)
// is one an agent can sort itself, and every ordering the projection can produce
// is derivable from the fields carried here.
func memoryQuery(q string) url.Values {
	values := url.Values{}
	if q = strings.TrimSpace(q); q != "" {
		values.Set("q", q)
	}
	return values
}

// parseCategory validates the status filter: empty is no constraint, one of the
// three categories is itself, and anything else is refused with the three named.
func parseCategory(want string) (string, error) {

M mcpsrv/beads_test.go => mcpsrv/beads_test.go +23 -2
@@ 248,7 248,12 @@ func trackerStore(tables []fakeTable) *fakeSession {

// beadsFixtures are the tracker databases this suite adds to the shared fixture
// set: one of every visibility, so the matrix has something to say about each,
// plus the two special shapes (no milestones, larger than the cap).
// plus the special shapes (no milestones, larger than the cap, and the two
// memory arms).
//
// The private one carries a memory as well as issues, so that every property the
// matrix holds for a tracker's issues is asserted for its memories by the same
// loop — including that nothing about it leaks to a caller who may not read it.
//
// They are added rather than folded into fixtures() so that the listing suites
// keep asserting about exactly the databases they were written for.


@@ 260,9 265,24 @@ func beadsFixtures() []fixture {
			name:       "roadmap",
			visibility: core.VisibilityPrivate,
			acl:        map[int]core.AccessMode{bobID: core.AccessRO},
			session:    trackerStore(plainTables("rm", "SECRETROADMAP the private plan")),
			session: withMemories(
				trackerStore(plainTables("rm", "SECRETROADMAP the private plan")),
				map[string]string{memoryKeyPrefix + "escrow": "SECRETROADMAP: the release key lives in the vault"},
			),
		},
		{name: "bulk", visibility: core.VisibilityPublic, session: trackerStore(bulkTables(bulkIssues))},
		{name: "memories", visibility: core.VisibilityPublic, session: memoryStore()},
		{
			// A tracker whose config table holds settings and no memory at all: the
			// half of "nothing to remember" that a missing config table is the other
			// half of.
			name:       "settings",
			visibility: core.VisibilityPublic,
			session: withConfig(
				trackerStore(plainTables("st", "a task in a tracker that remembers nothing")),
				map[string]string{"issue_prefix": "st", "compact_tier2_days": "30"},
			),
		},
	}
}



@@ 901,6 921,7 @@ func beadsTools() []beadsCall {
		{"list_issues", func(db string) map[string]any { return args(db) }},
		{"get_issue", func(db string) map[string]any { return args(db, "id", "bd-1") }},
		{"list_milestones", func(db string) map[string]any { return args(db) }},
		{"list_memories", func(db string) map[string]any { return args(db) }},
	}
}


M mcpsrv/mcpsrv_test.go => mcpsrv/mcpsrv_test.go +67 -16
@@ 422,6 422,25 @@ type fakeSession struct {
	tables   []fakeTable
	diffs    map[string]*browse.CommitDiff

	// configAt is the beads config table's contents *per ref* — key → value — for
	// the fixtures the memory revision walk runs over, and it takes precedence
	// over tables for that one table. Every other table in this fake is the same
	// at every ref, which is enough for the questions the other tools ask; the
	// memory walk is the one reading whose whole subject is how a table changed
	// between commits.
	//
	// It is keyed by the ref string as the caller names it — a branch name or a
	// commit hash — because that is what the walk passes and what a fixture is
	// written in. A ref that is absent from it has no config table there, which is
	// an ordinary answer while walking back past the commit that created it.
	configAt map[string]map[string]string

	// configHash is that table's content hash per ref, which is what makes the
	// walk cheap: equal hashes across a step mean the newer commit wrote no
	// memory and it is skipped without a single row read. An absent or empty entry
	// is a table that does not exist at that ref, exactly as browse reports one.
	configHash map[string]string

	logErr    error
	tablesErr error
	rowsErr   error


@@ 482,8 501,23 @@ func (s *fakeSession) Tables(_ context.Context, refStr string) ([]browse.TableIn
	return out, nil
}

func (s *fakeSession) TableHash(context.Context, string, string) (string, bool, error) {
	panic("TableHash: no tool of this phase reads a table hash")
// TableHash is read by the memory revision walk and by nothing else on this
// surface, so a fixture that declares no hashes is one no walk was meant to run
// over — and saying so is the point: answering "" instead would be read as "the
// config table is not there", which is a fact about the fixture the walk would
// then quietly build an answer on.
func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) {
	if s.configHash == nil {
		panic("TableHash: this fixture declares no table hashes, so no revision walk should reach it")
	}
	if table != memoryConfigTable {
		return "", false, nil
	}
	hash, ok := s.configHash[refStr]
	if !ok || hash == "" {
		return "", false, nil
	}
	return hash, true, nil
}

func (s *fakeSession) Rows(_ context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error) {


@@ 497,25 531,42 @@ func (s *fakeSession) Rows(_ context.Context, refStr, table string, offset, limi
		return nil, fmt.Errorf("fake: bad page offset=%d limit=%d", offset, limit)
	}

	// The config table of a memory fixture is per ref, and a ref it does not name
	// has no such table there — the answer a walk gets past the commit that
	// created it.
	if s.configAt != nil && table == memoryConfigTable {
		rows, ok := s.configAt[refStr]
		if !ok {
			return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
		}
		return pageOf(configTable(rows), offset, limit), nil
	}

	for _, t := range s.tables {
		if t.name != table {
			continue
		}
		page := &browse.RowPage{
			Columns: t.columns(),
			Rows:    [][]string{},
			Offset:  offset,
			Total:   len(t.rows),
		}
		if offset < len(t.rows) {
			end := min(offset+limit, len(t.rows))
			page.Rows = append(page.Rows, t.rows[offset:end]...)
		}
		return page, nil
		return pageOf(t, offset, limit), nil
	}
	return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}

// pageOf is one page of a fixture table, with the total browse reports beside
// it: a page past the end is empty and still carries the true count.
func pageOf(t fakeTable, offset, limit int) *browse.RowPage {
	page := &browse.RowPage{
		Columns: t.columns(),
		Rows:    [][]string{},
		Offset:  offset,
		Total:   len(t.rows),
	}
	if offset < len(t.rows) {
		end := min(offset+limit, len(t.rows))
		page.Rows = append(page.Rows, t.rows[offset:end]...)
	}
	return page
}

func (s *fakeSession) CommitSummary(_ context.Context, hashStr string) (*browse.CommitDiff, error) {
	hash := s.resolve(hashStr)
	if hash == "" {


@@ 770,9 821,8 @@ func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) {
	// §9.2, whole. This list is where an accidental extra — or a tool that
	// quietly stopped being registered — is visible.
	//
	// §9.2's list_memories and ready_work are deliberately absent: the memory
	// projection and the cross-database aggregation arrive with their own
	// commits (§11, phases 5 and 7).
	// §9.2's ready_work is deliberately absent: the cross-database aggregation
	// arrives with its own commit (§11, phase 7).
	assert.ElementsMatch(t, []string{
		"list_databases",
		"list_branches",


@@ 783,6 833,7 @@ func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) {
		"list_issues",
		"get_issue",
		"list_milestones",
		"list_memories",
	}, got)
}


A mcpsrv/memories_test.go => mcpsrv/memories_test.go +404 -0
@@ 0,0 1,404 @@
package mcpsrv_test

import (
	"fmt"
	"sort"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// list_memories, the last beads-aware tool of docs/DESIGN.mcp.md §9.2: what
// `bd remember` wrote into a tracker's config table, each entry dated from the
// history by the revision walk of docs/DESIGN.views.md §2.1.
//
// The properties worth more than the rest, and each has a test below: a memory
// the walk could date and one it could not are told apart in the payload rather
// than blurred into a zero value; the staleness flag is published with the age
// and the threshold it was computed from; a tracker with no memories — with or
// without a config table — is an empty answer and not an error; and everything
// the chapter holds for every beads tool (the visibility matrix, the non-tracker
// refusal, the closed session) holds for this one too, which is why it is in
// beadsTools().

// The revision walk reads through a seam of its own — wider than the board's,
// because a date that is not in the row has to come out of the history — and the
// fake is held to it here rather than discovered to satisfy it at run time. The
// production side of the same fact is that mcpsrv.BrowseSession covers
// beads.MemorySession, which is why the tool hands its session straight over.
var _ beads.MemorySession = (*fakeSession)(nil)

// The two names bd's memory layout is made of. beads/ owns them; these are the
// fixture's copies, and a projection that disagreed with them would answer
// nothing at all here.
const (
	memoryConfigTable = "config"
	memoryKeyPrefix   = "kv.memory."
)

// memoryWalkMax is the bound beads puts on the revision walk. It is unexported
// there, so this is the number the fixture below is built to exceed — and the
// answer's own walk_max is asserted against it, so a change in beads turns this
// suite red instead of quietly retiring the truncation case.
const memoryWalkMax = 500

// The memory texts of the fixture, spelled out so that the ?q= cases below can
// each name the one thing they match on: a slug that is not in any text
// ("ancient"), a word that is only in a text ("metered"), and a word that is in
// both ("calibrat").
const (
	handoffText     = "State after this round: the parser is landed and the CLI is next."
	calibrationText = "The owner is often on a metered link; calibrate downloads before pulling."
	ancientText     = "The first note anyone left in this tracker."
)

// memoryStore is the tracker the revision walk is asserted against: three
// memories whose dates the walk reaches in three different ways, over a history
// longer than the walk's own budget.
//
//	m-head   (1 hour ago)     handoff=v2 calibration=v2 ancient=x   hash h-3  ← head
//	m-fresh  (3 hours ago)    handoff=v2 calibration=v2 ancient=x   hash h-3  wrote handoff
//	m-old    (119 days ago)   handoff=v1 calibration=v2 ancient=x   hash h-2
//	m-stale  (120 days ago)   handoff=v1 calibration=v2 ancient=x   hash h-2  wrote calibration
//	m-000 …  (121+ days ago)  handoff=v1 calibration=v1 ancient=x   hash h-1
//
// So handoff resolves to a commit three hours old, calibration to one 120 days
// old — past the staleness threshold — and ancient, whose value never changes
// anywhere the walk can see, resolves to nothing at all: the tail is one commit
// longer than the walk examines, so the history is still going when the budget
// runs out and no date the walk could support exists.
//
// The dates are relative to now rather than fixed, because staleness is measured
// against the server's clock: a fixture pinned to a calendar date would start
// answering differently as that date recedes.
func memoryStore() *fakeSession {
	now := time.Now()
	at := func(d time.Duration) time.Time { return now.Add(-d) }
	days := func(n int) time.Duration { return time.Duration(n) * 24 * time.Hour }

	newest := map[string]string{
		memoryKeyPrefix + "handoff":     handoffText,
		memoryKeyPrefix + "calibration": calibrationText,
		memoryKeyPrefix + "ancient":     ancientText,
		"issue_prefix":                  "mem", // not a memory: the prefix filter drops it
	}
	middle := map[string]string{
		memoryKeyPrefix + "handoff":     "the handoff of the round before",
		memoryKeyPrefix + "calibration": calibrationText,
		memoryKeyPrefix + "ancient":     ancientText,
		"issue_prefix":                  "mem",
	}
	oldest := map[string]string{
		memoryKeyPrefix + "handoff":     "the handoff of the round before",
		memoryKeyPrefix + "calibration": "an earlier reading of the link",
		memoryKeyPrefix + "ancient":     ancientText,
		"issue_prefix":                  "mem",
	}

	// One commit of the fixture history: its hash and date, the config table's
	// contents there, and that table's content hash — which is what the walk
	// compares to decide whether the commit above it wrote anything at all.
	type step struct {
		hash      string
		date      time.Time
		config    map[string]string
		tableHash string
	}
	steps := []step{
		{"m-head", at(time.Hour), newest, "h-3"},
		{"m-fresh", at(3 * time.Hour), newest, "h-3"},
		{"m-old", at(days(119)), middle, "h-2"},
		{"m-stale", at(days(120)), middle, "h-2"},
	}
	// One commit more than the walk examines, none of which touched config: the
	// budget runs out with the history still going, which is the only way a memory
	// can carry no revision at all.
	for i := 0; i <= memoryWalkMax; i++ {
		steps = append(steps, step{fmt.Sprintf("m-%03d", i), at(days(121 + i)), oldest, "h-1"})
	}

	sess := &fakeSession{
		branches:   []browse.Branch{{Name: "main", Head: steps[0].hash}},
		tables:     plainTables("mem", "a tracker that remembers"),
		configAt:   map[string]map[string]string{"main": newest},
		configHash: map[string]string{"main": steps[0].tableHash},
	}
	for _, s := range steps {
		sess.commits = append(sess.commits, browse.CommitInfo{Hash: s.hash, Author: "bigbes", Date: s.date})
		sess.configAt[s.hash] = s.config
		sess.configHash[s.hash] = s.tableHash
	}
	sess.tables = append(sess.tables, configTable(newest))
	return sess
}

// configTable is a config table as browse reports it, built from the key/value
// map a fixture declares — so the schema a tool fingerprints on and the rows it
// then reads cannot disagree.
func configTable(config map[string]string) fakeTable {
	keys := make([]string, 0, len(config))
	for k := range config {
		keys = append(keys, k)
	}
	sort.Strings(keys) // a store returns rows in key order; so does this
	rows := make([][]string, 0, len(keys))
	for _, k := range keys {
		rows = append(rows, []string{k, config[k]})
	}
	return fakeTable{
		name: memoryConfigTable,
		cols: []browse.ColumnInfo{
			{Name: "key", Type: "text", PrimaryKey: true},
			{Name: "value", Type: "text", Nullable: true},
		},
		rows: rows,
	}
}

// withMemories gives a fixture store a config table with the same contents at
// every ref it has, and one content hash for all of them — a history in which
// nothing ever changed, so the walk reaches the root commit and attributes every
// memory to it. That is the cheap fixture, for the tests that are about who may
// read a memory rather than about when it was written.
func withMemories(sess *fakeSession, config map[string]string) *fakeSession {
	withConfig(sess, config)
	sess.configHash = map[string]string{}
	for ref := range sess.configAt {
		sess.configHash[ref] = "h-unchanging"
	}
	return sess
}

// withConfig gives a fixture store a config table and declares no table hashes
// for it, which is deliberate: a store whose config carries no memory has
// nothing to date, so a walk that ran over it at all would be work nobody asked
// for — and TableHash's panic is what says so, loudly, instead of the walk
// quietly succeeding over a fixture that never described one.
func withConfig(sess *fakeSession, config map[string]string) *fakeSession {
	sess.configAt = map[string]map[string]string{}
	for _, b := range sess.branches {
		sess.configAt[b.Name] = config
	}
	for _, c := range sess.commits {
		sess.configAt[c.Hash] = config
	}
	sess.tables = append(sess.tables, configTable(config))
	return sess
}

// --- the shapes a client decodes ---------------------------------------------

type (
	memoryRevisionResult struct {
		Commit string    `json:"commit"`
		Date   time.Time `json:"date"`
		Author string    `json:"author"`
	}

	memoryResult struct {
		Slug     string                `json:"slug"`
		Text     string                `json:"text"`
		Revision *memoryRevisionResult `json:"revision"`
		AgeDays  *int                  `json:"age_days"`
		Stale    bool                  `json:"stale"`
	}

	listMemoriesResult struct {
		Ref            string         `json:"ref"`
		Memories       []memoryResult `json:"memories"`
		Total          int            `json:"total"`
		WalkTruncated  bool           `json:"walk_truncated"`
		WalkMax        int            `json:"walk_max"`
		StaleAfterDays int            `json:"stale_after_days"`
	}
)

func listMemories(t *testing.T, s *mcp.ClientSession, a map[string]any) listMemoriesResult {
	t.Helper()
	var out listMemoriesResult
	decode(t, call(t, s, "list_memories", a), &out)
	return out
}

func memorySlugs(res listMemoriesResult) []string {
	out := make([]string, 0, len(res.Memories))
	for _, m := range res.Memories {
		out = append(out, m.Slug)
	}
	return out
}

func memoriesBySlug(res listMemoriesResult) map[string]memoryResult {
	out := map[string]memoryResult{}
	for _, m := range res.Memories {
		out[m.Slug] = m
	}
	return out
}

// --- what list_memories answers ----------------------------------------------

// The whole answer over the fixture history: the slug and text of every memory,
// the revision walk's three outcomes, and the two numbers that make the
// staleness flag checkable rather than merely believable.
func TestListMemoriesAnswersWhatTheTrackerRemembers(t *testing.T) {
	got := listMemories(t, beadsServer(t), args("memories"))

	assert.Equal(t, "main", got.Ref, "the default branch, named back")
	assert.Equal(t, 3, got.Total, "issue_prefix is a setting and not a memory")
	assert.Equal(t, []string{"ancient", "calibration", "handoff"}, memorySlugs(got), "ordered by slug")
	assert.Equal(t, memoryWalkMax, got.WalkMax)
	assert.Equal(t, 60, got.StaleAfterDays, "beads.MemoryStaleAfter, in the unit age_days is in")

	bySlug := memoriesBySlug(got)

	// Written three hours ago: the walk names the commit that changed the value,
	// not the head that merely carries it.
	handoff := bySlug["handoff"]
	assert.Equal(t, handoffText, handoff.Text)
	require.NotNil(t, handoff.Revision)
	assert.Equal(t, "m-fresh", handoff.Revision.Commit)
	assert.Equal(t, "bigbes", handoff.Revision.Author)
	require.NotNil(t, handoff.AgeDays)
	assert.Equal(t, 0, *handoff.AgeDays)
	assert.False(t, handoff.Stale)

	// Written 120 days ago: past the threshold, and the age says by how much.
	calibration := bySlug["calibration"]
	assert.Equal(t, calibrationText, calibration.Text)
	require.NotNil(t, calibration.Revision)
	assert.Equal(t, "m-stale", calibration.Revision.Commit)
	require.NotNil(t, calibration.AgeDays)
	assert.Equal(t, 120, *calibration.AgeDays)
	assert.True(t, calibration.Stale, "120 days is past the 60 the answer publishes")
	assert.Greater(t, *calibration.AgeDays, got.StaleAfterDays,
		"the flag is derivable from the two numbers beside it")

	// Never written inside the walk's budget: no revision, no age — and still
	// stale, because the oldest commit the walk examined is itself past the
	// threshold, which is a floor on the memory's age rather than a date.
	ancient := bySlug["ancient"]
	assert.Equal(t, ancientText, ancient.Text)
	assert.Nil(t, ancient.Revision, "no date the walk cannot support")
	assert.Nil(t, ancient.AgeDays, "and no age computed from one")
	assert.True(t, ancient.Stale)
	assert.True(t, got.WalkTruncated, "which is the only way a memory here carries no revision")
}

// The walk's honesty has to survive serialisation: a memory it could not date
// carries an explicit null, never a zero-valued revision an agent would read as
// "written by commit \"\" at the zero time". This is the one assertion made over
// the bytes rather than over the decoded struct, because it is about what the
// wire says and not about what Go's zero values look like.
func TestListMemoriesSpellsAnUnresolvedRevisionAsNull(t *testing.T) {
	payload := resultJSON(t, call(t, beadsServer(t), "list_memories", args("memories")))

	assert.Contains(t, payload, `"revision":null`, "the memory the walk could not reach")
	assert.Contains(t, payload, `"age_days":null`, "and no age invented beside it")
	assert.NotContains(t, payload, `"commit":""`, "never a zero-valued revision")
	assert.NotContains(t, payload, `"0001-01-01T00:00:00Z"`, "and never the zero time")
	assert.Contains(t, payload, `"walk_truncated":true`, "the reason it is null, in the same answer")
	assert.Contains(t, payload, `"m-fresh"`, "beside a revision that did resolve")
}

// ?q= is the projection's own substring rule over slug and text, handed over
// rather than re-applied here — these are the cases that would catch a second
// implementation drifting from it.
func TestListMemoriesFiltersOnSlugAndText(t *testing.T) {
	session := beadsServer(t)

	for _, tc := range []struct {
		name string
		q    string
		want []string
	}{
		{"a slug that is in no text", "ancient", []string{"ancient"}},
		{"a word that is only in a text", "metered", []string{"calibration"}},
		{"a word in both a slug and a text", "calibrat", []string{"calibration"}},
		{"case-insensitively", "METERED", []string{"calibration"}},
		{"a substring several memories share", "the", []string{"ancient", "calibration", "handoff"}},
		{"a query nothing matches", "zzz", nil},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := listMemories(t, session, args("memories", "q", tc.q))
			assert.Equal(t, tc.want, emptyToNil(memorySlugs(got)))
			assert.Equal(t, 3, got.Total,
				"total is what the tracker holds, so an empty search is distinguishable from an empty tracker")
		})
	}
}

func emptyToNil(s []string) []string {
	if len(s) == 0 {
		return nil
	}
	return s
}

// A tracker with nothing to remember is an empty answer and not an error, in
// both of the two shapes that reach it: a config table holding only the
// tracker's settings, and no config table at all. Neither costs a revision walk
// — the fixtures declare no table hashes, so one that ran would panic.
func TestListMemoriesOnATrackerWithNothingToRemember(t *testing.T) {
	repos, opener := beadsFakes()
	session := connect(t, newServer(t, repos, opener), nil)

	for _, tc := range []struct{ name, db string }{
		{"a config table with no memory in it", "settings"},
		{"no config table at all", "backlog"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			res := call(t, session, "list_memories", args(tc.db))
			require.False(t, res.IsError, "%s", errorText(res))

			var got listMemoriesResult
			decode(t, res, &got)
			assert.Empty(t, got.Memories)
			assert.Equal(t, 0, got.Total)
			assert.False(t, got.WalkTruncated, "there was nothing to walk for")
		})
	}
}

// A grantee reads a private tracker's memories whole: the matrix proves the tool
// answers, and this proves it answers with the tracker's actual contents.
func TestAGranteeReadsAPrivateTrackersMemories(t *testing.T) {
	repos, opener := beadsFakes()
	got := listMemories(t, connect(t, newServer(t, repos, opener), bob()), args("roadmap"))

	require.Len(t, got.Memories, 1)
	assert.Equal(t, "escrow", got.Memories[0].Slug)
	assert.Contains(t, got.Memories[0].Text, "SECRETROADMAP")

	// Nothing in this history ever changed the config table, so the walk runs off
	// the end of it — and a history that *ended* is a date the walk supports: the
	// root commit is what wrote the value.
	require.NotNil(t, got.Memories[0].Revision)
	assert.False(t, got.WalkTruncated)
}

// The memory text is normalised by the projection and not re-normalised here:
// the same tracker holds values typed into a shell string, with literal "\n"
// escapes, and values written from a file with real newlines.
func TestListMemoriesNormalisesTheStoredText(t *testing.T) {
	repos, opener := beadsFakes()
	opener.sessions[storePath("alice", "memories")] = withMemories(
		&fakeSession{
			branches: []browse.Branch{{Name: "main", Head: "only"}},
			commits:  []browse.CommitInfo{{Hash: "only", Author: "bigbes", Date: time.Now()}},
			tables:   plainTables("mem", "a tracker that remembers"),
		},
		map[string]string{memoryKeyPrefix + "typed": `first line\nsecond line`},
	)

	got := listMemories(t, connect(t, newServer(t, repos, opener), nil), args("memories"))
	require.Len(t, got.Memories, 1)
	assert.Equal(t, "first line\nsecond line", got.Memories[0].Text,
		"the escape a shell string carries is a newline, and beads is what decides that")
}