The cabinet & lanes
Grounded keeps memory in four explicit lanes over one durable home. Nothing is inferred into existence: your agents write facts, log sessions, ingest docs, and set vision as they work — recall and briefs only ever retrieve, rank, and cite what's there.
The cabinet #
The cabinet is the folder that holds your data — ~/Grounded/Cabinet by
default (%USERPROFILE%\Grounded\Cabinet on Windows). On the default adapter it
holds a single SQLite database that carries every lane plus its vector and full-text indexes. The
index is inside the cabinet; you can copy, diff, and back it up with ordinary tools. Settings live apart,
in ~/.grounded, which holds only configuration, models and runtime.
A custom --home folder is self-contained: its data lives in
cabinet/ inside it. An older install with data in
~/.grounded/cabinet keeps using it until you run
grounded migrate-cabinet.
Switch config.toml to the Postgres adapter and the same lanes live in a
grounded schema instead — the behaviour is identical. See
self-hosting.
Delivery accounting #
Every list-shaped response — facts, sessions, docs, vision, recall, and impact — comes back
as {data, meta}, never a bare array. meta is a
DeliveryMeta: {returned, available, truncated, limit,
bySource?}. available is a real computed count of what matched the
query — never data.length — so GET /facts?limit=8
can answer {returned: 8, available: 24, truncated: true} instead of
quietly handing back eight rules and losing the other sixteen.
Most memory systems report what they stored. Grounded reports what actually reached the agent. A truncation signal at the retrieval response — not a log line, not a debug flag, the response itself — is what lets a caller tell "this is everything" from "this is page one" every time, without a second round trip.
mem0, Graphiti/Zep, Letta/MemGPT, Honcho, cognee, and Basic Memory all report what they stored. None of them surface a truncation signal at the retrieval response — a caller discovers that sixteen facts were silently dropped only after the agent acts on an incomplete set.
Facts #
Durable rules and operator truths — the things an agent must follow. A fact has a
scope (e.g. global, project:grounded,
agent:codex), an optional category and
detail, a pinned flag, and an
importance from 0 to 1.
Facts stay explicit: your agents write them as they work and edit them in place by id — only the fields passed change, and a fact re-embeds only when its text does.
Sessions #
The chronological work log — what happened, recently, per project. A session carries a
summary, optional details, and metadata:
project, agent, machine,
tags, and a source (manual,
hook, import). Recent sessions carry momentum into
every brief and lose weight over time by a recency half-life.
Docs #
The markdown and notes you ingest. On ingest, Grounded walks your paths (indexing
.md .markdown .mdx .txt), applies .groundignore,
strips <private>…</private> regions, and splits each file into
~1200-character chunks (150 overlap). Each chunk is hashed, so re-ingesting only touches what
changed — a body-unchanged chunk whose tags did change gets a cheap tag-only rewrite instead
of a re-embed. A doc chunk has a status of active,
archived, or missing.
Every doc also carries a scope — a lane, e.g.
global (the default) or administration — set per
ingest batch via docsIngest(paths, {scope}). Lanes let you keep
corpora that shouldn't mix (engineering docs vs. household admin, say) in one cabinet without one
polluting the other's recall results. Browsing is unfiltered: GET /docs and
ground_docs_ingest's list-side counterpart return every lane unless you ask
for one. Retrieval is filtered by default: recall and brief
only read the global lane unless you pass scopes /
docScopes explicitly — and that list is the complete read set,
not an addition to the default, so asking for ["administration"] alone drops
your global docs out of the results. Want both? Pass
["global","administration"].
Separate from the lane, every doc also carries a project — derived at
ingest from the corpus/<project>/ path segment, and indexed in its own
right, mirroring sessions.project. It is nullable with no default: a path
with no corpus/ segment stays null rather than
claiming a membership it can't prove, and nothing is ever guessed from a filename. The two axes
answer different questions — scope is which corpus may read this,
project is which work it belongs to. Re-ingesting an existing cabinet
backfills it: the retag path fills project on files whose bodies never changed,
so nothing re-embeds to gain a column.
Scoping is routing, not a security boundary. A self-hosted cabinet has one
shared API token; scope and agent are values the
caller declares on each request, not something the server authenticates. Anyone who can call the API
can read or write any lane.
impact (see the API) is the one
operation that crosses this lane boundary on purpose. Recall drops out-of-lane docs entirely; impact
returns them citation-only — path, citation, and
scope survive, title/snippet
come back null, and inScope: false says why. That's
still routing, not enforcement: the caller learns a dependency exists without its content crossing the
boundary, and declaring the lane in scopes reveals it. Lane gating applies to
docs only — facts and sessions are never laned, so impact always reports them
inScope: true, scope: "global".
Orphaned rows — files that were deleted or moved on disk — aren't caught by ingest.
Reconcile them with docsPrune() (POST /docs/prune,
ground_docs_prune): marks stale rows missing by
default, or deletes them with {remove:true}. This is distinct from
an ingest's own removed count, which only tracks stale chunk indexes inside a
file that shrank on re-chunk — it says nothing about files gone from disk.
Vision #
What the work is for. Vision is one active narrative-markdown record per scope — a
global vision and a project:<name> vision. Like
facts, setting it edits the one active record for that scope in place. Unlike the other lanes,
vision is excluded from recall — it isn't embedded or indexed. It is always injected,
verbatim, into the brief, so every agent shares your direction, not just your history.
Typed IDs & citations #
Every retrievable item has a typed ID — fact:2,
session:274, doc:1091 — and no result ever comes
back without one. Recall returns compact cards; each card names its source and how it matched. Pass a
typed ID to GET /get/<typedId> for the full record, traced back to its
row and, for docs, its file path. Nothing is anonymous, nothing is unrecoverable.
Briefs #
A brief is the scoped startup summary an agent reads before it acts. It assembles, in order: a
startup note, your vision (global + project), the most recent sessions, the
facts that apply to this agent and project, and related docs. Fact scope is derived
from the request — global plus agent:<a>
plus project:<p> — so an agent sees the rules meant for it and
not another project's. Related docs come from the global lane unless
docScopes says otherwise (see docs above).
Ask for it as markdown or JSON — the brief's list-shaped members carry the same
delivery envelope as everything else.
Facts are the scarcest thing in a brief: they share one token reserve. Pinned is a delivery guarantee, not a rank boost — the entire pinned set survives the reserve, so a fact you pinned is a fact the agent read.
Past the full-text budget, the rest don't vanish. They fall to an index tier: a one-line
compressed form — topicKey — detail (fact:NN) — rendered
within its own small budget. A fact's detail is its trigger clause, so the
line still tells an agent when the fact matters and hands it an id to fetch in full; only
what overflows the index budget too is genuinely dropped. The counts stay honest either way:
meta.indexed counts index lines and is never folded into
returned, their ids come back in indexedItems, and
droppedItems keeps its one meaning — absent from the brief.
Optional per-category floors guarantee a minimum
number of slots to specific categories (a commit-rule or a
convention, say) before the reserve is spent, so a rare-but-critical
category can't be crowded out entirely by a larger one. Floors only reorder facts within
their existing ranking — they never filter anything out; with no floors configured, a brief
behaves exactly as it always has.
The session lines carry a date only, rendered in UTC unless you pass an IANA
timezone. That default is worth a thought if you are not on UTC: a session
logged at 23:53 local, west of it, renders as the next day — and a brief exists to tell
an agent what happened yesterday. Pass your zone and the dates land where you expect; the header then
names it, so a non-UTC date is never ambiguous. It is display only — stored instants and the JSON
createdAt are always UTC.