Skip to main content

Repository graph

Every time an agent spawns, it starts blind. It greps, it reads directory listings, it opens the same five files it opened on the last run — and it pays tokens for all of it. The repository graph is ferrus's answer: an optional, machine-local structural index that agents can query instead of rediscovering your codebase from scratch.

It is a rebuildable materialized view of one explicitly identified source state. Your source and Git remain authoritative. The graph may be missing, stale, incompatible, or deleted at any moment without changing task state — an absent index never blocks the Supervisor → Executor → Reviewer loop.

Two sidecars, one feature

The repository graph indexes current structure. Its sibling, project memory, indexes curated history — approved outcomes, decisions, milestones. They live in separate SQLite files, advance on independent revisions, and are queried through the same commands and tools.

Enable it

The graph is opt-in and not scaffolded by ferrus init. Add the namespace to ferrus.toml yourself:

[repository_graph]
enabled = true

That single line is enough — every other setting has a default. See Configuration for the full surface.

Build the index

ferrus graph index
Indexed repository graph
Snapshot: snapshot:7b39a63599ec38388b417269b82f330051507ca17b71ae25af95787b64130791
Freshness: fresh
Files: 265 discovered, 0 reused, 265 parsed, 0 skipped, 0 failed
Facts: 6913 nodes, 8200 edges, 700 diagnostics
Work: 2054529 bytes in 880 ms

Re-running it is incremental: unchanged files are reused rather than re-parsed, and a no-op build publishes the same snapshot identity.

ferrus graph index # incremental — reuses unchanged fragments
ferrus graph index --full # ignore the fragment cache and re-run every extractor

Facts land in repo-graph.db next to ferrus.db:

PathContents
~/.ferrus/projects/<project-id>/ferrus.dbRuntime source of truth (tasks, runs, leases)
~/.ferrus/projects/<project-id>/repo-graph.dbDerived structural facts — safe to delete
~/.ferrus/projects/<project-id>/project-memory.dbDerived curated history — safe to delete

Deleting a sidecar loses nothing but build time. ferrus graph index rebuilds it.

Git is not required

Indexing reads the filesystem through a confined source adapter. A clean Git tree lets ferrus reuse Git content identities, but a non-Git directory indexes fine — it just gets content digests instead.

What gets indexed

Three built-in extractors run today:

ExtractorCoversEmits
builtin.generic-structureEvery included file, any languagerepository, directory, file, document, manifest, configuration, entry_point
builtin.rust-syntaxRust sources, via tree-sittermodule, mod_declaration, struct, enum, trait, function, impl, constant, type_alias, import, re_export
builtin.cargo-manifestCargo.tomlcargo_workspace, cargo_package, cargo_target, declared_dependency

Relationships between them:

EdgeMeaning
containsStructural containment (directory → file, module → symbol)
classified_asA file's inferred role (document, manifest, configuration, entry point)
declares_moduleA mod x; declaration pointing at its module
imports / re_exportsuse declarations, split by visibility
declares_targetA Cargo package declaring a lib/bin/bench/test target
depends_onA declared Cargo dependency, classified internal / workspace / external

So a Rust project gets real symbol-level navigation, and any project gets structure, documentation, configuration, and entry-point mapping. Additional languages are extractor work, not architecture work — the identity, snapshot, and query contracts are language-neutral.

Semantic keys

Beyond opaque node IDs, most facts carry a human-typeable semantic key:

rust:struct:src/project.rs:project::RuntimeTaskContext
rust:file-module:src/project.rs
cargo:package:Cargo.toml:ferrus
cargo:target:Cargo.toml:bin:ferrus
file:README.md
configuration:11:ferrus.toml:toml

A semantic key is a best-effort lookup convenience, not a stable primary key. Renames, refactors, and parser changes may change it; opaque node IDs are scoped to one snapshot and are not promised to survive the next one either.

Query it from the CLI

Status first

ferrus graph status
Repository graph: Available
Snapshot: snapshot:7b39a63599ec38388b417269b82f330051507ca17b71ae25af95787b64130791
Freshness: Stale
Facts: 265 files, 6913 nodes, 8200 edges
Diagnostics: 0 info, 700 warnings, 0 errors

status is the only graph command that works while the graph is disabled or unbuilt — everything else requires enabled = true. When there is nothing to query it tells you what to run next (Next: ferrus graph index).

ferrus graph search RuntimeTaskContext --limit 5
Snapshot: snapshot:7b39a635...
Freshness: Stale
Diagnostics: 0 info, 700 warnings, 0 errors
Truncated: no
0.98 struct rust:struct:src/project.rs:project::RuntimeTaskContext
id=node:df6810fd... evidence=src/project.rs:361:1 resolution=Resolved confidence=Exact

