From dba72f5908d704bbe8b3e8efe35432917192292b Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 09:22:11 +0300 Subject: [PATCH] mcpsrv: list the memories a tracker holds --- mcpsrv/beads.go | 230 ++++++++++++++++++++++- mcpsrv/beads_test.go | 25 ++- mcpsrv/mcpsrv_test.go | 83 +++++++-- mcpsrv/memories_test.go | 404 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+), 26 deletions(-) create mode 100644 mcpsrv/memories_test.go diff --git a/mcpsrv/beads.go b/mcpsrv/beads.go index 8cc129c79fe8cf8c8b072a033d47ebe4259a3303..195e34a560f56269b4d17d5c3d519b0f9b4f3cc2 100644 --- a/mcpsrv/beads.go +++ b/mcpsrv/beads.go @@ -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.` 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) { diff --git a/mcpsrv/beads_test.go b/mcpsrv/beads_test.go index 4b761ebd8fd37657b8ffaf13a14cd651f1331405..8508a1502292374fef3ae3d2f6f6ac288b705755 100644 --- a/mcpsrv/beads_test.go +++ b/mcpsrv/beads_test.go @@ -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) }}, } } diff --git a/mcpsrv/mcpsrv_test.go b/mcpsrv/mcpsrv_test.go index 799b4e94589ad9e9427a5c5c47774b99a2506637..1b56ad1ffa50025256b41538883f510d2797f37b 100644 --- a/mcpsrv/mcpsrv_test.go +++ b/mcpsrv/mcpsrv_test.go @@ -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) } diff --git a/mcpsrv/memories_test.go b/mcpsrv/memories_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f4f147d085bda96e35391d4c753fae5471806212 --- /dev/null +++ b/mcpsrv/memories_test.go @@ -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") +}