Manent
README.md
# Manent
> *Verba volant, scripta manent.* Spoken words fly away, written words remain.
**File-first, git-versioned memory for AI agents.** A specification plus a toolchain that turns a plain Markdown vault (Obsidian-compatible) into a queryable, lintable, MCP-served brain that any agent can mount, and that several agents with different clearances can share.
Context windows evaporate: *verba volant*. Manent is the written memory that remains: plain files you can read, diff, and own.
## Why
Agent memory today is either a proprietary vector-DB dump (lock-in, no audit trail, no human curation) or an unstructured pile of notes (no schema, no retrieval quality, no guarantees). Manent takes a third path:
- **Markdown files are the source of truth.** Everything else (search indexes, graphs, embeddings) is derived and rebuildable from scratch.
- **Git is the sync and audit backbone.** Every memory write is a commit: who, when, why.
- **A closed, versioned schema** (JSON Schema 2020-12) for note types and typed edges. Linted in CI, so a malformed note never lands.
- **MCP is the access layer.** Any MCP client (Claude, ChatGPT, VS Code, Cursor, your own agent) mounts the brain with a URL, no custom SDK.
- **Identity decides visibility, before ranking.** Each agent reads the vault through a view built from the notes it may see; a note that says nothing about its audience is private.
## Packages
| Package | What |
|---|---|
| `@manent/spec` | The vault specification (v0.2): note types, frontmatter schemas, audience, typed edges, layout |
| `@manent/core` | Parser (frontmatter + wikilinks), vault loader, graph builder, audience filter, PII/injection scanner |
| `@manent/retrieval` | Ranking: BM25 lexical, local dense embeddings, graph expansion, RRF fusion, status demotion |
| `@manent/eval` | Eval harness: golden sets, recall@k / MRR / nDCG, regression gate |
| `@manent/lint` | Rule engine: schema, links, duplicates, orphans, personal data, model-directed text, audience labels |
| `@manent/curate` | Curation: near-duplicate pairs, declared contradictions, link communities and the maps of content they lack |
| `@manent/server` | MCP server over a vault: read tools, gated write tools, gap register, identities, audit, hot reload; see [Protocol eras](#protocol-eras) |
| `manent` | The CLI: `init | lint | eval | serve | gaps | promote | curate` |
## Quickstart
```
npm install
npm run build
# scaffold a new vault
node packages/cli/dist/index.js init my-vault
# lint it
node packages/cli/dist/index.js lint my-vault
# serve it over MCP (stdio); re-indexes on every edit
node packages/cli/dist/index.js serve my-vault
# or over Streamable HTTP with bearer auth (for remote clients / claude.ai)
node packages/cli/dist/index.js serve my-vault --http 3939 --token <long-random-token>
# with the gap register (needs Node 22.5 or newer), an audit log and agent identities
node packages/cli/dist/index.js serve my-vault --http 3939 --token <token> \
--gaps ~/manent/gaps.sqlite --audit ~/manent/audit.jsonl --agents agents.json --writable
```
Register with Claude Code:
```
claude mcp add mybrain -- node <repo>/packages/cli/dist/index.js serve <vault>
```
### Use from claude.ai (web)
Verified working. claude.ai will not connect to a remote MCP server that has no discoverable
OAuth metadata, so the HTTP server ships its own single-owner authorization server: your vault
token is the login password, or an agent's token, to connect as that agent.
1. Serve over HTTP and expose it: a tunnel (`cloudflared tunnel --url http://127.0.0.1:3939`)
or a reverse proxy. HTTPS is required by OAuth for non-localhost redirects.
2. claude.ai, Settings, Connectors, Add custom connector, URL `https://<your-host>/mcp`.
Leave the OAuth Client ID empty; discovery and registration are automatic.
3. claude.ai opens the consent page. Paste your token, approve, done.
Access tokens are HMAC-derived from the vault token rather than stored, so a connected client
survives server restarts. Rotating the vault token invalidates every issued token. Tokens issued
to an agent carry the agent's name; tokens issued before identities existed keep working as the
owner's.
Three details worth knowing:
- The endpoint binds `127.0.0.1` by default and refuses to start without a token: a vault never
reaches the network unauthenticated by accident.
- Redirect URIs are restricted to an allowlist (`claude.ai`, `claude.com`, localhost) and must be
HTTPS. PKCE S256 is mandatory; authorization codes are single-use and expire in five minutes.
- `/t/<token>/mcp` also works for clients that can neither set headers nor do OAuth. That URL
**is** a credential; treat it like a password.
Run `npm run test:oauth` to exercise the whole flow, including wrong token, failed PKCE, code
replay, forged token and disallowed redirect.
## Tools
| Tool | What |
|---|---|
| `brain_search` | ranked search, returns `{searchId, query, hits}` |
| `brain_read` / `brain_read_raw` | a note, parsed or verbatim |
| `brain_neighbors` | the notes linked to a note, up to 3 hops |
| `brain_list` / `brain_grep` | enumerate, or regex over bodies |
| `brain_feedback` | "this answer was wrong / outdated / incomplete / helpful"; see [The gap register](#the-gap-register) |
| `brain_curate` | near-duplicates, contradictions and unmapped communities over the whole vault; comes back as a task on the modern path, see [Long-running calls](#long-running-calls-the-tasks-extension) |
| `brain_quarantine` / `brain_gaps` / `brain_graph` | the review queue, the gap register and the link neighbourhood, each with a page to read it in; see [MCP Apps](#mcp-apps-three-views-on-the-vault) |
| `brain_write` / `brain_append` | listed only with `--writable`; gated, stamped, approved; see [Writes](#writes-the-gate-quarantine-and-approval) |
Every tool runs on the caller's **view** of the vault. That is the whole access model, and it is
worth stating once: a view is built from the notes the caller may read *before* any index exists
over it, so `brain_grep` and `brain_read_raw`, which never touch the ranker, cannot reach a note
the ranker would have hidden. Filtering after ranking would leave them wide open.
## One vault, several agents
A vault read by one person needs no identities: the token is the password. A vault read by a
customer-care agent, a coding agent and the owner's own sessions needs to know who is asking.
**Identities** live in a JSON file passed with `--agents` (HTTP only; stdio is the owner's own
session):
```json
{
"customer-care": { "token": "<long random>", "read": ["product", "business"], "write": "quarantine/customer-care" },
"coding": { "token": "<long random>", "read": ["tech", "product"] }
}
```
- `read` is the agent's scope: the audience labels it may see. `*` is the owner's scope and sees
everything, private notes included. `private` is never granted; it is the absence of a grant.
- `write` is the one directory the agent may write into. No `write`, no writing.
- The vault token is the **owner**. The `--agents` file, the vault token, and the OAuth tokens
minted for either all resolve to an identity that travels with every call.
**Visibility** is a property of the note, in its frontmatter, not of the folder, because the
same note often serves two audiences:
```yaml
audience: [tech, product] # who may read it
```
Absent or empty means **private**: the most restrictive reading, on purpose. A note written
without thinking about it cannot become visible by accident, the same principle as `secrets/`.
`public` is the one reserved label that may leave the organisation; every other label is the
vault's own, and `manent lint --audiences tech,business,product` closes the set: an unknown
label is reported, and until fixed it makes a note *less* visible, never more, because no scope
names it.
**The index is a note too**, and it is where a per-note perimeter leaks. An index or a MOC
carries the title and the one-line description of everything it points at, which for a memory
note is most of the content: a reader who cannot open the target still gets the map of it. Lint
reports it (`audience-leak`) — a document that summarizes others inherits the narrowest audience
among them. An index serving two audiences **splits**; it does not widen.
**Audit**: `--audit <file>` appends one JSONL line per tool call: timestamp, identity, tool,
redacted arguments, result names, elapsed. Enough to reconstruct an incident; not enough to
reconstruct a customer.
`npm run test:acl` exercises all of it: scoped search / list / grep / raw read / neighbours,
quarantine writes, the gate, the approval round-trip, OAuth as an agent, and both protocol eras.
## Writes: the gate, quarantine and approval
Write tools are off unless the server starts with `--writable`, and not even advertised
otherwise. When they are on:
1. **The gate.** Every write is scanned before anything touches the disk. Text that carries
personal data (email, phone, IBAN, card, national id) or reads as an instruction aimed at a
model ("ignore previous instructions", hidden HTML directives, zero-width characters) is
refused with the reason. A vault lives in git and git history is forever, so the check is
before storage, not a cleanup afterwards; and a knowledge base that agents with tools read must
not be a place where an outsider's words can become instructions.
2. **Quarantine.** An agent's write lands in the directory it was granted and nowhere else,
stamped `status: quarantine`, `author: <agent>`, `audience: [private]`, whatever the call
asked for. Quarantined notes rank below active ones (score halved) and are visible to the owner only,
who promotes them with `manent promote` (below): a commit with a name on it. The owner's
writes keep their folder and audience, and get `author: owner`.
3. **Approval.** On MCP 2026-07-28 the write does not complete on the first call: it answers
`resultType: "input_required"` with an elicitation form that shows the note, and completes
on the retry that carries the person's confirmation (`inputResponses`). The request state is a
fingerprint of what was proposed, so an altered retry is asked again instead of trusted. The agent
proposes, the person confirms, in the standard's own primitive. Clients that cannot ask (the
handshake eras, clients without the elicitation capability) fall through: the owner's write
goes straight through as before, an agent's goes to quarantine.
### Every write is a commit: `--git`
The principle above — git as the audit backbone, every memory write a commit — was, until
`--git`, kept by a person: the one who ran `manent promote --commit` on their own machine. A brain
that lives on a server and is written through MCP has no such person, and what an agent wrote was
a file nobody had committed: gone with the next checkout, sync or disk.
```
manent serve <vault> --writable --git # each write is committed, authored by its identity
manent serve <vault> --writable --git-push # …and pushed to origin, fast-forward only
```
The **author** of the commit is the identity that wrote (`tech <tech@manent>`, `owner <owner@manent>`),
the **committer** is whoever runs the server — the repository's own `user.name`, required at
startup. So `git log --author=tech` answers "what did tech write this week", and a quarantine write
is a commit whose message says it waits for promotion. A write that changed nothing commits
nothing. A push finds the remote moved on — someone committed elsewhere — and reports it instead
of forcing; the next push after a `pull --rebase` goes through. A failed commit never undoes a
write that reached the disk: it is reported to the caller, the audit and stderr, and the next
commit sweeps the file up. `npm run test:git` is the invariant: nothing untracked, ever.
## Promotion: the review queue
Quarantine is only half a design: something has to take notes out of it, and if that something is
three YAML edits by hand (status, audience, folder) the one that gets skipped is the audience, so
the note either stays invisible or becomes readable by everyone. `manent promote` is the other
half — one move, one commit message:
```
manent promote <vault> # the queue: what is waiting, oldest first
manent promote <vault> --author customer-care # only what one agent proposed
manent promote <vault> --note <name> --dry-run # the whole move, nothing touched
manent promote <vault> --note <name> --audience tech,product --to memory --commit
```
The queue prints one line per quarantined note: age in days (from `created`, or the file's mtime
when it has none), name, author, current audience, path, description. Promotion sets
`status: active`, sets the audience it is given (keeps the one it has when given none), moves the
file out of `quarantine/<agent>/` when `--to` says where, stamps `updated`, and prints the commit
message a person would have written by hand:
```
promote(cache-warmup): out of quarantine
Written by tech on 2026-09-01, promoted 2026-09-05.
status: quarantine, now active
audience: private, now tech and product
moved from quarantine/tech/cache-warmup.md to memory/cache-warmup.md
```
`--commit` stages exactly those paths and commits them in the vault repository; without it the
message is printed for the person to use. What promotion never does is decide: no note leaves
quarantine because a heuristic liked it. It refuses an unknown or ambiguous name, a note that is
not in quarantine, a destination already taken or outside the vault, an audience label that is
not a slug, and `private` alongside another label — `private` is the absence of an audience, not
one more of them, and a note carrying both reads as private while being served to everybody
holding the other label. Every refusal happens before anything is written.
## Curation: what a vault accumulates
A brain that is written to keeps two things nobody added on purpose: the same thing said twice, and
two notes that disagree. Both cost the reader, and both are judgements about meaning — so
`manent curate` reports and never resolves.
```
manent curate <vault> # all three reports
manent curate <vault> --duplicates --dense # by meaning, not by shared words
manent curate <vault> --communities --gaps gaps.sqlite # groups without a map, by demand
manent curate <vault> --contradictions --json
```
**Near-duplicates.** Two notes saying the same thing rank lower than either would alone: the ranker
splits the evidence between them, and the reader who finds one never learns the other exists. The
default comparison needs no model — word triples, scored by the larger of Jaccard and containment
(how much of the smaller note sits inside the bigger one), with containment allowed to speak only
when the two are within a factor of four in size, and pairs of very short notes dropped, because a
one-line note shares a sentence with anything. `--dense` compares meaning instead, using the same
embedding model as the ranker.
Both thresholds are measured on a 488-note vault, not guessed:
| | median | p99.9 | max | default | pairs reported |
|---|---|---|---|---|---|
| lexical (word triples) | 0.005 | 0.214 | 0.40 | **0.25** | 6 |
| dense (whole-note cosine) | 0.851 | 0.933 | 0.973 | **0.95** | 35 |
The dense numbers say something worth saying out loud: this model puts everything one person wrote
about one job in a narrow band, so at those scores the dense list is **topic twins** — a handoff and
the retro of the same day, a project note and its retro — while the lexical list is where the actual
copies are. Read them as two different questions.
**Communities, and the maps that are missing.** A vault's folders are one taxonomy, chosen once;
its links are another, made a note at a time by whoever was writing, and it is the honest one.
`--communities` finds the groups the links form (Louvain modularity optimisation, plus the guarantee
that motivated Leiden: a community whose subgraph is disconnected is split into its components) and
says which of them already has a map of content, so the ones without are where writing one would
pay. Nothing is random and node order is fixed: two runs give the same answer, because a suggestion
that moves between runs is one nobody can act on.
Two things had to be measured rather than assumed:
- **Index and MOC notes are held out of the graph.** An index links every note by design. Leaving
it in gave one community of 437 notes and a modularity of 0.000 on the reference vault. Held out,
the same vault yields **15 communities, modularity 0.775**, and the maps come back at the end to
say which community is covered.
- **What "covered" means.** A real MOC links a small share of a community — 12 members out of 45 in
the best case here — so "covers a third of it" reports nothing. What separates a map of a subject
from a general one is where *its own* links land: a MOC belongs to the community holding most of
them, provided that is at least a quarter of what it links. The vault's personal MOC, 22 links
spread over five communities at four apiece, is a map of none of them. On the reference vault
that leaves **10 of 15 communities with no map** — the work list.
With `--gaps <register>` the report is ordered by demand instead of size: each community carries the
number of open gaps whose best answers landed inside it, so the suggestion at the top is the subject
people ask about and the vault fails to answer. That is the roadmap's "fed by the gap register's
numbers, not by intuition", and it is the difference between a clustering toy and a work list.
**Contradictions.** Only what is checkable without understanding the notes: a pair where both
declare `contradicts` (a work item until one is deprecated or a note reconciles them); a one-sided
declaration, where the reader who arrives at the other note never learns there is an argument; and a
note that `supersedes` another which is still `active` — the decision was made and the vault was not
told, so the replaced note still ranks and still answers. Semantic contradiction detection is
deliberately absent: it would be a guess dressed as a finding, and the duplicate report already
surfaces the pairs worth looking at.
## The gap register
To learn from the people asking, the brain does not need their text; it needs to know **which
questions it could not answer**. `--gaps <sqlite file>` records every `brain_search`, redacted,
into a file *outside* the vault (a gap is not a fact; and unlike the embedding cache it is
observed, not derived; losing it loses weeks of real questions). Two signals carry the weight:
- **followed**: a search that no `brain_read` of one of its results follows, from the same
identity within ten minutes, is a search that did not help. The server sees that alone.
- **count**: rows group by meaning through the ranker's own embedding model, so paraphrases
collapse into one line; by normalized words until the model is warm. Threshold 0.9, measured on
`multilingual-e5-small`: paraphrases scored 0.908 to 0.943, unrelated questions 0.752 to 0.835
(`--gaps-threshold` to tune).
The caller recorded is the **agent**, never a person: the register is free of personal data by
construction, not by discipline. It is a queue, not a memory; its job is to be emptied:
```
manent gaps <vault> --gaps gaps.sqlite # open gaps, by asked − read
manent gaps <vault> --gaps gaps.sqlite --show g_<id> # the searches behind one
manent gaps <vault> --gaps gaps.sqlite --close g_<id> --note <name> --golden eval/golden.json
manent gaps <vault> --gaps gaps.sqlite --dismiss g_<id> # not a real question
manent gaps <vault> --gaps gaps.sqlite --feedback # what agents reported
```
**Closing a gap with the note that answers it emits a golden-set entry**: the question as the
asker phrased it, the note the curator wrote: an `oblique` query by construction. That is the
set the eval is weakest on (MRR 0.208) and the one that had to be written by hand imagining how
someone else would ask. The register manufactures it from real use, and the regression gate then
keeps every closed gap reachable when the ranker changes.
The register sees what was *missing*. It cannot see "there, but wrong": a confident wrong answer
arrives with a high score and a read, and looks like a success. Only the agent or the person can
say so; that is `brain_feedback` (verdict, note, searchId, outcome), filed next to the question
it came from.
## Hot reload
`manent serve` watches the vault and re-indexes what changed: lexical index and graph rebuilt,
dense vectors re-embedded only for notes whose content hash moved, per-identity views
invalidated. Edits are coalesced (a `git checkout` touching hundreds of files costs one reload)
and reloads are serialized. Measured: a new file is searchable ~530 ms after the write. A
`post-receive` hook that checks out the shared branch is all a git-backed brain needs to serve
what was just pushed. `--no-watch` turns it off.
## Deploy: a git-backed brain
The vault is a git repository, so deploying it is a push. `deploy/` carries the layout that has
been running, and the reasoning: a bare repo whose **`pre-receive` hook is the lint gate** (a note
with personal data or model-directed text is refused at the push, not found later in a history that
is forever), a **`post-receive` that checks the tree out and restarts nothing** — the watcher
re-indexes it in about half a second, and a restart would throw away the dense index and every warm
view — a systemd unit, and an `agents.json` example.
The one line worth repeating here: the gap register and the audit log live outside the vault, under
`/var/lib/manent`, and **they are the part no git push restores**. Back that directory up
separately. Details in [`deploy/README.md`](deploy/README.md).
## Retrieval, measured
Ranking changes are decided by an eval harness, not by intuition. `manent eval` scores a
retriever against three kinds of query, and each kind answers a different question:
| Source | How it's built | What it measures |
|---|---|---|
| `curated` | hand written, wording close to the note | lexical recall |
| `oblique` | asks for the concept **without** the note's words | semantic recall, the hard case |
| `auto` | derived from each note's own description | broad regression signal, no labelling |
```
manent eval <vault> --golden eval/golden-aios.json # bm25 vs hybrid side by side
manent eval <vault> --golden ... --save baseline.json # record a baseline (metrics only: safe to commit)
manent eval <vault> --golden ... --baseline baseline.json # exits 1 if a metric dropped
manent eval <vault> --golden ... --save full.json --save-full # with per-query results: names every note, keep it private
```
Results on a real 305-note vault (298 queries), in the order they were measured:
| Ranker | curated hit@1 | curated MRR | oblique MRR | auto hit@1 |
|---|---|---|---|---|
| BM25, naive tokenizer | 45.0% | 0.621 | n/a | 97.8% |
| BM25 + stopwords, length-gated prefix/fuzzy | 75.0% | 0.863 | 0.099 | **97.8%** |
| Hybrid (graph expansion + recency + centrality) | 75.0% | 0.863 | 0.104 | 93.0% |
| Dense only (multilingual-e5-small, local) | 95.0% | 0.975 | 0.131 | 94.4% |
| **Fused, lexical 1 : dense 2 (RRF)** | **100.0%** | **1.000** | **0.208** | 95.9% |
Five findings worth keeping:
1. **Tokenization was the first big win.** Dropping stopwords and allowing prefix/fuzzy matching
only on longer terms moved curated hit@1 by 30 points. With prefix matching on, `di` matches
*diritto*, *disposizione*, *documento*; long notes then win on accumulated noise.
2. **Graph expansion did not pay.** Once retrieval is lexically sound, Personalized PageRank over
wikilinks adds nothing measurable and the recency/centrality multipliers cost ~5 points on the
auto set. `hybrid` stays available for vaults with a much denser link structure. PPR amplifies a
good seed; it cannot create one.
3. **Lexical and dense fail in opposite directions, so fusing them beats both.** Dense alone found
the notes BM25 missed but blurred exact slugs and identifiers; at equal RRF weights the lexical
list pulled correct answers off the top spot. Weighting dense twice reached 100% hit@1 on
hand-written queries, trading ~2 points on the synthetic set.
4. **Vocabulary mismatch is improved, not solved**: `oblique` MRR went from 0.099 to 0.208 and recall@5
from 25% to 37.5%. A question whose wording shares nothing with its note is still often unreachable,
which is what the gap register is for.
5. **Chunking made it worse here, and that is informative.** Splitting notes into passages was the
obvious next step; measured, it cost 10 to 15 points of curated hit@1. With ~2400 passages instead
of 307 notes, max-scoring gives a long note one chance per passage to match by luck, so retros
and legal texts float up, the same length bias BM25 normalizes away. Damping by passage count
(`max-norm`) recovers oblique recall (MRR 0.221) but still trades away curated and auto accuracy.
| Passages | curated hit@1 | oblique MRR | auto hit@1 | Configuration |
|---|---|---|---|---|
| 307 | **95.0%** | 0.133 | **95.9%** | one passage per note, full body |
| 2419 | 90.0% | 0.096 | 96.3% | 1000-char passages, best-passage scoring |
| 2419 | 85.0% | **0.221** | 91.9% | 1000-char passages, length-damped |
| 1181 | 75.0% | 0.013 | 95.9% | 2000-char passages, best-passage |
| 1181 | 55 to 75% | at most 0.19 | 73 to 79% | any size, **without** the contextual prefix |
Two things to keep from that: the **contextual prefix is not optional**: a passage stripped of
its note's name and description loses the subject and everything collapses; and **truncation
beats completeness** on this corpus (1400-char single passage scored 100%, full body 95%).
Notes here are atomic and front-loaded, one fact each, stated at the top, so the tail is
elaboration that only blurs the vector. Raise `maxPassages` for vaults of long, multi-topic
documents, where the answer can sit in the middle of a note.
Every ranker is wrapped by **status demotion**: `quarantine` and `deprecated` notes keep half their score,
`archived` a quarter, on the server and in the eval alike, so what is measured is what ships. On a
vault with no such notes the wrapper is a no-op (regression gate: no change).
The September 2026 re-measurement on the grown vault (479 notes) is in `paper/`; it moved some of
these numbers, and the paper says how.
Reproduce any sweep: `scripts/tune-retrieval.mjs` (graph/scoring params), `scripts/tune-fusion.mjs`
(lexical/dense balance), `scripts/tune-chunking.mjs` (passage size, prefix, aggregation).
### Dense retrieval setup
Embeddings run **locally**: no API key, nothing leaves the machine, and query time needs no
network. The model is an optional dependency, so `bm25` keeps working without it:
```
npm install @huggingface/transformers # ~120 MB model, downloaded on first use
manent serve <vault> --retriever fused # or --retriever dense
manent eval <vault> --golden ... --retriever all
```
Vectors are cached in `<vault>/.manent/embeddings.json`, keyed by content hash: editing one note
re-embeds one note. Changing the model invalidates the cache. `manent init` gitignores that
directory; it is derived data, rebuildable from the notes.
## Lint as the gate
```
manent lint <vault> # what a human should look at
manent lint <vault> --strict-content --audiences tech,business,product # what CI refuses
```
Beyond schema, links, duplicates and orphans, lint reports **personal data** in a note, text that
**reads as an instruction to a model**, **audience labels** outside the vault's set, and an
**index that names notes its own readers cannot open**. They are
warnings for a person and, with `--strict-content`, errors for a pipeline. Run the strict form as
a pre-commit hook or in CI on the brain repository: a note that fails it never lands in the
shared branch, which is the only place the server reads from.
## MCP Apps: three views on the vault
Three of this server's answers are ones a person works through rather than reads: the notes agents
wrote that nobody has promoted, the questions the brain could not answer, and the shape of the
links around a note. As text in a transcript the first two are a wall to skim and the third cannot
be written down at all. So the modern path implements `io.modelcontextprotocol/ui` (MCP Apps, final
2026-01-26) and ships three pages:
| Resource | Tool it lays out |
|---|---|
| `ui://manent/quarantine` | `brain_quarantine` — what is waiting, oldest first, with author, audience and age |
| `ui://manent/gaps` | `brain_gaps` — open gaps by how often they were asked, with the command that closes one |
| `ui://manent/graph` | `brain_graph` — the wikilink neighbourhood around a note: click a dot to walk to it |
They are served as `text/html;profile=mcp-app` from `resources/list` / `resources/read`, and each
tool points at its page with `_meta.ui.resourceUri`. A host without the extension ignores the
metadata and shows the same JSON — the page is a way to read the answer, never the only way to get
it.
Two properties make them safe to render inside somebody's chat, and both are tested:
- **Nothing is loaded from anywhere.** No script `src`, no stylesheet, no font, no `fetch`. The
page *is* the resource; its declared CSP allows no connect domains at all, because it needs none.
Data arrives from the host: the tool result that opened the page, or a `tools/call` the page asks
the host to make when you press Refresh.
- **The pages decide nothing.** They read and they re-ask. Promoting a note and closing a gap are
writes, and writes go through the gate and the person's confirmation — never through a button a
page could press on its own. Each row instead carries the command that would do it.
`brain_gaps` is the owner's: one agent does not get to enumerate what the others asked. The
quarantine queue needs no such rule — quarantined notes are private, so an agent's view never
contains them.
The graph page draws itself: a force layout in about sixty lines, no library, no randomness — the
same neighbourhood produces the same picture every time, because a drawing that moves between runs
teaches nothing. Two numbers made it a graph instead of a rim of dots: repulsion is cut off past
two and a half ideal spacings (without that, 40 of 60 nodes ended clamped on the border; with it,
6), and linked notes land a mean 59 pixels apart against 266 for any pair. Clicking a node opens
it, "Centre on it" walks the neighbourhood, and "Ask about it" hands the conversation a question —
the only thing a page here may do besides read.
## Long-running calls: the tasks extension
Every brain tool answers in milliseconds except the one that reads the whole vault at once:
`brain_curate` compares every note against every other, so its cost grows with the vault and not
with the question. A vault of a few hundred notes finishes in a second; a few thousand does not,
and no client holds a request open that long.
So on the modern path Manent implements `io.modelcontextprotocol/tasks` (SEP-2663). A client that
declares the extension in `_meta`:
```json
{ "io.modelcontextprotocol/clientCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } }
```
gets a handle instead of a held connection — `{"resultType": "task", "taskId": …, "status": "working",
"ttlMs": …, "pollIntervalMs": …}` — and polls `tasks/get` until the status is terminal, at which
point the task carries the same result the inline call would have returned. `tasks/cancel` ends one;
`tasks/update` exists and says plainly that it does not apply here, because this server's only
question — confirming a write — is answered on the call itself. A client that declares nothing waits
for the answer exactly as before: the extension is an option the client takes, not a change to the
contract.
Three properties, all tested in `npm run test:tasks`:
- **A task belongs to the identity that created it.** Another identity asking is told it does not
exist. That is the same reason the extension has no `tasks/list`: without a session there is no
safe way to enumerate what someone else started.
- **A task runs on its creator's view of the vault.** An agent curating sees only the notes its
audience allows, so a duplicate pair it may not read is not a pair it is told about.
- **Cancellation is cooperative and never invents a result.** A task that had already finished stays
finished, and work that completes after a cancel does not resurrect it.
## Protocol eras
MCP revision `2026-07-28` removed the `initialize` handshake and sessions; every shipping client
still speaks the older, handshake-based revisions. Manent serves **both, as two separate
implementations** rather than one blended path:
| Era | Revisions | How | File |
|---|---|---|---|
| legacy | `2025-11-25`, `2025-06-18`, `2025-03-26` | official SDK, `initialize` handshake | `src/legacy.ts` |
| modern | `2026-07-28` | native: no handshake, `resultType`, caching hints, `server/discover`, `input_required` | `src/modern.ts` |
Both adapters expose the same tools because the tool definitions live in one place
(`src/tools.ts`) and every call goes through one dispatcher (audit included); the eras cannot
drift in capability. `src/http.ts` only routes.
```
manent serve <vault> --http 3939 # auto: routes each request to its era
manent serve <vault> --http 3939 --era legacy # pin: modern requests get the fallback signal
manent serve <vault> --http 3939 --era modern # pin: 2026-07-28 only
```
Auto-detection keys on the RPC itself (`server/discover`, `subscriptions/listen`) or a declared
2026+ protocol version, never on the `Mcp-Method` transport header alone, since dual-era clients
send it with a legacy `initialize` too. `npm run test:era` exercises all three modes.
## Vault layout (see `packages/spec/SPEC.md`)
```
vault/
├─ MEMORY.md # global index, loaded every session (type: index)
├─ HOME.md # human navigation hub (type: moc)
├─ memory/ # global lessons: feedback_*.md, reference_*.md
├─ projects/<p>/ # per-project knowledge, handoffs, retros
├─ moc/ # one map-of-content per branch
├─ people/ # person notes (type: persona)
├─ library/ # raw dated sources: YYYY-MM-DD-<slug>.md
├─ wiki/ # compounding wiki: entities/ concepts/ queries/
├─ knowledge/ # read-only mirrors from external repos
├─ quarantine/ # agents' proposals, private until a person promotes them
└─ secrets/ # NEVER synced, NEVER indexed (enforced)
```
## Tests
```
npm run smoke # init, lint, search
npm run probe # stdio: initialize, tools/list, brain_search
npm run test:era # both protocol eras, pinned and auto-routed
npm run test:oauth # the whole OAuth flow and its refusals
npm run test:write # write tools: gate and path containment
npm run test:links # wikilink resolution by name, path and file name
npm run test:gaps # gap register: redaction, grouping, follow, close, feedback, CLI
npm run test:acl # identities, visibility at load, quarantine, approval, audit
npm run test:reload # hot reload: add, edit, delete, bursts
npm run test:promote # promotion: the queue, the refusals, the move, the commit
npm run test:curate # curation: duplicates, contradictions, communities, and the vault it must not touch
npm run test:tasks # tasks extension: handle, polling, ownership, cancel, ttl
npm run test:apps # MCP Apps: declaration, linkage, self-contained pages, the layout, what they are shown
npm run test:warmup # dense ranker warms up in the background
npm run lint:fixture # the lint gate on eval/fixture-vault, content rules strict
npm run eval:fixture # retrieval regression gate on eval/fixture-vault
npm run check:published # nothing tracked here names a private note, a disk or a credential
MANENT_VAULT=<vault> npm run eval:gate # the same gate on your own vault; baselines are metrics-only files
```
A golden set is written against a real vault, so the names it expects are an index of what that
vault holds. `eval/golden-aios.json` therefore publishes them **by hash** (`note:<sha256 truncated>`):
the queries stay readable, because they are the measurement, and the names resolve only against the
vault that has those notes — where the run and its miss list read exactly as before. `npm run
check:published` holds the line in CI: no private note name, no absolute path from anyone's disk, no
credential, and no personal data outside the few files that exist to test for it.
CI (`.github/workflows/ci.yml`) runs all of it on node 20 and 22 except `test:warmup`, which would
download the embedding model, and `npm pack --dry-run` on every package, so a release ships what it
means to. The gates run on `eval/fixture-vault`: nineteen invented notes — an invented hosting
provider, an invented project — because a corpus a public CI can read is a corpus nobody wrote
their real memory into. Its numbers have the shape of the real thing (curated hit@1 91.7%, oblique
0%, `eval/baseline-fixture-bm25.json`), so a change that breaks retrieval breaks the gate.
## Roadmap
Done, in the order it was built:
- [x] Spec v0.1 + lint + graph + BM25 search + MCP stdio server
- [x] Eval harness: three query kinds, recall@k / MRR / nDCG, regression gate
- [x] Lexical retrieval done properly (stopwords, length-gated prefix/fuzzy): +30 pts hit@1
- [x] Graph expansion (Personalized PageRank) + RRF fusion: built, measured, **not** default
- [x] Local dense embeddings + RRF fusion: curated hit@1 from 75% to 100%, oblique MRR from 0.10 to 0.21
- [x] Chunk-level embeddings: implemented, measured, **not** default; worse on atomic notes
- [x] Streamable HTTP transport, stateless, bearer-token auth
- [x] OAuth 2.1 (RFC 9728 metadata, dynamic registration, PKCE); connects from claude.ai
- [x] Two protocol eras as separate implementations: legacy handshake (SDK) and native 2026-07-28
- [x] Read tools beyond search: `brain_list`, `brain_read_raw`, `brain_grep`
- [x] Write tools, off unless the operator opts in
- [x] Wikilinks resolved by name, path and file name, so the vault reads the same in Obsidian and here
- [x] **Gap register**: unanswered searches become a work list by frequency, then `oblique` golden-set entries on closure
- [x] **Spec v0.2**: `audience`, `author`, `status: quarantine`; `status` consumed by ranking
- [x] **Identities** (`--agents`), visibility filtered at load, per-identity views on every tool
- [x] **Write gate** (personal data, model-directed text), quarantine for agents, `--audit`
- [x] **Write path behind approval**: `resultType: "input_required"` on 2026-07-28, fingerprinted retry
- [x] **Vault hot reload**: watch, coalesce, re-embed only what changed
- [x] `brain_feedback`: "there, but wrong", filed next to the question
- [x] Lint gate for CI: `pii`, `injection`, `audience-unknown`, `audience-leak`, `--strict-content`
- [x] **`--git`**: every write is a commit authored by its identity, committed by the server's user;
`--git-push` pushes fast-forward only and reports a remote that moved on instead of forcing
- [x] **Promotion tooling**: `manent promote`: out of quarantine with status, audience, folder and
a commit message in one move; a review queue of quarantined notes by age and author
- [x] **GitHub Actions**: build, every test, the lint gate and the retrieval gate on a public
fixture vault, on two node versions; `npm pack --dry-run` on every package
- [x] **Git-backed deploy recipe** (`deploy/`): the lint gate as a `pre-receive` hook, checkout on
`post-receive` with no restart, systemd unit, identities and audit paths as a server layout
- [x] **Curation** (`manent curate`): near-duplicate pairs, lexical or dense, thresholds measured on
a real vault; contradictions surfaced (declared, one-sided, a superseded note left active);
link communities with the maps of content they are missing, ordered by the gap register's
numbers. Reported, never resolved
- [x] **Tasks extension** (`io.modelcontextprotocol/tasks`, SEP-2663) on the modern path: a call whose
cost grows with the vault comes back as a task to poll, owned by the identity that started it
- [x] **MCP Apps** (`io.modelcontextprotocol/ui`): the review queue, the gap register and a graph
explorer — self-contained pages that load nothing and write nothing, each bound to its tool
Next, in the order it should be built:
- [ ] npm publish (the packages carry their publish metadata and the CLI is named `manent`; the
release itself is a person's gesture, with their own token)
- [ ] MCP spec 2026-07-28 wire upgrade on the legacy path, when the official SDK ships it
Open questions the code does not settle: who owns the brain infrastructure once four agents
depend on it; which audience labels a given organisation wants (the spec reserves `private` and
`public` and leaves the rest to the vault, on purpose).
## License
Apache-2.0; see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessResponsive