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")
+}