How GitLoom works
Why the store is a git repository, how the index stays cheap to update, and what makes retrieval fast.
Core concepts covers the API-level vocabulary — accounts, namespaces, memories, cues. This page is one level deeper: the engine that makes those ideas hold up, and the design decisions behind it.
Git is the source of truth
A namespace is a real git repository of markdown files with YAML frontmatter — not a database with a git-shaped export. Three things follow from that choice, and they are the reason for it:
Correction without losing the record. A fact that changes gets its file rewritten, not appended to — the memory always states what is true now, which is what a retrieval should return. The previous version is still in git's object store, because that is what a commit is. Nobody had to design a versioning scheme; git already had one.
Change detection is native. Git tree objects are content-addressed — a Merkle
tree — so any directory whose contents have not changed keeps the same tree hash
across commits. Re-indexing after a write diffs the new root tree against the
previously indexed one, and any subtree whose hash matches is skipped without being
walked into. This is the identical mechanism that makes git status fast: a sync
after 50 changed files out of 10,000 visits about 50 files, not 10,000.
It is inspectable. A user's memory is a directory you can git log, diff, grep,
and hand back to them. When someone asks why does the assistant think that about
me, the answer is a file and a commit — not a vector nobody can interpret.
Every namespace's repository is packed and kept in object storage, updated with a compare-and-swap so two writers cannot silently lose each other's work, and reads happen through a warm local checkout that revalidates its commit against the recorded head on every request — see cold starts.
The index is a derived cache, never the source of truth
Structural search, full-text search, the topic tree, and vectors all live in one SQLite database with an FTS5 table, built entirely from what is in git. Nothing in it is authoritative:
gitloom rebuildreconstructs a correct index from git alone, and a schema version bump triggers this automatically on next open. Losing the index file loses nothing but rebuild time.- Reads never see mid-write state. Writes take a single serialized lock (git worktrees are not safe for concurrent writers) and apply the whole index changeset in one SQLite transaction; readers see WAL snapshots, never a partial commit.
- TTL expiry runs through the same writer, as its own
gc:commit, so a sweep and a concurrent write cannot interleave into an inconsistent state.
Why one meter-scale index instead of a vector database
Semantic search is an injected Embedder — any OpenAI-compatible /v1/embeddings
endpoint — scored by brute-force cosine similarity in pure Go, no ANN index. That
is a deliberate trade, not a shortcut. At memory scale (thousands of vectors per
namespace, see Vector search at scale),
an exact scan costs single-digit milliseconds, and skipping an ANN index keeps the
whole engine free of CGo — sqlite-vec is a C extension the pure-Go SQLite driver
this project uses cannot load, and CGo-free is what makes cross-compiling to arm64 a
plain GOARCH=arm64 go build rather than a toolchain project.
What is embedded is cues, not memory bodies — a handful of short, question-shaped retrieval keys per memory, written at ingestion time. See Cue for why that alignment matters for retrieval quality; architecturally, it is also what keeps the vector table small: one namespace's index is sized by facts, not by characters of prose.
Retrieval: three arms, fused, no model call
GET /v1/retrieve runs three searches concurrently:
- Lexical (BM25) over the FTS5 table — exact wording, names, model numbers.
- Semantic over cue vectors — the same meaning in different words.
- Graph — a one-hop walk of the relationship edges from the top hits, finding memories connected to a match without resembling the query text at all.
Results are combined by reciprocal-rank fusion. This is the one call in the read path that talks to a model at all, and only for the query embedding — search itself is a local, single-digit-millisecond operation regardless of which arm wins. See Retrieval: three arms, fused for the API-facing detail, and Limits and behaviour for measured numbers.
Ingestion runs in stages, and only one of them needs ordering
POST /v1/memories hands a conversation to a four-stage pipeline: extract atomic
facts, cue each with retrieval keys, relate facts about the same real-world
entity, and reconcile — decide whether a new fact rewrites an existing memory or
becomes a new one. Reconciliation is the only stage that must run in session order,
which is why writes to one namespace serialize while different namespaces proceed in
parallel; it is what keeps one real-world subject as one file instead of a pile of
near-duplicates. See Memory for the extraction contract
itself, and Writes are asynchronous for
what that means for a caller.
One capability layer, every front-end thin over it
Every operation — write, search, semantic_search, related, tree, topics,
vocab, embed_pending, ingest, answer, and more — is defined exactly once, as a
self-describing op (a parameter schema plus a handler over the engine) in a single
toolkit package. Three things are built on top of it, and none of them implement an
operation twice:
- The CLI parses flags into the op's arguments and formats the result.
- The MCP servers (local and hosted) turn each op into a tool,
schema and all — an editor sees the same parameters a person reading
--helpwould. - The hosted API wraps the subset of operations meant for a multi-tenant service
(
write,retrieve, namespace management) behind HTTP, tenant isolation, and metering — see The platform for that layer.
A capability added to the toolkit appears in the CLI and every MCP server without anyone remembering to wire it up separately, and a parameter cannot describe itself differently to a model than it does to a person, because there is only one description.
Key design decisions
| Decision | Choice | Why |
|---|---|---|
| Git layer | pure-Go (go-git) | no CGo → clean cross-compilation, arm64-friendly |
| Index store | SQLite + FTS5, pure-Go driver | one transactional store for structured filters and BM25, so apply/rebuild stay atomic |
| Provenance | derived from git, never stored in frontmatter | the authoring commit does not exist until after the write; git log/blame is the provenance |
| Section addressing | path#slug, GitHub-style |
human-readable, and duplicate headers get -2/-3 suffixes rather than colliding |
| TTL expiry | enforced, through the single writer | the sweep's own commit, so reads never see a half-expired state |
| Write safety | one serialized writer (RWMutex) per repository | go-git worktrees are not safe for concurrent writers |
What "topics" are, and are not
The directory namespace under a tier (facts/database, facts/database/replication)
is open-ended by design — the engine imposes no fixed taxonomy, because ingestion
invents topics as it files memories. To keep near-duplicate topics from
accumulating (facts/databases beside facts/database), the engine exposes a
Topics query that enumerates existing topics with memory counts, so extraction can
check before inventing a new one. The directory tree is materialized with
incrementally maintained subtree counts, so this — and the tree navigation
Core concepts describes — is O(direct children), not a corpus
scan, at any depth.
Read next
- The platform — how this engine is deployed as a multi-tenant service: AWS architecture, tenant isolation, the language models behind ingestion and embeddings.
- Research and benchmarks — the numbers behind the claims on this page: indexing throughput at scale, and how much of this actually helps an agent answer correctly.