Concepts

Last updated: 2 September 2026

Ulpia is a library, and the rest of these pages talk about it in a library's words. This page defines them, the library term first and the technical term after it, and then shows the four shapes every other page assumes: an agent, the two memories, the private layer and the person. It is written for the developer deciding whether this model of memory matches the agent they are building, so where a word hides a mechanism the mechanism is named beside it.

The words

the library
Ulpia
A fleet of agents, each with its own knowledge base, and one memory layer under all of them, on your machine. The name is the Bibliotheca Ulpia, Trajan's library, which was Rome's public reading room and its official record office in one building.
the librarian
Vesta, kb boot
The orchestrator that knows who is in the fleet and routes what arrives. She runs as kb boot on a UserPromptSubmit hook: the message is scored across every base, the owner is picked, and that agent's constitution is injected before the model reads anything. When no base covers the message she does not pick an agent. She says the question has no clear owner, and the session stays hers rather than any agent's.
a book
a note
One markdown file about one subject, most often in an agent's knowledge/, with two labelled lines directly under the title: a Search for line, which is the label, and an Exists to line, which says in one sentence what the file is for.
the label
the Search for line
The words a real question would use, chosen by a person and written at the top of the note. It is the heaviest term in the keyword scorer, which also gives a smaller weight to a title match and to a whole phrase. A file without one builds no entry in that index and scores zero on every question there.
the catalogue
kb route
Answers which agent and which files a question should open, by scoring the question against every label. No model is involved, so it is instant, explainable and reproducible, and it cannot invent a file that does not exist. It prints which words matched, so a bad ranking can be diagnosed instead of guessed at, and it says plainly when nothing matched rather than returning a confident guess.
the card index
<base>/.kb/index.db
One SQLite file per base, built by kb index. It holds the full text chunks the second scorer reads and nothing that cannot be recomputed from the markdown. Derived and disposable: delete .kb/ and you have lost a rebuild, not a fact. There is no flag to point it anywhere else, because which database you got once depended on where you were standing.
a shelf
a base
Any directory with markdown in it. It is served the moment it exists: no marker, no init, no repository. An agent is a base with an agent.txt; the person is a base without one.
the fleet
Every base under one root, found by being there, with fleet.txt beside them when the fleet keeps one. That manifest carries the fleet's own name = and role =, the answerer = command that kb answer hands passages to, and the exceptions the shape cannot express: a base attached from elsewhere with attach =, an agent switched off with disable =.
the deposit
short memory
inbox/ in each agent, plus kb-misses.txt, the questions the base could not answer. Fresh and unjudged. Fed by files a person drops there and by kb capture at the end of a session. It is searched, and everything served from it is labelled short.
the stacks
long memory
knowledge/ in each agent, with a label on every note. The library proper. Fed by kb write, by kb promote, and by a person editing markdown.
accession
kb promote
The filter between the two memories. It reads each agent's deposit and offers what it finds to two promoters. The first proposes notes and never sees the base; the second decides, three times through three different questions, and never sees the first one's reasoning. Only a unanimous accept writes, at stage captured. A refusal is counted in kb-rejections.txt, because the same proposal refused three times is a gap in the base rather than a bad proposal.
the three verdicts
hit, guess, nothing
What the catalogue says about its own answer. A hit is here is the book: something here matches. A guess is this might be it, served with the warning written on it: the match may be a coincidence of vocabulary. Nothing is I do not have that book, and the base then offers its own vocabulary, the keys it does hold that look like the words you used. That comparison is character trigram overlap, so it reaches a typo or a cognate and never a translation, and the reply says which kind of help it is.
the floor
gate.floor
The verdict is the top file's keyword score compared against a floor, and the floor scales with the corpus, because a key's weight is inverse document frequency and rarity needs a corpus to be rare in. The calibration constant is 17.5, measured on a fleet of 226 entries; restated as a share of what one unique key scores at a given size, it is 6.9 at eleven entries and 4.1 at four. Every surface that names a floor prints the one that applied to that fleet, never the constant.
the reader
fleet/person/
The human the fleet works for, recorded as a base with no agent.txt. The router reads it and can never elect it, because a person is not an agent.
the request slips
kb-misses.txt
The questions the base could not answer, verbatim, one line per distinct question with a count, and the count is which one to fix next. They are a real person's real questions, so the log is gitignored wherever it lands; what gets committed is the alias line a miss eventually earns, never the miss. It lives beside fleet.txt and not in .kb/, because deleting the index loses a rebuild and deleting this loses evidence that cannot be recomputed.
the cross references
kb-aliases.txt
One alias = canonical per line. Expansion is additive, so the original words always survive and a wrong line can add noise but never remove signal. A line is added only after a real question missed: it is a record of misses, not a dictionary, and a dictionary is what makes it unmaintainable.
the reading room
kb ui
A local page over the same contract as every other surface, at http://127.0.0.1:4114. It binds 127.0.0.1 and nothing else, and there is no flag to bind it anywhere else, because the page has no business leaving the machine.
the constitution
blocks.txt
What an agent wakes up knowing, declared as labelled blocks in the order of how often each one changes, most stable first. The order is a mechanism, and the next section says which.
provenance and stage
Two fields a note declares and a served passage carries in the JSON and in the text the MCP tool returns; a note without them draws a warning from kb check. Provenance says who wrote it: human, agent or external. Stage says how far it has come: raw, captured, distilled or derived. A model's output stored as evidence is a fact laundered, so the two travel with the passage where a program or a model reads it, and an agent claim is never quietly promoted to human.

