How greppy works
greppy is a native Rust binary with two modes. An invocation that is not one of greppy's commands runs the real system grep. Everything else is a structured command over an index that greppy builds and keeps for each repository.
Ordinary grep
Any invocation that is not a greppy command runs the real grep and returns its output and exit code unchanged. It opens no index, loads no model and touches no greppy cache: in an empty HOME, greppy -rn hello . does not create a single file. ripgrep-style invocations go to a real rg when one is installed. The details are in grep and ripgrep passthrough.
A precomputed code graph
An indexed, typed symbol graph with the edges CALLS, USAGE, TYPE_ASSIGN and IMPORTS answers who-calls, callees, impact and path directly. You get resolved relationships with file:line, not text matches. Structural edges such as DEFINES and CONTAINS_FILE come on top. greppy stats shows what is in the graph.
$ greppy stats
project: serde
files: 339
nodes: 4573
…
Function 731
…
Method 950
…
edges: 9427
BOUND 31
CALLS 1410
CONTAINS_FILE 221
CONTAINS_FOLDER 43
DEFINES 4814
IMPLEMENTS 11
IMPORTS 72
TYPE_ASSIGN 77
USAGE 2748
Tree-sitter parsers for more than 60 languages are compiled into the binary; Languages lists what each tier extracts. The graph lives in a local SQLite store outside your repository. greppy does not copy full source text into it: exact code search (search-pattern) reads the current worktree through the real grep.
Semantic search and navigation hints
Every binary contains two models:
| Model | Job |
|---|---|
| Google's EmbeddingGemma (300M) | search finds definitions by their meaning, that is, code you can describe but not name |
| Qwen3.5-0.8B (Q4_K_M, MTP), fine-tuned by greppy through distillation | writes a short purpose hint under every returned function signature and for every definition brief prints |
The weights are on Hugging Face (EmbeddingGemma, greppy-Qwen3.5), pinned by SHA-256 in MODEL_ASSETS.json and fetched before the build. Release packages already contain them. greppy downloads nothing at runtime.
Inference is local Rust with bundled Metal and CUDA kernels: no llama.cpp runtime, no Python, no HTTP, no model server. In production it runs on Metal on Apple Silicon and on CUDA on Linux with an NVIDIA GPU.
Evidence and hints
Source bytes and graph relations are the evidence. The hints are not: in the output, a sentence after an em dash is a generated hint, not source. greppy treats hints as untrusted and leaves them out when inference or validation fails. A failed inference never removes deterministic source or graph output.
brief shows both side by side. visit_content_map has no doc comment, so the first sentence is a generated hint. The signature is literal source, line 1111 names the call site with the resolved symbol, and a hint follows it again.
$ greppy brief visit_content_map
Visits a map of content items and returns the resulting value.
serde/src/private/de.rs:1103-1110
fn visit_content_map<'de, V, E>(
content: Vec<(Content<'de>, Content<'de>)>,
visitor: V,
) -> Result<V::Value, E>
where
V: Visitor<'de>,
E: de::Error,
{
1111 MapDeserializer::new — constructs a MapDeserializer from a vector of Content values
}
called by ContentDeserializer::deserialize_any, ContentDeserializer::deserialize_map and ContentDeserializer::deserialize_struct
Shared inference daemons
Indexing, search, hints and bash-smart send model work to user-wide local daemons over a Unix-domain socket. Client processes never load a heavy model themselves as a fallback. Requests are bundled into micro-batches and served round-robin per client. The queue has no capacity limit at which requests get rejected.
A model stays loaded for 300 seconds without a request; a daemon process exits after 1,800 seconds idle. greppy doctor --json shows each daemon's backend, state, socket and queue policy.
$ greppy doctor --json | jq '.inference.daemons | map_values({backend, state, queue_policy})'
{
"embedding": {
"backend": "metal",
"state": "ready",
"queue_policy": "fair-round-robin-unbounded"
},
"summary": {
"backend": "metal-q4k-forward",
"state": "ready",
"queue_policy": "fair-round-robin-unbounded"
}
}
Since 0.4.1, embeddings and hints also go through a user-wide, content-addressed cache, bound to the exact model, prompt, task profile and input. An unchanged definition is computed once across repositories and worktrees; the daemons see genuine cache misses and nothing else. greppy index reports the split. Here greppy indexes a fresh copy of serde, which had already been embedded on this machine.
$ greppy index .
…
embedded 2443 code spans (0 local-store reused, 2443 global-cache hits, 0 inference misses, 4573 considered, 2150 non-definition skipped, 0 missing-file, 0 invalid-span, 0 oversize, 0 failed, 0 stale pruned)
The index lifecycle
The graph is built once per repository and reused across sessions. A first graph query builds it when needed, and graph and symbol commands answer as soon as it is published. The embeddings for semantic search are built by an explicit greppy index or by the first search that finds them missing.
In 0.4.1 the lifecycle is observable:
- A first graph query that has to wait for the build relays its phases on stderr, for example
writing_graph: 154/219 files; phase ETA about 40s. If indexing cannot start, the query exits with the temporary code 75 and names the condition for the next attempt. - An explicit
greppy indexreports its first phase, its PID and the status command right away. greppy index status --jsonnever blocks during a build. It exits 75 and reports"status": "indexing"and abackground_jobrecord with the current phase (state), its unit (progress_unit), the completed and total amount and, once known, a phase ETA.- A job whose progress record does not change for two minutes is marked as possibly stalled.
This is what it looks like when a script polls the status every 0.25 s during a greppy index .:
greppy index . > /dev/null 2>&1 &
while sleep 0.25; do
s=$(greppy index status --json); rc=$?
echo "$rc $(echo "$s" | jq -c '[.status, .background_job.state, .background_job.completed_spans, .background_job.total_spans]')"
[ $rc -eq 75 ] || break
done
75 ["indexing","extracting_files",0,219]
75 ["indexing","extracting_files",0,219]
75 ["indexing","writing_graph",0,219]
75 ["indexing","writing_graph",0,219]
75 ["indexing","writing_graph",219,219]
75 ["indexing","building_files",0,339]
75 ["indexing","resolving_file_imports",1,1]
75 ["indexing","counting_embeddings",0,0]
…
75 ["indexing","embedding",0,2443]
75 ["indexing","checking_integrity",0,0]
0 ["ok",null,null,null]
Semantic search never returns partial hits. Automation waits until greppy index status --json exits 0 and reports "healthy": true and "embedding_complete": true.
Freshness without a file watcher
Every query checks the index against the working tree. When indexed spans no longer match the files, greppy fails closed instead of answering from stale source: a query that cannot refresh the index exits with the temporary code 75 and returns no results.
Since 0.4.1, greppy heals small changes directly in the query. In the following serde example, an ordinary editor (not a greppy command) inserts a new function visit_content_map_logged after visit_content_map in serde/src/private/de.rs, and the new function calls visit_content_map. Without greppy index, the next query knows the new caller, and the three known call sites appear on their new lines.
$ greppy who-calls visit_content_map
serde/src/private/de.rs:1152 ContentDeserializer::deserialize_any
serde/src/private/de.rs:1399 ContentDeserializer::deserialize_map
serde/src/private/de.rs:1415 ContentDeserializer::deserialize_struct
$ greppy who-calls visit_content_map
serde/src/private/de.rs:1125 visit_content_map_logged
serde/src/private/de.rs:1163 ContentDeserializer::deserialize_any
serde/src/private/de.rs:1410 ContentDeserializer::deserialize_map
serde/src/private/de.rs:1426 ContentDeserializer::deserialize_struct
A large refresh starts a single observable background job, and the query returns bounded guidance for the next attempt instead of hiding a long rebuild. The embeddings of changed definitions are built by greppy index or by a search that finds them missing. Until then, greppy index status --json reports exit 73 with "embedding_complete": false. In the example above, the next search embedded the new function and returned it as the first hit, and the status then reported 0 again. read refuses a graph that is refreshing with a temporary error instead of combining current bytes with a stale span.
What stays on your machine
The embedded models compute the index, symbol graph, embeddings and hints locally. Code navigation makes no network calls, sends no telemetry and needs no account. Local data, privacy and uninstall describes what is stored and how you remove it.