Matching is ranked deterministically, best class first: exact semantic key → exact path → exact name → name prefix → name substring → semantic-key substring → path substring. Filters narrow it further:

ferrus graph search Task --kind struct --kind enum --path src --limit 20

Show

Look up everything evidenced by one node, symbol, or path:

ferrus graph show --node node:df6810fd...
ferrus graph show --symbol "rust:struct:src/project.rs:project::RuntimeTaskContext"
ferrus graph show --path src/project.rs

Context

Assemble a bounded, evidence-backed context packet around one or more seeds:

ferrus graph context --symbol "rust:struct:src/project.rs:project::RuntimeTaskContext" --depth 1
Snapshot: snapshot:7b39a635...
Freshness: Stale
Diagnostics: 0 info, 700 warnings, 0 errors
Truncated: Depth (2 results, 1974 bytes, depth 1)
struct rust:struct:src/project.rs:project::RuntimeTaskContext src/project.rs:361:1
selected=ExactSeed resolution=Resolved confidence=Exact extractor=builtin.rust-syntax@1.0.0+tree-sitter-0.26.11.rust-0.24.2
module rust:file-module:src/project.rs src/project.rs:1:1
selected=Containment resolution=Resolved confidence=Exact extractor=builtin.rust-syntax@1.0.0+tree-sitter-0.26.11.rust-0.24.2

Every item states why it was selected (ExactSeed, Containment, Declaration, resolved dependency, documentation, configuration, …) and which extractor produced it. Ranking is deterministic: selection reason, then depth, then evidence location, then kind, then key, then node ID. The same request against the same snapshot returns the same ordered results.

Neighbors

Walk a bounded neighborhood around one node:

ferrus graph neighbors node:599f0a1f... --direction both --depth 2 --limit 50
Nodes:
module node:599f0a1f... evidence=src/project.rs:1:1 resolution=Resolved confidence=Exact
struct node:df6810fd... evidence=src/project.rs:361:1 resolution=Resolved confidence=Exact
Edges:
contains node:599f0a1f... -> node:df6810fd... resolution=Resolved confidence=Exact

Machine-readable output

Every graph command accepts --json and emits one document containing the wire version, repository and snapshot identities, freshness, diagnostics, pagination/truncation state, and the operation payload.

ferrus graph status --json
ferrus graph search RuntimeTaskContext --json

Snapshots, builds, and freshness

The single most important thing to understand about the graph is that three states are orthogonal and never collapse into one "is it OK?" flag:

AxisValuesQuestion it answers
Availabilitynot_built, available, incompatibleIs there a published snapshot I can read?
Buildbuilding, complete, published, failed, supersededWhat happened to the most recent attempt?
Freshnessfresh, stale, unknown, not_applicableDoes the published snapshot still match the source?

This is why status can report "an available snapshot, plus a newer refresh that failed" without either fact erasing the other. A failed build never replaces the last published view, and partial facts are never visible to ordinary queries — publication is a compare-and-set against the expected current pointer, so an older build cannot overwrite a newer one.

Status also emits a machine-readable recommended action: index, wait_for_build, retry_index, refresh_index, or rebuild.

Freshness is computed by actually comparing the current source manifest against the snapshot's identity inputs — not inferred from events. Edit a file outside ferrus entirely and the next ferrus graph status still reports Stale.

A snapshot's identity is derived from the repository, its source manifest digest, the graph model version, the effective semantic configuration digest, and the extractor-set digest. Operational settings — query budgets, retention, telemetry, endpoints, credentials — deliberately do not participate. Two configurations that mean the same thing produce the same snapshot; changing a timeout does not invalidate your index.

Task views: what the executor sees

Executors work in isolated Git worktrees, so pointing them at a canonical latest index would be wrong. Instead, each task gets its own task repository view:

pinned baseline snapshot (the task's Ferrus baseline Git tree)
+ overlay (that worktree's changed / added / deleted files)
= task view
  • A newer canonical snapshot does not make a pinned baseline stale — other tasks advancing does not disturb this one.
  • Worktree edits make only that task's overlay stale.
  • Overlay deletions hide baseline nodes, edges, snippets, and search hits for the deleted path.
  • On submission, the view is frozen together with its Git tree, and review reopens it by identity. The reviewer reads exactly what was submitted.
  • If a task view is unavailable, the tool says so (not_built, stale, unavailable, failed) and directs the agent to inspect source directly. It never silently falls back to canonical data.

Canonical integration after approval marks the canonical snapshot stale and refreshes it best-effort. Graph refresh failure can never undo or fail a successful approval.

What agents actually get

When the graph is configured, ferrus serve exposes three read-only tools to Supervisor, Executor, and unfiltered servers alike:

ToolPurpose
repository_graph_statusAvailability, build state, freshness, task-view status, recommended action
repository_searchBounded ranked search — query, kinds, paths, budget caps, cursor
repository_contextBounded context assembly — seeds, direction, edge_kinds, include_unresolved, include_external, include_snippets

None of them resolves, claims, renews, or mutates a task lease. Graph output is never injected into task or review prompts — an agent must ask for it, and tool descriptions steer it to check status first and keep requests bounded.

Verified snippets

Structural results never embed file bodies. Source text is opt-in per request via include_snippets, and every excerpt is resolved through SnapshotContent: the reader holds a canonical source-root handle, rejects symlink traversal, reapplies the sensitive-path policy, and verifies size, mode, and SHA-256 before slicing the recorded byte span. An independent aggregate snippet-byte cap applies on top.

Content that changed, became unavailable, was excluded, or is not UTF-8 is omitted and reported as a bounded diagnostic (content.changed, content.unavailable, content.non_utf8). Unverified bytes are never returned.

Absence is not proof

The contract every consumer must respect: a missing node or edge means "not known by this snapshot and its capabilities" — not "does not exist."

Every fact carries its extractor ID and version, evidence path and span, resolution state (resolved / unresolved / external), and a confidence classification. Paths that cannot be represented as valid UTF-8 are skipped with a bounded diagnostic — an explicit capability limitation, not evidence that the file has no relationships.

That's also what the diagnostic counts in every response are for. The ferrus dogfood snapshot above carries 700 bounded warnings: places the extractors declined to guess.

Privacy and safety

The graph treats your repository as untrusted input:

  • Indexing never executes repository code, hooks, build scripts, compilers, or macros. It parses.
  • Source adapters confine paths to the selected root and do not follow symlinks outside it.
  • Sensitive-path policy is applied before extraction. The default excluded set covers **/.env, **/.env.*, **/*.key, **/*.pem, **/*.p12, **/*.pfx, **/id_rsa, **/id_ed25519.
  • Full source bodies are not stored in the sidecar — only identities, spans, and structure.
  • Logs, events, metrics, errors, and diagnostics carry IDs, counts, timings, and bounded codes — never source bodies, secrets, or absolute workspace paths.
  • Credentials, tokens, and endpoints never participate in snapshot identity and never appear in diagnostics.
  • Everything is local. Indexing and retrieval make no network call, load no model, and contact no service.

Budgets

Every query is bounded, and the configured service cap always wins over a client's request. When a budget runs out you get the deterministic prefix that fits plus an explicit truncation reason (Results, Bytes, Depth, Duration, Capability) — never a silent drop and never a generic backend error. A continuation cursor is issued only after at least one result was returned, and it is bound to the exact operation, snapshot, and normalized request shape.

Defaults, all tunable under [repository_graph.query_limits]:

KeyDefault
max_results100
max_bytes262144 (256 KiB)
max_snippet_bytes32768 (32 KiB)
max_depth3
max_duration_ms2000
max_diagnostics50

Measured behaviour

From the Criterion benchmark over a generated 300-module Rust fixture (cargo bench --bench repository_graph, macOS arm64, release profile):

OperationTimeParsedReused
Cold build (302 files)97.0 ms3020
No-op build49.1 ms0302
One-file change84.0 ms1301
Indexed symbol search238 µs

These are development baselines for catching accidental full re-extraction or unbounded queries — not release performance guarantees. Compare relative behaviour and fact counts before comparing wall-clock across machines.

A 26-case deterministic navigation evaluation gates retrieval quality on every change:

GateThresholdObserved
Exact path Recall@1100%100%
Exact unique-symbol Recall@1100%100%
Supported discovery Recall@10≥ 90%93.75%
Repeated same-snapshot determinism100%100%
No regression vs. navigation baseline100%100%
Median files-read or context-byte reduction≥ 20%100% files-read reduction

The honest caveat: on that same corpus, broad direction=both expansion currently returns more serialized graph evidence than a baseline source scan (median context-byte change ≈ -772%), even though it eliminates source-file reads entirely. That is a real limitation and it is why the graph stays a query tool rather than something ferrus injects into prompts for you.

Deliberately not here yet

  • Automatic context injection. Agents ask; ferrus does not push. Context-byte volume needs to come down first.
  • Embeddings and semantic search. Structural retrieval already covers the high-confidence paths, and a mandatory model runtime or vector extension is not worth adding before a measured recall gap exists. The architecture reserves a separate semantic revision so an embedding model change can never rebuild or rename a structural snapshot.
  • Remote / distributed indexing. The identity, snapshot, evidence, and bounded-query contracts are deliberately backend-neutral so a future service can partition storage and workers by tenant — but local ferrus never initializes a network client, and remote indexing would be strictly opt-in.

See also