|
| 1 | +# Enabling terraphim-grep Offline (lessons learned) |
| 2 | + |
| 3 | +**Date:** 2026-06-29 |
| 4 | +**Scope:** Getting `terraphim-grep` to return real results offline, with a project-specific knowledge graph, no server required. |
| 5 | + |
| 6 | +This is an operational lessons-learned note, not a design doc. It captures the |
| 7 | +exact failure mode that makes a freshly-installed `terraphim-grep` return zero |
| 8 | +results for every query, and the minimal setup to get KG-driven search working |
| 9 | +against any project. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## TL;DR — the one thing that bites everyone |
| 14 | + |
| 15 | +A stock `cargo install terraphim_grep` binary **does not search code**. |
| 16 | + |
| 17 | +`terraphim_grep`'s `Cargo.toml` gates the actual code scanner behind an opt-in |
| 18 | +feature: |
| 19 | + |
| 20 | +```toml |
| 21 | +[features] |
| 22 | +code-search = ["dep:fff-search"] # fff-search = the code scanner |
| 23 | +default = ["llm"] # <-- code-search is NOT default |
| 24 | +``` |
| 25 | + |
| 26 | +The default install compiles `search_code()` down to a stub that returns |
| 27 | +`Ok(vec![])`: |
| 28 | + |
| 29 | +```rust |
| 30 | +// terraphim_grep/src/hybrid_searcher.rs |
| 31 | +async fn search_code(..) { |
| 32 | + #[cfg(feature = "code-search")] { /* real fff-search */ } |
| 33 | + #[cfg(not(feature = "code-search"))] { let _ = (..); Ok(vec![]) } // <- you are here |
| 34 | +} |
| 35 | +``` |
| 36 | + |
| 37 | +So **every query returns `chunks_returned: 0` in `search_latency_ms: 0`**, with |
| 38 | +no error. Rebuild with the feature enabled: |
| 39 | + |
| 40 | +```bash |
| 41 | +cargo install terraphim_grep --version 1.20.5 --features code-search --force |
| 42 | +``` |
| 43 | + |
| 44 | +That is the fix. Everything below is diagnosis and project setup. |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## Symptom → diagnosis → fix |
| 49 | + |
| 50 | +| Symptom | Meaning | Fix | |
| 51 | +|--------|---------|-----| |
| 52 | +| `chunks_returned: 0`, `search_latency_ms: 0`, no error | fff-search is compiled out (no `code-search` feature) | rebuild with `--features code-search` | |
| 53 | +| `chunks_returned > 0` but `kg_hits: 0` | fff-search works; thesaurus not loaded / not matching | pass `--thesaurus`; check synonym terms | |
| 54 | +| `Graph has no nodes yet - no documents have been indexed` | rolegraph has no document nodes | **ignore for grep** — the thesaurus-only fallback still boosts; only matters for `terraphim-agent` | |
| 55 | +| `No thesaurus specified and could not find default` | `--thesaurus` missing and no auto-discovered file | pass `--thesaurus`, or place `.terraphim/thesaurus-<role>.json` | |
| 56 | +| `Failed to compute source hash for ...kg` | broken symlinks in the global KG dir poison the build | `find ~/.config/terraphim/kg -xtype l -delete` | |
| 57 | + |
| 58 | +**The single reliable signal** that fff-search never ran is |
| 59 | +`search_latency_ms: 0` combined with `chunks_returned: 0` for a query that |
| 60 | +*must* exist in the codebase. Real scans take single-digit-to-hundreds of ms. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## Architecture: why it works offline (and what fooled me) |
| 65 | + |
| 66 | +`terraphim-grep` is **offline-first**. The `terraphim-agent` help calls itself |
| 67 | +"server-backed", which mislead me into thinking grep also needed the server. It |
| 68 | +does not. |
| 69 | + |
| 70 | +``` |
| 71 | +terraphim-grep "query" --paths . --thesaurus t.json |
| 72 | + │ |
| 73 | + HybridSearcher::search() (parallel tokio tasks) |
| 74 | + ├── search_code() → fff-search over --paths → code_results (THE chunks) |
| 75 | + └── search_kg() → rolegraph query |
| 76 | + └─ if graph empty → thesaurus-only fallback |
| 77 | + │ |
| 78 | + boost_chunks_with_kg(code_results, kg_concepts) ← KG only RE-RANKS |
| 79 | + │ |
| 80 | + results |
| 81 | +``` |
| 82 | + |
| 83 | +Two consequences worth internalising: |
| 84 | + |
| 85 | +1. **Chunks come from fff-search, not from the KG.** The thesaurus/rolegraph |
| 86 | + only *boosts* (re-ranks) chunks. So an empty rolegraph is **not** fatal — |
| 87 | + there is an explicit thesaurus-only fallback in `search_kg` so boosting still |
| 88 | + fires when no documents are indexed. |
| 89 | + |
| 90 | +2. **The rolegraph rabbit hole is a trap.** I burned real time believing the |
| 91 | + `Graph has no nodes` message (and the missing `terraphim_server`) was the |
| 92 | + blocker. It was a red herring. The real blocker was the feature flag. When |
| 93 | + grep returns zero, look at the *code-search feature* first, the graph last. |
| 94 | + |
| 95 | +Source of truth (in the published crate, ~`~/.cargo/registry/src/.../terraphim_grep-<ver>/`): |
| 96 | +`src/hybrid_searcher.rs` (`search`, `search_code`, `search_kg`) and `Cargo.toml`. |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## Project setup: a self-contained `.terraphim/` |
| 101 | + |
| 102 | +Drop this into any project root. No global config edits required. |
| 103 | + |
| 104 | +``` |
| 105 | +.terraphim/ |
| 106 | +├── kg/ # KG concepts as markdown |
| 107 | +│ ├── provider.md |
| 108 | +│ ├── session.md |
| 109 | +│ └── ... |
| 110 | +├── thesaurus.json # compiled synonym -> {id, nterm} map |
| 111 | +└── config.json # (optional) Role for --role-config |
| 112 | +``` |
| 113 | + |
| 114 | +### KG markdown format |
| 115 | + |
| 116 | +One concept per file. The `synonyms::` line is what the thesaurus is built from: |
| 117 | + |
| 118 | +```markdown |
| 119 | +# Provider |
| 120 | + |
| 121 | +Abstract LLM backend implementing the `Provider` trait (`src/provider.rs`). |
| 122 | +Description, key files, related concepts. |
| 123 | + |
| 124 | +synonyms:: provider, llm backend, anthropic, openai, gemini, cohere, azure, bedrock, vertex, copilot, kimi |
| 125 | +``` |
| 126 | + |
| 127 | +### Thesaurus format (must match exactly) |
| 128 | + |
| 129 | +```json |
| 130 | +{ |
| 131 | + "name": "My Project Engineer", |
| 132 | + "data": { |
| 133 | + "provider": { "id": 100, "nterm": "provider" }, |
| 134 | + "llm backend": { "id": 100, "nterm": "provider" }, |
| 135 | + "anthropic": { "id": 100, "nterm": "provider" } |
| 136 | + } |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +Each synonym maps to `{id, nterm}` where `nterm` is the normalised concept. |
| 141 | +All synonyms of one concept share its `id`/`nterm`. Generate it from the |
| 142 | +markdown by parsing the `# Title` and the `synonyms::` line — a ~15-line |
| 143 | +Python script is enough (lowercase keys, dedupe, sequential ids). |
| 144 | + |
| 145 | +### Generate from KG markdown |
| 146 | + |
| 147 | +```bash |
| 148 | +python3 - <<'PY' |
| 149 | +import os, json, glob |
| 150 | +data, cid = {}, 100 |
| 151 | +for f in sorted(glob.glob("kg/*.md")): |
| 152 | + nterm = os.path.splitext(os.path.basename(f))[0] |
| 153 | + txt = open(f, encoding="utf-8").read() |
| 154 | + syn = next((l.split("::",1)[1] for l in txt.splitlines() |
| 155 | + if l.strip().lower().startswith("synonyms::")), "") |
| 156 | + for t in dict.fromkeys([nterm] + [s.strip().lower() for s in syn.split(",") if s.strip()]): |
| 157 | + data.setdefault(t.lower(), {"id": cid, "nterm": nterm}) |
| 158 | + cid += 1 |
| 159 | +json.dump({"name": "My Project Engineer", "data": data}, open("thesaurus.json","w"), indent=2) |
| 160 | +PY |
| 161 | +``` |
| 162 | + |
| 163 | +--- |
| 164 | + |
| 165 | +## Run it |
| 166 | + |
| 167 | +```bash |
| 168 | +cd /path/to/project |
| 169 | +terraphim-grep "session persistence" \ |
| 170 | + --paths . --thesaurus .terraphim/thesaurus.json -n 8 -C 2 |
| 171 | + |
| 172 | +# structured: |
| 173 | +terraphim-grep "provider" --paths . --thesaurus .terraphim/thesaurus.json --json |
| 174 | +``` |
| 175 | + |
| 176 | +A healthy result: |
| 177 | + |
| 178 | +```json |
| 179 | +{ "stats": { "search_latency_ms": 306, "chunks_returned": 5, "kg_hits": 3 }, |
| 180 | + "concepts": [ {"name":"provider"}, {"name":"sse"} ], "sufficiency": "SearchOnly" } |
| 181 | +``` |
| 182 | + |
| 183 | +KG-boosted chunks score above the fff baseline (1.0); a chunk whose path/content |
| 184 | +matches a thesaurus concept rises to ~3.0, so your project vocabulary directly |
| 185 | +shapes ranking. |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## Gotchas checklist |
| 190 | + |
| 191 | +- [ ] Rebuilt with `--features code-search`? (Default install is useless for search.) |
| 192 | +- [ ] `--thesaurus` passed and parsed? (`Loaded thesaurus with N entries` in debug log.) |
| 193 | +- [ ] `search_latency_ms > 0`? (Zero = fff-search never ran.) |
| 194 | +- [ ] Global KG broken symlinks cleaned? (`find ~/.config/terraphim/kg -xtype l -delete`.) |
| 195 | +- [ ] Query terms lowercased in the thesaurus? (Aho-Corasick matching is case-sensitive on the keys.) |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## Enriching the KG with ast-grep (code anchors) |
| 200 | + |
| 201 | +Prose synonyms only get you so far — a query for `ReadTool` or `AnthropicProvider` |
| 202 | +won't boost the right chunks unless those exact code identifiers are in the |
| 203 | +thesaurus. Use **ast-grep** to mine real identifiers and add them as anchors. |
| 204 | + |
| 205 | +### Why it helps |
| 206 | + |
| 207 | +KG-boosting (`boost_chunks_with_kg`) raises a chunk's score when its path or |
| 208 | +content contains a thesaurus key. Adding real code tokens (`ReadTool`, |
| 209 | +`ModelRegistry`, `AuthStorage`, `SseParser`, `AnthropicProvider`) means a query |
| 210 | +for that token matches the concept **and** ranks the actual definition file |
| 211 | +first. Observed: `ReadTool` → concept `tool`, `tools.rs` boosted to 3.0 vs 1.0 |
| 212 | +baseline; `AnthropicProvider` → concept `provider`, `providers/anthropic.rs` to |
| 213 | +4.0 (three concept hits). |
| 214 | + |
| 215 | +### Extract with ast-grep |
| 216 | + |
| 217 | +```bash |
| 218 | +ast-grep run -l Rust -p 'pub struct $NAME { $$$BODY }' src --json=compact > structs.json |
| 219 | +ast-grep run -l Rust -p 'pub enum $NAME { $$$BODY }' src --json=compact > enums.json |
| 220 | +ast-grep run -l Rust -p 'pub trait $NAME { $$$BODY }' src --json=compact > traits.json |
| 221 | +ast-grep run -l Rust -p 'impl $T for $NAME { $$$BODY }' src --json=compact > impls.json |
| 222 | +``` |
| 223 | + |
| 224 | +Captured names live at `match["metaVariables"]["single"]["NAME"]["text"]` (the |
| 225 | +top-level JSON is a **list** of matches; `metaVariables.single`, not a bare |
| 226 | +`NAME`). |
| 227 | + |
| 228 | +### Bucket by concept — selectively |
| 229 | + |
| 230 | +Map each identifier to a concept with **name-based** rules. Do **not** use |
| 231 | +file-wide catch-alls like `f.startswith("src/providers/")` — they flood the |
| 232 | +thesaurus with peripheral request/response types and metrics noise. |
| 233 | + |
| 234 | +```python |
| 235 | +rules = { |
| 236 | + "provider": lambda n,f: n.endswith("Provider"), |
| 237 | + "session": lambda n,f: "Session" in n, |
| 238 | + "extension": lambda n,f: "Hostcall" in n or n.startswith("Extension") or "Capability" in n, |
| 239 | + "model-registry": lambda n,f: "Model" in n, |
| 240 | + "sse": lambda n,f: "Sse" in n or n == "StreamEvent", |
| 241 | + "acp": lambda n,f: "Acp" in n, |
| 242 | + "auth": lambda n,f: "Auth" in n or "OAuth" in n, |
| 243 | + "interactive-tui": lambda n,f: ("Picker" in n or "Selector" in n or n == "PiApp") and "src/interactive" in f, |
| 244 | + "tool": lambda n,f: n.endswith("Tool") or n == "ToolRegistry", |
| 245 | + "hashline-edit": lambda n,f: n == "HashlineEditTool", |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +Append each bucket to the matching `kg/<concept>.md` `synonyms::` line |
| 250 | +(idempotently — only add lowercased terms not already present), then regenerate |
| 251 | +`thesaurus.json`. A working, idempotent version of this whole loop lives at |
| 252 | +`pi_agent_rust`'s `.terraphim/scripts/refresh-anchors.sh` — run it whenever the |
| 253 | +code changes to keep anchors aligned. |
| 254 | + |
| 255 | +### Verify |
| 256 | + |
| 257 | +```bash |
| 258 | +terraphim-grep 'ReadTool' --paths src --thesaurus .terraphim/thesaurus.json -n 3 --json |
| 259 | +# expect: concepts include 'tool', top chunk = tools.rs at score > 1.0 |
| 260 | +``` |
| 261 | + |
| 262 | +--- |
| 263 | + |
| 264 | +## Lessons, bluntly |
| 265 | + |
| 266 | +1. **A feature-gated no-op stub is indistinguishable from "working but empty"** unless you read the source. `Ok(vec![])` behind `cfg(not(feature))` returns success with zero items — no error, no warning. Always confirm a search tool actually *scans* (non-zero latency) before debugging results. |
| 267 | +2. **"Server-backed" in a sibling tool's tagline does not mean every CLI in the family needs the server.** terraphim-grep is offline-capable; the rolegraph-empty messages are advisory, not blocking. |
| 268 | +3. **Match the diagnostic to the layer.** Zero chunks → code scanner. Zero boost → thesaurus. Both are independent; fix them independently. |
| 269 | +4. **Keep the project KG in the repo** (`.terraphim/`), version-controlled. It is documentation that doubles as a search index. |
| 270 | +5. **The best thesaurus terms are the code's own identifiers.** Prose synonyms match intent; ast-grep-mined struct/trait/impl names match the actual text in chunks. Enrich the KG structurally and keep it fresh with a repeatable script — a hand-maintained thesaurus rots. |
| 271 | +6. **Selectivity beats coverage.** File-wide bucketing rules (`f.startswith("src/providers/")`) drown the thesaurus in noise; name-based rules (`n.endswith("Provider")`) keep it signal-rich. |
0 commit comments