What an agent is

An agent is a shelf with a name on it: a base that carries an agent.txt. The shape below is what kb init writes. The same shape is kept in the repository as agent-skeleton/, generated by that command, and a test fails if the two ever differ. person-skeleton/ has the same drift test for the same reason: a template that has drifted from its generator is documentation that lies about its own product.

The tree

fleet/<name>/
  AGENTS.md         who the agent is
  index.md          its operating instructions
  MAP.md            what exists in its base, and the words a question would use
  agent.txt         name and role, read by the orchestrator
  blocks.txt        the constitution, ordered by how often each block changes
  kb-aliases.txt    a record of real questions that missed
  knowledge/        the distillations. The brain
  inbox/            raw material awaiting distillation
  decisions/  protocols/  templates/

The three folders on the last line hold decisions that outlive a conversation, the agent's own procedures, and document skeletons. No absolute path exists anywhere inside a fleet, so moving the directory, backing it up and setting up a new machine are the same operation.

Three folder names are private before anybody says so: profile/, projects/ and records/. They are not in the tree above because kb init does not create them, and that is exactly why the names are worth knowing: create one later, by hand, and its contents stop being served unless a reader passes --all. A base overrides the list with a private = line in its agent.txt, a comma separated set of folders, or . for the whole base. A base directory named person is private in its entirety by name alone, which is how the person's base can sit inside a fleet without leaking into every answer. Nothing else is consulted: no repository is asked and no file needs to be tracked, which is what ADR-0034 settled.

Name, role, and where it stops

agent.txt is what the librarian reads to name and route. Its fields, as the skeleton ships them:

name = Skeleton
role =

# private = profile/, projects/, records/

ends = 

The private = line is commented out because its value is the default: it is there to be edited, not to be needed. ends = is where this agent stops, and it is read by the model that chooses the agent, because a roster of roles alone tells that model what each agent does and never what none of them does. An agent generated without the line is one that model cannot bound. The file is separate from index.md on purpose: a field drawn in a menu does not belong inside the block whose whole job is never changing.

The constitution in blocks

blocks.txt declares what the agent wakes up knowing. As the skeleton ships it, with the comments removed:

[identity]
AGENTS.md
index.md

[user]
../person/core.md

[map]
MAP.md

[session] on-demand

The order is a mechanism. Prefix caching reuses the cached state of a prompt only up to the first token that differs, so a change invalidates its own block and everything after it. Put a frequently changing block early and every switch pays to recompute the stable ones behind it, for nothing. Identity changes rarely and goes first. The person is one file shared by the whole fleet. The map changes whenever a note is added, so it goes last among the resident blocks. The session is fetched only when a question needs it. kb blocks . prints what each block costs and what changing it costs.

The header of a note

A note carries its own keys. Directly under the title, before the body, two labelled lines, shown here with placeholder words:

**Search for:** `the words a real question would use`

**Exists to:** say in one line what this file is for.

Not YAML front matter and not a heading, and both alternatives were run before being refused, because both fail silently. A delimited block has a way to not exist: a file with no front matter at all came back clean from the linter, while a file that had front matter and omitted one field earned two warnings, so the more broken file got fewer findings. A Keywords heading is sharper. The chunker skips everything from a keyword section to the next heading of any level, so a section placed at the top of a note deleted the note's opening paragraphs from the text index. A line excludes itself; a heading excludes everything after it.

The line is read tolerantly and emitted in one shape. The label may be bold or plain, the separator a comma or a semicolon, the backticks optional, and the Portuguese Buscar por: is accepted because half of the fleet this was built in writes Portuguese. Each of those tolerances was a real parse failure first.

