Knowledge store — one flat set of docs, an agent-edited index¶
Component: biopb-mcp — mcp/_docs.py (store, index, tools),
mcp/_docs_data/ (the shipped seed), ~/.config/biopb/docs/ (the user's own).
Two tools, read_doc/write_doc, reach one flat set of markdown files plus
an agent-edited index. One ablation on record (20 cases, one model) scored
17/3 with the catalog against 12/8 without; §6 is the acceptance test this
store re-runs.
1. The store¶
A doc is a markdown file. Two directories, read fresh on every access:
| tier | where | writable by the agent |
|---|---|---|
| shipped | mcp/_docs_data/ inside the wheel |
no — copy-on-write (§3) |
| local | ~/.config/biopb/docs/ (services.docs_local_dir moves it) |
yes |
services.skills_local_dir, the pre-redesign key, is read as an alias for
one release; the new key wins where both are present.
Id is the path under the directory, minus .md, and may contain /.
Nothing about subdirectories is interpreted today; allowing the slash now
means the store can grow a tree by naming convention later with no
migration. index is reserved (§2).
A _-prefixed name is banked: unlisted, not withheld. It ships, and
read_doc returns it by id; what it does not get is a line in the seed
index, so it stays out of the reconciliation tail (§2) and no session is
told it exists. One hand-written entry promotes it, enforced in exactly one
place, shipped_ids(), which only the tail consumes. The _ prefix is the
release does not list this; the index's ignored: line is this agent does
not list this — neither can express the other's decision, and the agent
owns ignored: outright. A banked doc gets no assertion from the package
gates (§6), since it's usually banked because its evidence is thin.
Frontmatter is optional and small, parsed tolerantly: scalars and inline lists, unknown keys ignored, nothing fatal.
| key | who writes it | meaning |
|---|---|---|
description |
author, or inferred from the first paragraph | the hook write_doc files a new doc under (§3) |
packages |
shipped docs only | name~=x.y.z, … — third-party APIs the body quotes, read by the contract tests (§6) and nothing at runtime |
updated |
author, optional | overrides the file mtime the header otherwise reports |
Title is the first H1, else the id. Origin (shipped / local / local shadowing
shipped) is derived from where the file sits, never stored. The store
classifies nothing else — which docs are reference and which are
procedures is the index's sectioning, maintained by the agent, not a
runtime kind: field.
2. The index¶
The index is a doc — index.md in the local directory — that the agent
edits like any other. It is not derived from the files: a derived listing
cannot carry the agent's own hooks, grouping and "don't use X for Y" notes.
Seeded on first run. The package ships an index.md beside the docs;
when the local directory has none, it is copied there.
Format is free markdown with two recognised line shapes.
# biopb docs
## References
- kernel: namespace, jobs, where computes run, the user shares this kernel
- data: pyramids, laziness, axis order; read a layer with viewer.tensor()
## Procedures
- flatfield: correct uneven illumination from the image itself or a blank
- stitch-tiles: assemble a grid of overlapping tiles into one canvas
- my-lab/nuclei-protocol: the lab's nuclear segmentation, validated on the 2026 set
ignored: measure-smlm-resolution, ratiometric-fret
- An entry is a list line whose first token is an id:
- <id>: <hook>, the hook rendered verbatim. - The
ignored:line names shipped docs the agent has decided not to list, bounded by the size of a release. Ignore is explicit rather than absence, so a line lost in a sloppy rewrite reappears instead of silently retiring a shipped doc. An ignored doc is still readable by id.
Local docs are indexed from birth and retired by one edit. write_doc
appends an entry for a doc it creates (§3), so every local doc has a line to
remove. A local file with no entry — one the agent retired, or one the user
dropped in by hand — is not surfaced anywhere; it is readable by id and is
the user's to delete. Keeping local docs out of the tail and the ignore line
is what keeps both bounded.
Headings, prose and order are the agent's. The loader interprets nothing else.
Rendering. read_doc("index") and the handshake (§3) return the file
plus what the loader knows and the file cannot:
- an entry whose file does not exist gets the suffix
(missing); one that shadows a shipped doc gets(local copy); - a tail, New shipped docs, listing shipped docs with neither an entry
nor an
ignored:mention, one- id: descriptionline each — already in entry shape, so filing one is moving the line; - a second tail, Shipped docs your local copy may be behind on, naming
shadowed ids whose shipped text changed since this install last looked
(§4) — the one shadowed-doc case with something to act on, since an
unshadowed entry is simply read fresh and needs no flag. Each line ends
with the
read_doc("<id>@diff")call (§3) that shows what changed.
Each tail opens with a sentence saying what to do with it, and each item carries the shipped doc's description: for a new doc it is the agent's only hint of relevance, and for a stale one the copy's own description would say nothing about the release. The instruction is inline because the agent meets the tail cold in the handshake, and a tail whose action has to be looked up elsewhere is a tail it skips.
Neither tail is truncated, since both are bounded by what one release adds. The agent cleans up either by editing the file (filing/ignoring a new id; merging the release's changes into its copy, or leaving it, for a stale one).
Kernel plugins are not docs. They are modules already bound in the
namespace: server_status lists which ones loaded and inspect_object
reads their docstrings, so the store does not mirror them.
Bounds. At most 200 entries; write_doc refuses an index over that
with a message saying so. At the cap the rendered index is roughly 25 KB,
paid once per session start. The number of files is unbounded; only indexed
ones are discoverable, which is the bound that matters.
3. Tools¶
Two tools replace list_skills, skill://{id} and the five guide://
resources. Tools rather than resources because every host has tools.
read_doc(id) returns the body under a one-line header (origin,
updated, and shadows shipped when it does). read_doc("index") returns
the rendered index (§2). read_doc("<id>@diff") returns a unified diff of a
shadowed doc's local copy against its shipped text — the only way to see a
release's change the shadow hides. The manifest (§4) keeps hashes, not old
text, so the diff is copy-vs-shipped and mixes the agent's edits with the
release's; its header says so. @ is outside the id charset, so the suffix
can never name a real doc.
write_doc(id, body=None, old=None, new=None) — body alone, or the
old/new pair.
bodycreates or replaces the local file. On a create, the tool also appends- <id>: <description>to the index, under a trailing## Unfiledheading it adds if absent, so the doc is discoverable without a second call.old/newreplaces one exact occurrence ofoldwithnew; refused ifoldis absent or matches more than once. This is the primitive every agent harness trains models on for file edits; a full index rewrite is both slow and lossy (models drop and paraphrase lines past ~100 of verbatim reproduction), where a replace costs a few dozen tokens whatever the index length.
There is no delete. Retiring a local doc is removing its index entry, and
retiring a shipped one is putting it on the ignored: line (§2).
Shipped docs are copy-on-write. A body or old/new write to a
shipped id creates a local file that shadows it (the replace is applied to
the shipped text as its base). The shipped file is never touched, so an
upgrade can still replace it, and the index marks the shadow (§2).
The result is the diff — difflib.unified_diff of before and after, so
a replace that landed somewhere unexpected shows immediately.
Body cap. A doc is read whole into context, so write_doc refuses a
body over 300 lines. The shipped procedures sit near 200.
The handshake carries the index. The MCP instructions field — sent
once per session at initialize, read by every host, in context before the
first tool call — carries the rendered index under one header line saying
this is doc index, editable with write_doc and re-readable with
read_doc. It is inlined rather than "now call read_doc('index')", since
a prompted hop loses agents that never take the first one. start_kernel's
own return is unaffected: it also doubles as the recovery path after a
stuck or dead kernel, and a 25 KB index resent on every retry would be cost
with no information.
Mid-session edits are seen through read_doc("index"), and the write_doc
result shows the change. Where the host implements resource subscriptions,
the index is additionally exposed as a resource with a resources/updated
notification on every write — a nudge for hosts that honour it, nothing in
the design depends on it.
The rest of the handshake is: call the start tool first; the index above lists what to read, and its reference docs are what to read before non-trivial work; the standing guardrails; one sentence that destructive steps always ask first.
4. Upgrades¶
The local index is the user's; the shipped set is the release's. The two never need merging because the index references docs by id and the loader reconciles on every render:
- a new shipped doc has no entry and no ignore, so it appears in the New shipped docs tail; the agent files it or ignores it, once;
- a removed shipped doc leaves its entry marked
(missing); the agent deletes the line; - a changed shipped doc, unshadowed, is simply read fresh: the index entry (if any) still resolves, now to the new text, no action needed;
- a changed shipped doc, shadowed: the shadow keeps winning (§1, §3), which means the release's fix or improvement is invisible to this install until the agent notices and re-shadows. This is the one case that does need a merge step, and it is what the change manifest (below) exists to flag.
The change manifest, .shipped-manifest.json in the local tier
(mcp/_docs.py, changed_shipped_ids), is a sha256 per shipped id plus the
installed package version, refreshed once per version bump rather than on
every render — a session that never upgrades pays nothing past reading one
small file. On a version bump it diffs the new hashes against the stored
ones, records which ids changed, and render_index intersects that set with
the ids the local index currently shadows for the Shipped docs your local
copy may be behind on tail (§2) — an unshadowed change needs no flag, since
reading it already returns the new text. There is no per-item
acknowledgment: the flagged set is simply whatever changed between the last
version this ran under and the current one, so it clears itself at the next
version bump regardless of whether the agent acted on it. A first-ever run
has no prior manifest to diff against and reports nothing changed.
Still no seen-list, no merge step of the file-format kind for the index: the
manifest tracks the shipped set, not the local one, and sits beside the local
tier's files on disk without being one of them — not .md, not counted in
local_ids(), not reachable through read_doc/write_doc. The agent's
ownership of the index and its docs (§2) is unchanged; this is the loader's
own bookkeeping, the same way describe()'s frontmatter parsing is.
5. Writing discipline¶
The authoring gate is the write_doc docstring, in five bullets: write only
a validated multi-step procedure, never a dataset-specific one; phrase the
index hook as the user's request; update an existing doc rather than
write a near-duplicate — read the index first, prefer an old/new edit
to a new file; and verify a name, flag or call a doc quotes still exists
before relying on it, since a local doc has no contract test behind it. A
short shipped reference doc, authoring, holds what a docstring cannot —
the checkpoint types (confirm-input, visual check, validate-and-gate) and
the derivation-rule convention for parameters — in well under a hundred
lines. Promotion to the shipped seed is a PR, and that is where review
happens.
Links: one syntax, no policing. A doc links another as [[id]], and the
link resolves by read_doc(id) — nothing renders or rewrites it. A dangling
link in a local doc is allowed: it marks something worth writing. Shipped
docs keep the check that every link resolves to a shipped doc. When to link
is the agent's call — link what this doc depends on or hands off to, not
everything it mentions. Index hooks carry no links.
6. Ablation and tests¶
The bench switch is an index. --bench-docs=false writes the run's
isolated config tree a local index: the seed with every entry under its
## Procedures heading moved to the ignored: line. Nothing lists them,
the tail does not resurface them, and the handshake never shows an id the
agent could guess; the reference docs stay listed. That heading is the only
index heading named from outside the index, by the bench alone, and the
seed gate pins it.
_tests/docs/ covers retrieval (test_seed.py: every shipped doc is either
a seed-index entry or on its ignored: line, every seed entry names a file
that ships, every [[link]] lands, nothing is over a cap), packaging (every
doc, banked ones included, reaches the wheel), and contract (hand-written
API pins, keyed off packages:).
Acceptance. Re-running the ablation pairing against the new store, both arms, is the open validation step — §8.
7. Extensibility: what the design does not preclude¶
The next class of shipped content is the engine's own design docs across
packages — larger and more numerous than today's seed. To stay additive
without a later migration: ids may contain / and map to subdirectories in
both tiers; an id ending in / is reserved — refused by write_doc, and a
- <id>/: index line is kept verbatim and counted as one entry, so a
collection line in a seed index parses today and means something later;
reconciliation, the cap and the ignored: line are keyed by id in one
place, so grouping by first segment is a change to that place alone;
read_doc keeps a single positional id, leaving room for an optional
section later without a new tool or call-site change.
None of that — collection listings, section reads, full-text search over the doc set — is built. The gap it would close: a listing answers "what exists", not a troubleshooting question that starts from a literal (an exception name, a config key, a log line), which is a grep-shaped search over prose, not a ranking problem, at this corpus size.
8. Open risks¶
- Whether the seed still gets read, now that reading it is a
read_doccall the agent chooses to make rather than a tool/resource the harness pushes. Only the acceptance run (§6) answers this; the fallback if it fails is inlining reference bodies into the handshake as well. - Quality of unsupervised local docs. The bench uses the seed, so it can't see this. The cap bounds the size of the damage, not its rate.
- Concurrent hand edits. The user can edit the local directory. An
old/newwrite fails safe when the text has moved; abodywrite from stale state clobbers. Accepted for now. - Two hundred entries is a context-budget guess. Raising it later touches nothing but the constant, since edits are replaces, not rewrites.