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 booton aUserPromptSubmithook: 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 forline - 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.txtbeside them when the fleet keeps one. That manifest carries the fleet's ownname =androle =, theanswerer =command thatkb answerhands passages to, and the exceptions the shape cannot express: a base attached from elsewhere withattach =, an agent switched off withdisable =. - the deposit
- short memory
-
inbox/in each agent, pluskb-misses.txt, the questions the base could not answer. Fresh and unjudged. Fed by files a person drops there and bykb captureat 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 bykb write, bykb 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 inkb-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.txtand 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 = canonicalper 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,agentorexternal. Stage says how far it has come:raw,captured,distilledorderived. 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 link stops at the edge
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:
- Sovereign Local Air-Gapped Mode: When paired with local inference (such as Ollama, llama.cpp, or 4-bit AWQ distillation via ADR-0043), 100% of your notes, search queries, and prompt contexts stay on the physical machine. Zero bytes leave over the network, providing complete data sovereignty, air-gap compliance, and eliminating third-party security surface.
- External Cloud Provider Mode: When calling external APIs (Claude, OpenAI, Gemini), retrieved passages and prompts travel over HTTPS to the provider's data centers, subject to their terms and governance policies.
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