The generated index.md ships with a placeholder label and a note under it saying to replace that line: half of its terms are the agent's own name and the rest are true of every agent in the fleet, so together they distinguish nothing. It asks for thirty to seventy terms naming what this agent is actually asked about, in both languages, which is what makes it reachable by a real question.

The map, demoted

MAP.md is demoted, not deleted. The router does not consult it and kb check does not demand it; a base without one indexes and routes. It stays for two readers that are not the catalogue: a person, who gets a reading list, and the agent itself, which carries it as a resident constitution block. kb write still writes the keys twice, into the note's own header and into a map entry, and it refuses when the agent has no map. A failed map write deletes the note again rather than leaving one half behind.

A [[wikilink]] resolves inside its own base and nowhere else, in the linter and in the reading room alike, and a test fails if the two rules ever diverge again. To point at another agent's file, write the path. The reason is privacy rather than tidiness: a base is a privacy boundary, and a link that silently crossed one is how a reference to private material lands in a file you meant to publish. Writing the path out means you knew. When a broken link's target exists in another base, the linter names that base and says to write the path instead, so the rule does not bend and the error becomes something a person can act on.

The person

A fleet has agents, and it has exactly one person, and the person is not an agent.

kb init --person

That writes fleet/person/ with no agent.txt, which is the whole trick: the router reads the base and can never elect it as the one who answers, because a question about you belongs to the librarian and not to a specialist impersonating you. The base holds a map and three notes. core.md is resident in every agent through the [user] block, so it is kept short, because it is paid for by every question every agent answers: who they are, the languages they write in, the machine, and how they want to be worked with. work.md and presence.md are retrieved when a question calls for them, so nobody pays for the whole person on every question.

Every agent kb init creates carries that [user] block, so an agent cannot be born not knowing who it works for. That is not hypothetical. This fleet ran with two agents that had no such block, and the marketing one answered a question about its owner's CV without knowing his name. Until the block was written into the template, the template carried a comment describing it and not the block itself, and a comment describing a block that is not there is worse than neither.

Careful. An empty profile is not a neutral state. An agent that does not know who it works for gives generic answers confidently, which is worse than one that knows it is missing something. The generated files carry questions rather than placeholders for that reason.

The shape is public and the content is not. The skeletons and the generator are part of the system; what a person writes into their own fleet/ is gitignored by the system's repository and is never published. It leaves the machine only inside a prompt your own client sends to a model you chose, and with a local model not at all. The base is named person because its earlier name, profile, named three different things in one tree, and the collision was already producing code to work around it.

Two memories, and the filter between them

The verbs are one system, and its shape is two memories with a filter between them. Short memory is the deposit: inbox/ in each agent plus kb-misses.txt, fed by files a person drops and by kb capture at session end. Long memory is the stacks: knowledge/ with a label on every note, fed by kb write, by kb promote, and by a person editing markdown. The filter is kb promote, with kb remember as its measure. It reads the short memory and either writes into the long one or refuses; nobody reads the filter itself.

Both memories are searched

That is the decision rather than a gap. The router routes to the library, because a note without a label has no index entry and the deposit has none by design, so the catalogue never names a raw drop as an answer. The text scorer reaches both, and a raw drop in the deposit can surface as a passage. When it does it arrives labelled. In the JSON every result carries memory: "short" or "long", from one rule on the path. On the passage header kb answer hands the model, the label reads [SHORT MEMORY: recent, not distilled], and it rides on every passage rather than once per file, because a model reading passage four does not reliably remember what was said above passage one. On the terminal line it is [short memory]. In the text the MCP tool returns it is the long form, [short memory: recent, not distilled, not yet in the library].

The rule travels with the label. The model may use a short-memory passage, and if it does it says the claim comes from short memory, so the reader knows it is fresh rather than settled. Hiding the deposit would lose real facts, and serving it unmarked would let a raw drop read as settled knowledge. Serving it marked leaves the decision with whoever is reading, made consciously.

Three doors into the library

A fact enters the library by one of three doors: a person writing it, kb write writing it on their behalf, or kb promote admitting it unanimously. Each door leaves provenance on the note saying which. kb capture is not a door. It runs no model and writes no label. It turns the session's record, which the librarian appends to on every message, into one raw file in the routed agent's deposit, at provenance: agent and stage: raw, and then the filter reads it. With --conclusion <text>, it also captures assistant decisions with automatic secret credential sanitization in pure Rust std. For task continuity across sessions, kb handoff enforces baton claim semantics (Pending → Claimed → Completed) to prevent race conditions between concurrent agents. No index is built in the background; every one is rebuilt by whoever asks.

