Skip to main content
Glama

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.

Related MCP server: loreweave

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

manent

The CLI: `init

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

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

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

brain_write / brain_append

listed only with --writable; gated, stamped, approved; see Writes

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):

{
  "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:

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.

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:

{ "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:

  • Spec v0.1 + lint + graph + BM25 search + MCP stdio server

  • Eval harness: three query kinds, recall@k / MRR / nDCG, regression gate

  • Lexical retrieval done properly (stopwords, length-gated prefix/fuzzy): +30 pts hit@1

  • Graph expansion (Personalized PageRank) + RRF fusion: built, measured, not default

  • Local dense embeddings + RRF fusion: curated hit@1 from 75% to 100%, oblique MRR from 0.10 to 0.21

  • Chunk-level embeddings: implemented, measured, not default; worse on atomic notes

  • Streamable HTTP transport, stateless, bearer-token auth

  • OAuth 2.1 (RFC 9728 metadata, dynamic registration, PKCE); connects from claude.ai

  • Two protocol eras as separate implementations: legacy handshake (SDK) and native 2026-07-28

  • Read tools beyond search: brain_list, brain_read_raw, brain_grep

  • Write tools, off unless the operator opts in

  • Wikilinks resolved by name, path and file name, so the vault reads the same in Obsidian and here

  • Gap register: unanswered searches become a work list by frequency, then oblique golden-set entries on closure

  • Spec v0.2: audience, author, status: quarantine; status consumed by ranking

  • Identities (--agents), visibility filtered at load, per-identity views on every tool

  • Write gate (personal data, model-directed text), quarantine for agents, --audit

  • Write path behind approval: resultType: "input_required" on 2026-07-28, fingerprinted retry

  • Vault hot reload: watch, coalesce, re-embed only what changed

  • brain_feedback: "there, but wrong", filed next to the question

  • Lint gate for CI: pii, injection, audience-unknown, audience-leak, --strict-content

  • --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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    A self-hosted MCP server that gives AI agents shared, long-term memory over a git-backed folder of markdown, enabling persistent knowledge search, read, and write without a database.
    16
    26 npm
    11
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    An MCP server that gives AI agents durable, temporal memory over local markdown vaults, with tools for searching, asserting facts, querying point-in-time state, and reinforcing useful knowledge.
    380 npm
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    MCP server that exposes an Obsidian-style markdown vault as a shared memory for AI agents, with tools for searching, reading, writing, and querying notes and wiki-links.
    12
    3
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    An MCP server that exposes an Obsidian-style markdown vault as agent-readable memory. It provides tools for search, read, graph, and guarded capture without using embeddings.
    1
    MIT