Two axes, one file
The graph lives at ~/.ryvem/dev.db (override the parent directory with RYVEM_DATA_DIR). There is nothing to install and no service to run — SQLite and its FTS5 index are compiled into the binary. Every fact sits on two axes at once:
- Structural — typed nodes (
task,branch,pr,review,decision,business-rule,codebase-map, …) connected by typed edges (about,depends-on,supersedes,belongs-to,implements, …). - Temporal — every node carries a validity window. An update never overwrites; it supersedes. Default queries see current truth; the past is a first-class query away.
A node has an id, a kind, a scope, an optional identity key, a title, a markdown body, a free-form data object, and timestamps. When a key is set, the node has a stable identity within its (kind, scope); an unkeyed node is an append-only row with no successor.
Supersede semantics
Writes are insert-or-supersede, keyed on the triple (kind, key, scope). Writing a node whose identity already has a current row does three things in one atomic transaction:
- closes the current row — sets its
valid_toto now and stamps itssuperseded_bywith the new row's id; - inserts the successor as the new current row (
valid_tois null); - records a
supersedesedge from the new row to the old one, so the lineage is explicit.
An unkeyed write always appends. A partial unique index enforces the invariant that matters: at most one current row per (kind, key, scope). Nothing is ever deleted — even retiring a node closes its window (optionally with a reason) rather than removing the row. When a decision changes, the old one becomes queryable history, not a lost commit message.
Nodes carry lineage; edges do not
Only nodes have a supersededBy successor. An edge update reinserts the edge and retires the old one by closing its window — there is no edge chain to walk.
Validity windows & time travel
Each row records valid_from and valid_to. The current row of any identity is the one whose valid_to is null. That single convention gives you three reads:
| Read | Returns |
|---|---|
| default search | only current rows (valid_to IS NULL) |
asOf(timestamp) | the rows that were current at that instant — the graph as it stood then |
history(id) | the full supersede chain of one identity, oldest first, each version with its window |
Any version id resolves to the whole chain, so you can hand history the current row or any ancestor and get the same lineage. This is how "what did we believe about this task last Tuesday" becomes a single query.
Scopes
Scope is a column on every node, not a folder on disk. That makes a cross-repository question — "every branch merged for this board across all repos" — one query rather than a directory walk. Three shapes exist:
| Scope | Holds |
|---|---|
global | facts not tied to one repository — repo identities, people, cross-cutting settings |
repo:<uuid> | everything about one repository — its local board, plans, reviews, codebase map, business rules |
board:<id> | facts tied to a specific board |
A repository's uuid is minted on first touch, recorded as a repo node in the global scope, and committed to ryvem.json so the identity survives a database reset or a fresh clone. Every read filter accepts a scope, so you narrow to one repository or open the whole graph at will.
Full-text search
Search runs over an FTS5 index that mirrors the title and body of current rows only. Triggers keep it honest automatically: inserting a node adds it to the index, and closing a node's window removes it — so superseded text never pollutes a default search. Results are ranked by BM25 and returned as compact hits: id, kind, title, a short snippet, and the score.
- Filter by one or more
kindvalues and byscope; both narrow the same query. - Omitting the text lists the most recent current rows for the given filters.
- Time-travel reads (
asOf/history) touch superseded rows that are not in the index, so they fall back to aLIKEtext match instead of BM25.
Read cheaply, expand deliberately
Search returns hits, not bodies. Above 50 matches it refuses and asks for a narrower query, so a broad question can never flood context. The agent expands exactly one node with memory_get, or a lineage with memory_history.
From an MCP client the graph is read through the memory family — see the MCP tools reference for full signatures:
| Tool | Does |
|---|---|
memory_search | BM25 search over current rows with kind / scope filters; returns compact hits |
memory_get | expands one node id into its full body and data |
memory_history | the supersede chain of a node, each version with its validity window |
repo_map | a budget-fitted, ranked symbol skeleton of the repository |
Graph export
The binary can emit the entire graph as a single, self-contained HTML file — no server, no tokens, no external requests, all CSS and JavaScript inlined. It is the human window into what your agent knows.
ryvem-mcp graph export --open
The command writes the file, prints its path, and (with --open) launches it in your default browser. It accepts three flags:
| Flag | Effect |
|---|---|
--out <path> | where to write the HTML — default ryvem-mcp-graph.html in the current directory |
--open | open the result in the OS default browser after writing |
--scope <scope> | restrict the export to one scope, e.g. global or repo:<uuid> |
The export includes every node version — current and superseded — and every edge, so the page can draw the full structural and temporal picture on its own. The same operation is available to agents as the graph_export tool. The render is deterministic: the same graph always produces a byte-identical document.
Inside the exported page
The page is an Obsidian-style force-directed view. Nodes are coloured by kind (a stable hue per kind) and sized by how many current connections they have; the header shows a live N nodes · M edges count for whatever the filters currently reveal.
- Direct manipulation. Drag a node to reposition it, drag the canvas to pan, and scroll to zoom.
- Physics panel. A gear toggle opens a glass card of live sliders — Repulsion, Link distance, Link strength, Gravity, Collision, Friction — plus Reset to defaults. It is collapsed by default and re-heats the layout as you drag.
- Filters. A kind legend with per-kind counts toggles kinds on and off; a scope selector narrows to one scope; a History toggle brings superseded nodes into view (they render as ghosts).
- Search that dims, not hides. Press
/to focus the box; matches stay lit while everything else dims, andEntercentres the strongest match.Escclears it. - Node panel. Clicking a node opens its rendered markdown body, a data summary, a clickable list of connections for navigation, and — when the node has predecessors — a History timeline that walks the supersede chain, each version tagged current or superseded with its timestamp.
- Theme. A toggle switches light and dark, defaulting to your system preference.
Why symbol-index is hidden by default
symbol-index nodes are the repository-map symbol cache — there can be a great many, and drawing them all would swamp the graph and bury the knowledge you came to read. They are excluded from the default view and the header count; the legend shows how many are hidden ("… hidden (repo-map cache) — click above to reveal"), and clicking the legend row brings them in.