The filter

kb promote is two models with different inputs, not one model reviewed by another. The first promoter reads the deposit and never sees the base; it proposes. The second reads the proposal together with what the base already holds and never sees the first one's reasoning; it decides, three times, through three different questions: does this contradict something the base states, is it already held, and does it belong to this agent at all. The second reader's view of the base does not come from a model. It comes from the catalogue, asked with the proposal's own keys, and that path has no model in it, which is the only reason the word independent applies to anything here. Any lens refusing is a refusal, an answer with no verdict line is a refusal, and every failure degrades toward writing nothing, because the command mutates the base. What is admitted lands at stage captured: read by a model and reviewed by a model and by no person, and the word keeps that visible.

The pressure behind two readers is a measurement from elsewhere. A production audit filed on the public issue tracker of another memory layer counted 10,134 entries over 32 days, of which 97.8 percent were judged junk. The count and the judgement are the filer's, not ours. That is what happens when the thing that writes is also the only thing that decides, and no model fixes it, because it is not a capability gap.

Ingestion writes memory only, and the writer refuses anything else in code. A conversation can teach the fleet a fact. It cannot grant a competence or install a tool, because those are written and configured deliberately, so a proposal aimed at anything but a memory folder is refused as a hard rule rather than caught by a lens that sometimes notices, and the refusal is counted like any other.

Privacy is a property of the layout

Each base declares its own private layer, and nothing outside the base is consulted.

The declaration

private = profile/, projects/, records/

One line in agent.txt. That value is also the default, so a base that says nothing behaves exactly as the folder map says: those three folders describe a real person and real work, and they are served only with --all. private = . declares the whole base private. A base named person is private as a whole by name, because that is the name kb init --person writes and the person's base carries no manifest of its own. The line is read off disk when the base is opened, so a declaration cannot be absent and cannot be stale, and it costs a file read instead of a subprocess per base per question.

The deposit is deliberately not in that list, and the first draft of the record that decided this had it there. Short memory should be searched. So inbox/ is served in every scope, with the label on.

The flag

--all includes the private layer and means nothing else. It is the right flag for your own agents and the wrong one for a consumer other people talk to, because it turns the filter off. In the fleet this was built in, the local boot hook passes it, because that hook runs on the owner's machine, on the owner's own message, and injects context into the owner's own session. The MCP server registered in .mcp.json is invoked as serve . without it, because what a tool returns travels to whatever model is reasoning. The rule that keeps those two apart: a new surface gets the public scope by default and has to argue for --all, and the argument has to be that it cannot carry data off the machine.

Careful. The first deployment of this passed --all for the wrong reason: not to include a private layer, but to make anything serve at all under the older git rule described below. That reason no longer exists. Pass the flag only when the consumer is meant to see the private layer.

Data sovereignty and air-gap

The security boundary is defined by where the model executes:

Full threat models, layout isolation details, and cross-machine migration safety protocols (Windows, Linux, macOS) are formally documented in DATA_HANDLING.md.

Git is not consulted

And it used to be. An earlier version read git ls-files to decide what it may serve, on the argument that the thing deciding what is served should be the thing deciding what is published. The argument was right and the cost was not. A folder was refused until somebody ran git init. A note was invisible until it was tracked, and the write command grew a git add of its own so that a router could see a file. A deployment bundle without .git served nothing. A fleet of a hundred agents shelled out to git a hundred times per question, before a single index was read. And every surface the owner actually used passed --all and bypassed the gate anyway. A memory layer is used as a database, and nobody asks a database to commit itself before it will answer. The old sentence, unknown is not public, is retired. What replaces it: undeclared is the folder map.

What git is still for

Versioning a fleet, if you want that. kb init writes a .gitignore that mirrors the declared private layer, so a fleet you choose to version starts with the right ignores, and kb check warns when the two disagree. More than one session writes a fleet at once, so committing is a verb the tool owns:

kb commit <path>... -m "message"

Name every path. There is deliberately no flag meaning everything, because that one affordance is how a commit ends up carrying somebody else's half finished work under your message. The mechanism: git commit -- <paths> builds the commit from only those paths and ignores the rest of the index, so whatever another session staged a second ago cannot land in yours. kb commit does that, then reads the commit back and prints what it left dirty, which is the step a person skips by hand. A tracked pre-commit hook refuses a raw git commit, enabled once, per repository, with git config core.hooksPath .githooks.

What this does not solve: two sessions editing the same file still clobber each other. No git technique fixes that, because the race is at the filesystem before git sees anything.

Next: Citing What You Read · All of the docs · Back to the library