Documents / Architecture / arch-1cfb1b212237
Doc-Index CLI — Architecture
The shape of the system: git as the source of truth, the SQLite index as a derived projection, the layer map and the daemon — with each part of the pipeline in its own document.
Principle¶
Git is the source of truth. The database is a derived, read-optimized index built on top of the markdown files. No data lives uniquely in the database — it can always be rebuilt from the files. AI agents never edit markdown files directly; all writes go through the CLI to guarantee schema consistency.
Diagram¶
The thesis in one picture: the files are the source, everything below them is a compile artifact, and there is exactly one path that writes markdown.
Solid edges are synchronous and current when the command returns. Dashed edges are the deferred or derived ones: a reindex makes the index agree with the files, the embedding queue catches up afterwards, and a peer store is read but never written.
Layers and responsibilities¶
| Layer | Responsible for | Not responsible for |
|---|---|---|
| Files (.md) | Content storage, git history, human readability | Fast search, structured queries |
| SQLite index | Search, filtering, relation graph, embeddings | Storing unique data |
| Daemon | Warm embedding model, async embed queue, request serialization | Being user-visible; holding canonical data |
| CLI | Agent contract, single write path, frontmatter consistency | Project business logic |
Daemon process¶
The CLI remains a thin, stateless client from the user's/agent's perspective
(docir <command> ...), but underneath it delegates to a long-lived local
background worker instead of doing heavy work cold on every invocation.
Why¶
the dominant cost of adding a semantic layer is not the embedding computation itself but reloading the ONNX model into memory on every process start — hundreds of milliseconds to over a second per call if done cold. A persistent process keeps the model warm and serializes writes.
Lifecycle¶
- On first invocation of any command, the CLI checks for a running daemon: a
pid file under
DOCIR_HOMEand a Unix socket. The socket is not under the home — a deep home path would blow past the platform's ~104-characterAF_UNIXlimit — so it is<tmpdir>/docir-<sha1(home)[:12]>.sock, short and stable per installation. - If not running, the CLI spawns it as a detached
python -m docir daemon serve, waits for the socket, then proceeds. That entry point must keep working: it is how every cold start recovers. - Subsequent commands connect to the existing socket — no spawn, no model reload.
- The daemon owns the SQLite connection and the loaded embedding model.
Requests are serialized through one
SerializingExecutor, which resolves write races without file locking. - The daemon is disposable: killed, missing, or answering on a stale socket, the CLI transparently respawns it. Where it cannot be started at all — a read-only sandbox with nowhere to put a socket — the client says so on stderr and runs the command in process instead. No command hard-fails because the daemon was not up, and none because it could not come up.
- An idle timeout (
DOCIR_IDLE_TIMEOUT, 900s) keeps it from lingering as a forgotten background process.
It also watches docs/¶
Hand-editing a file is permitted, and the window
between an edit and a docir reindex was one where every read answered from a
stale index with nothing saying so. Automating it is safe only because the files
are canonical: reindex writes no markdown, so it can only make the index agree
with them — which is why it defaults on (DOCIR_WATCH=0 opts out) rather than
being a flag. Two details are easy to undo by accident. The watcher and the
socket server share one executor, because the server serializes clients but
the watcher is a second writer and SQLite has one. And the watcher swallows
reindex failures on purpose: a half-written file is normal (editors save in two
steps) and the next batch fixes it, while an exception would end the thread
silently, leaving a daemon that looks healthy and has stopped watching.
A daemon that does not match the installed code is replaced¶
It loads docir
once and lives on, so after an upgrade or an edit under src/ it kept answering
from the old build — and a stale answer imitates a correct one. The pid file
records a stamp of the version plus the newest mtime across the package, and a
mismatch stops and respawns. The stamp a running daemon reports is the one it
started with, not what is on disk now.
Reaching the socket and waiting for the reply are timed separately¶
, and only
one is retryable. The connect is bounded at 5s, because a local AF_UNIX
connect succeeds at once or not at all; the reply is bounded by
DOCIR_REQUEST_TIMEOUT (300s) of silence, not of work: the daemon sends a
keepalive every five seconds while a request runs and each frame re-arms the
budget, so a whole reindex finishes however long it takes and the timeout fires
only when the daemon has stopped talking. One shared timeout meant every command
slower than 5s failed while the daemon completed it; a fixed reply budget later
meant the reindex inside docir self upgrade could not finish past roughly
1,500 documents (adr-56d29d521620). The two failures are
different exceptions on purpose: a refused connect means the request never
landed, so it is respawned and resent; an unanswered reply is never resent,
because the daemon still has it — a blanket retry killed it mid-transaction and
ran the command twice, which for a create meant a second document.
This keeps the "just run docir ..." UX simple — the daemon is an
implementation detail, never something to manage manually (though docir daemon status / docir daemon stop are useful escape hatches), and docir --no-daemon runs any command in-process.
Where the detail lives¶
This document is the shape — the principle, the layer map and the daemon. Each part of the system is now a document of its own, so a reader lands on the one that answers them instead of scrolling a single file.
arch-39314a23ba7f— the file format on disk: frontmatter fields, the tag registry.arch-0368cc754c15— the write path: id allocation, validation, the index update, diverged files.arch-03eeae8bf37d— the read path: ranking and fusion, section embeddings, visibility, peer stores.arch-ad342aae8293— validation strictness tiers, and schema drift against the index build.arch-7fd54a82f7d6— the CLI surface, the site build, and a worked end-to-end flow.