Repo Mapping
Feature Definition
Section titled “Feature Definition”Every non-trivial AI coding session runs into the same wall: the repository is larger than the context window. You can’t send every file to the LLM on every turn — the cost is prohibitive, the latency is painful, and the model’s attention gets diluted to the point of uselessness. A 50,000-line codebase tokenizes to somewhere between 500k and 1.5M tokens depending on the language, which is far beyond what any model can effectively reason over even if it technically fits.
The naive solutions all fail. Sending only the currently-open file means the LLM has no awareness of the broader codebase — it can’t know that UserService is defined in src/auth/user.rs, that it’s referenced by twelve other modules, or that the function the user is asking about delegates to DatabasePool. Sending everything fails on cost. Sending a recent-files list is arbitrary. What’s needed is a structural synopsis — a compressed, semantically-ranked map of the codebase that answers “what are the most important things the model needs to know about this project right now?”
This is what repo mapping solves. The idea is to extract symbol-level information (function definitions, class declarations, type aliases, their locations, and where they’re used), rank those symbols by how relevant they are to the current conversation, and render a skeleton view of the most important ones — all within a tight token budget. Done well, the model can orient itself in an unfamiliar codebase, trace call chains, and avoid the hallucinated import paths that plague naive approaches.
The hard parts are:
- Extraction: Parsing dozens of languages reliably enough to find definitions and references without running a full compiler.
- Ranking: Deciding what “relevant” means given the current conversation context. High-frequency symbols like
new,index, oridare noise; the specific helper function the user just mentioned is gold. - Budget fitting: The map has to fit in a fixed token budget that may vary per model and per turn. Cutting it arbitrarily by line count doesn’t work — some files have dense one-liner definitions, others have sprawling doc comments.
- Caching: Re-parsing every file on every turn would be impossibly slow. The implementation needs efficient invalidation.
Aider Implementation
Section titled “Aider Implementation”Reference: references/aider/aider/repomap.py | Commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b
Aider’s RepoMap class is the most sophisticated implementation of this concept in any of the reference codebases. It’s been refined over hundreds of commits and handles edge cases that the other implementations simply don’t encounter. The core idea is a PageRank-based graph over files and symbols, personalized to the current chat context.
The Tag Data Structure
Section titled “The Tag Data Structure”Everything starts with a Tag — a namedtuple representing a single symbol in the codebase:
Tag = namedtuple("Tag", "rel_fname fname line name kind".split())kind is either "def" (a definition) or "ref" (a reference). A definition is where a symbol is declared — a function body, class declaration, or type definition. A reference is any other site where that symbol’s name appears. The line field is a zero-indexed line number within the file, used later for rendering the surrounding context. The distinction between rel_fname (path relative to repo root) and fname (absolute path) matters because the graph operates on relative paths for portability, while the cache and file reads need absolute paths.
Tag Extraction: Tree-sitter with Pygments Fallback
Section titled “Tag Extraction: Tree-sitter with Pygments Fallback”Tags are extracted in get_tags_raw(). The primary mechanism is tree-sitter with language-specific S-expression queries stored in .scm files alongside the package:
def get_tags_raw(self, fname, rel_fname): lang = filename_to_lang(fname) if not lang: return
language = get_language(lang) parser = get_parser(lang)
query_scm = get_scm_fname(lang) if not query_scm.exists(): return query_scm = query_scm.read_text()
code = self.io.read_text(fname) tree = parser.parse(bytes(code, "utf-8"))
captures = self._run_captures(Query(language, query_scm), tree.root_node)The .scm query files use a naming convention that maps to tree-sitter capture groups: any capture named name.definition.* yields a def tag, and name.reference.* yields a ref tag. The * wildcard covers language-specific sub-kinds like name.definition.function, name.definition.class, name.reference.call, etc. This allows the same loop to handle all languages uniformly:
for node, tag in all_nodes: if tag.startswith("name.definition."): kind = "def" elif tag.startswith("name.reference."): kind = "ref" else: continue
yield Tag( rel_fname=rel_fname, fname=fname, name=node.text.decode("utf-8"), kind=kind, line=node.start_point[0], )The Pygments fallback is triggered when tree-sitter produces definitions but no references — which happens for languages like C++ where the .scm query files only capture defs. In that case, Aider switches to Pygments lexing to backfill references by scanning Token.Name tokens:
if "ref" in saw: returnif "def" not in saw: return
# We saw defs, without any refs — use pygments to backfilllexer = guess_lexer_for_filename(fname, code)tokens = list(lexer.get_tokens(code))tokens = [token[1] for token in tokens if token[0] in Token.Name]
for token in tokens: yield Tag(rel_fname=rel_fname, fname=fname, name=token, kind="ref", line=-1)Note the line=-1 for Pygments-sourced refs. These tokens have no precise line number — Pygments is a syntax highlighter, not an AST parser — so they’re excluded from the line-of-interest rendering later.
There’s also a tree-sitter API compatibility shim. Between versions 0.23.2 and 0.24.0, captures moved from Query.captures(node) to QueryCursor(query).captures(node). Aider handles both:
def _run_captures(self, query: Query, node): if hasattr(query, "captures"): return query.captures(node) # Old API (0.23.2) from tree_sitter import QueryCursor cursor = QueryCursor(query) return cursor.captures(node) # New API (0.24.0+)The SQLite Tag Cache
Section titled “The SQLite Tag Cache”Parsing every file on every invocation is too slow for large repos. Aider uses diskcache.Cache (backed by SQLite) to persist tag results between runs, keyed by absolute file path and invalidated by mtime:
TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"
def get_tags(self, fname, rel_fname): file_mtime = self.get_mtime(fname) cache_key = fname
val = self.TAGS_CACHE.get(cache_key) if val is not None and val.get("mtime") == file_mtime: return self.TAGS_CACHE[cache_key]["data"]
# cache miss — parse fresh data = list(self.get_tags_raw(fname, rel_fname)) self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data} return dataCACHE_VERSION is bumped whenever the tag format changes, which forces a full rebuild. The cache directory is .aider.tags.cache.v3 (or v4 with the tree-sitter-language-pack). If the SQLite file becomes corrupted, the code falls back gracefully to an in-memory dict(). This three-tier fallback — disk cache → recreate cache → in-memory dict — is the kind of production hardening that took real-world breakage reports to build.
Graph Construction and PageRank
Section titled “Graph Construction and PageRank”With tags extracted, get_ranked_tags() builds a directed graph using networkx.MultiDiGraph. The nodes are files (relative paths). A directed edge from file_A to file_B means “file A references a symbol defined in file B.” The weight of that edge determines how much PageRank flows through it.
The weight multipliers are where Aider’s domain expertise shows up:
mul = 1.0
is_snake = ("_" in ident) and any(c.isalpha() for c in ident)is_kebab = ("-" in ident) and any(c.isalpha() for c in ident)is_camel = any(c.isupper() for c in ident) and any(c.islower() for c in ident)
if ident in mentioned_idents: mul *= 10 # User explicitly mentioned this symbolif (is_snake or is_kebab or is_camel) and len(ident) >= 8: mul *= 10 # Long, structured names are meaningfulif ident.startswith("_"): mul *= 0.1 # Private symbols are less interestingif len(defines[ident]) > 5: mul *= 0.1 # Defined in many files = probably a common utility
for referencer, num_refs in Counter(references[ident]).items(): for definer in definers: use_mul = mul if referencer in chat_rel_fnames: use_mul *= 50 # Strong boost for files already in chat
num_refs = math.sqrt(num_refs) # Dampen high-frequency refs
G.add_edge(referencer, definer, weight=use_mul * num_refs, ident=ident)The 50x boost for files already in the chat is the most impactful heuristic. If the user is editing src/parser.rs, then symbols referenced by src/parser.rs get a massive priority boost, pulling in the definitions those references point to. The sqrt damping on num_refs prevents common utility functions (called hundreds of times) from completely dominating the graph over more architecturally significant but less-called symbols.
PageRank then runs on this weighted graph, with a personalization vector for files mentioned in chat:
if personalization: pers_args = dict(personalization=personalization, dangling=personalization)else: pers_args = dict()
ranked = nx.pagerank(G, weight="weight", **pers_args)After PageRank, the rank flows are distributed from each source node back down to the specific (file, symbol) pairs:
for src in G.nodes: src_rank = ranked[src] total_weight = sum(data["weight"] for _src, _dst, data in G.out_edges(src, data=True)) for _src, dst, data in G.out_edges(src, data=True): data["rank"] = src_rank * data["weight"] / total_weight ident = data["ident"] ranked_definitions[(dst, ident)] += data["rank"]This gives a final score for each (file, symbol) pair — not just per-file — which is what lets Aider show only the most relevant symbols within each file rather than showing a file as a whole.
Binary Search for Token Budget Fitting
Section titled “Binary Search for Token Budget Fitting”The ranked tag list is sorted by score, then Aider needs to find how many tags fit within the max_map_tokens budget. Rather than cutting at a fixed count, it uses binary search over the rendered output, sampling the token count via the model’s tokenizer:
middle = min(int(max_map_tokens // 25), num_tags)while lower_bound <= upper_bound: tree = self.to_tree(ranked_tags[:middle], chat_rel_fnames) num_tokens = self.token_count(tree)
pct_err = abs(num_tokens - max_map_tokens) / max_map_tokens ok_err = 0.15 if (num_tokens <= max_map_tokens and num_tokens > best_tree_tokens) or pct_err < ok_err: best_tree = tree best_tree_tokens = num_tokens if pct_err < ok_err: break
if num_tokens < max_map_tokens: lower_bound = middle + 1 else: upper_bound = middle - 1
middle = int((lower_bound + upper_bound) // 2)The 15% error tolerance (ok_err = 0.15) is a practical concession — an exact fit would require too many iterations. The starting point of max_map_tokens // 25 assumes roughly 25 tokens per tag entry, which is a reasonable initial estimate. For large repos, the token counting itself uses a sampling strategy to avoid calling the tokenizer on the full output every iteration:
def token_count(self, text): len_text = len(text) if len_text < 200: return self.main_model.token_count(text)
lines = text.splitlines(keepends=True) num_lines = len(lines) step = num_lines // 100 or 1 lines = lines[::step] sample_text = "".join(lines) sample_tokens = self.main_model.token_count(sample_text) est_tokens = sample_tokens / len(sample_text) * len_text return est_tokensFor texts over 200 characters, it samples 1% of lines and extrapolates. This trades a small amount of accuracy for a significant speedup in the binary search loop.
Special Files and Rendering
Section titled “Special Files and Rendering”Before binary search runs, filter_important_files() pulls out root-level files like README.md, LICENSE, pyproject.toml, .github/workflows/*.yml, etc., and prepends them to the ranked tag list. These always appear in the map regardless of their PageRank score because they give the LLM essential project-level context.
The final rendering in to_tree() uses grep-ast’s TreeContext to show each symbol’s definition with minimal surrounding context, and truncates every output line to 100 characters to prevent minified JavaScript or generated code from blowing the token budget:
output = "\n".join([line[:100] for line in output.splitlines()]) + "\n"Codex Implementation
Section titled “Codex Implementation”Reference: references/codex/codex-rs/file-search/src/lib.rs, references/codex/codex-rs/core/src/project_doc.rs | Commit: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476
Codex takes a fundamentally different philosophy. Rather than automated ranking, it uses two complementary mechanisms: an interactive fuzzy file finder for user-driven search, and a hierarchical documentation convention (AGENTS.md) for developer-provided project context. There is no PageRank, no symbol graph, no automated tag extraction. This is a deliberate design choice — Codex is optimized for the case where a skilled developer is guiding the agent, not for autonomous exploration.
Interactive Fuzzy File Search
Section titled “Interactive Fuzzy File Search”The file-search crate implements a real-time fuzzy file finder using two background threads. The public API is create_session(), which sets up the concurrency structure and returns a FileSearchSession handle:
pub fn create_session( search_directories: Vec<PathBuf>, options: FileSearchOptions, reporter: Arc<dyn SessionReporter>, cancel_flag: Option<Arc<AtomicBool>>,) -> anyhow::Result<FileSearchSession> { // ... let nucleo = Nucleo::new(Config::DEFAULT.match_paths(), notify, Some(threads.get()), 1); let injector = nucleo.injector();
thread::spawn(move || matcher_worker(matcher_inner, work_rx, nucleo)); thread::spawn(move || walker_worker(walker_inner, override_matcher, injector));
Ok(FileSearchSession { inner })}The walker_worker thread uses the ignore crate’s WalkBuilder to traverse the filesystem in parallel. It respects .gitignore by default, follows symlinks, and feeds each discovered file path into nucleo’s Injector:
fn walker_worker(inner: Arc<SessionInner>, override_matcher: ..., injector: Injector<Arc<str>>) { let mut walk_builder = WalkBuilder::new(first_root); walk_builder .threads(inner.threads) .hidden(false) .follow_links(true) .require_git(false);
walker.run(|| { Box::new(move |entry| { // ... if let Some((_, relative_path)) = get_file_path(path, &search_directories) { injector.push(Arc::from(full_path), |_, cols| { cols[0] = Utf32String::from(relative_path); }); } ignore::WalkState::Continue }) }); let _ = inner.work_tx.send(WorkSignal::WalkComplete);}The matcher_worker thread drives the nucleo fuzzy matcher, which scores file paths against the current query. Communication between the UI and these workers flows through a crossbeam_channel using an enum of work signals:
enum WorkSignal { QueryUpdated(String), NucleoNotify, WalkComplete, Shutdown,}When the matcher detects that matches have changed, it builds a FileSearchSnapshot and calls the SessionReporter::on_update() callback. This means results stream into the UI progressively as the walk completes — you see matches appear as new files are discovered, not just after the entire filesystem has been walked:
pub struct FileSearchSnapshot { pub query: String, pub matches: Vec<FileMatch>, pub total_match_count: usize, pub scanned_file_count: usize, pub walk_complete: bool,}walk_complete: false in a snapshot means more results may still arrive. The TUI uses this to show a progress indicator until the walk finishes.
Query updates are handled incrementally. When the new query string starts with the previous query string (i.e., the user is refining their search by appending characters), nucleo can skip re-scoring candidates that already didn’t match:
WorkSignal::QueryUpdated(query) => { let append = query.starts_with(&last_query); nucleo.pattern.reparse(0, &query, CaseMatching::Smart, Normalization::Smart, append); last_query = query;}Hierarchical Project Documentation (AGENTS.md)
Section titled “Hierarchical Project Documentation (AGENTS.md)”project_doc.rs implements a convention for developer-authored project context. The discover_project_doc_paths() function walks upward from the current working directory to the Git repository root, collecting AGENTS.md files at each directory level:
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> { let mut dir = config.cwd.clone(); // Build chain from cwd upwards, detect git root let mut chain: Vec<PathBuf> = vec![dir.clone()]; let mut git_root: Option<PathBuf> = None; let mut cursor = dir; while let Some(parent) = cursor.parent() { let git_marker = cursor.join(".git"); if std::fs::metadata(&git_marker).is_ok() { git_root = Some(cursor.clone()); break; } chain.push(parent.to_path_buf()); cursor = parent.to_path_buf(); } // ...}The search order goes from the repo root down to the CWD (root-first), so that project-wide instructions appear before workspace-specific or crate-specific overrides. The collected files are concatenated by read_project_docs() with a byte budget enforced via tokio::io::BufReader::take():
let mut reader = tokio::io::BufReader::new(file).take(remaining);let mut data: Vec<u8> = Vec::new();reader.read_to_end(&mut data).await?;
if size > remaining { tracing::warn!("Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", ...);}There’s a priority order for filenames within each directory. AGENTS.override.md takes precedence over AGENTS.md, which takes precedence over any configured fallback filenames. This allows a developer to place a local override that won’t be committed to the repo (if .gitignored):
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { let mut names = Vec::new(); names.push(LOCAL_PROJECT_DOC_FILENAME); // "AGENTS.override.md" names.push(DEFAULT_PROJECT_DOC_FILENAME); // "AGENTS.md" for candidate in &config.project_doc_fallback_filenames { names.push(candidate.as_str()); } names}The result of read_project_docs() is concatenated with any CLI-provided system instructions via the separator "\n\n--- project-doc ---\n\n", and injected into the system prompt before the first agent turn.
OpenCode Implementation
Section titled “OpenCode Implementation”Reference: references/opencode/packages/opencode/src/lsp/index.ts, references/opencode/packages/opencode/src/lsp/client.ts | Commit: 7ed449974864361bad2c1f1405769fd2c2fcdf42
OpenCode’s approach to repo-level context is fundamentally dynamic rather than static. Instead of a pre-computed map built at startup, it queries live LSP servers on demand to extract symbol information at the moment the LLM needs it. The LSP namespace in packages/opencode/src/lsp/index.ts defines the data structures and orchestrates multiple language server processes.
The core Zod schemas define what OpenCode treats as “context atoms” for LLM injection:
export const Symbol = z.object({ name: z.string(), kind: z.number(), location: z.object({ uri: z.string(), range: Range, }),})
export const DocumentSymbol = z.object({ name: z.string(), detail: z.string().optional(), kind: z.number(), range: Range, selectionRange: Range,})The kind field maps to LSP’s SymbolKind enum (1 = File, 2 = Module, 3 = Namespace, 5 = Class, 6 = Method, 12 = Function, etc.). This is the same symbol ontology that IDE features like “Go to Symbol” use.
Server selection is managed per-language with feature-flag control. A notable example is the experimental switch between pyright and ty (Astral’s new type checker) for Python:
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => { if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { // Disable pyright in favor of ty if (servers["pyright"]) { log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled") delete servers["pyright"] } } else { if (servers["ty"]) { delete servers["ty"] } }}When a file is opened or referenced in a chat turn, OpenCode’s agent tools can issue LSP requests — textDocument/documentSymbol to list all symbols in a file, workspace/symbol to search across the whole project, or textDocument/references to find all usages of a symbol. These results are then formatted and injected into the message context, providing high-fidelity type-aware information that a static AST scan cannot match. An LSP knows about implicit type conversions, trait implementations, macro expansions, and cross-module visibility — things tree-sitter queries simply cannot capture.
The tradeoff is startup cost: LSP servers take one to several seconds to initialize and index the project before they can respond to queries. OpenCode accepts this latency because its target use case is interactive sessions where the user expects a certain amount of initialization time. For an automated agent that needs to answer its first question within milliseconds, this would be a significant problem.
Pitfalls and Hard Lessons
Section titled “Pitfalls and Hard Lessons”Token Estimation is Harder Than It Looks
Section titled “Token Estimation is Harder Than It Looks”Aider’s sampled estimation (token_count) is necessary but imprecise. A 15% error tolerance in the binary search means the map might use only 1700 tokens of a 2048-token budget on bad luck. The naive approach of len(text) / 4 is even worse — it breaks badly on languages with long identifiers (Rust, Haskell) or heavily commented code. The right answer is either a real tokenizer call on every iteration (slow) or a carefully calibrated heuristic (fragile). There’s no clean solution here.
Tree-sitter API Instability
Section titled “Tree-sitter API Instability”Aider’s _run_captures shim exists because tree-sitter broke its Python API between minor versions. Any Rust implementation using tree-sitter directly will hit similar issues as the library matures. Pinning crate versions in Cargo.lock is mandatory, and a compatibility test against a known fixture file is worth writing early.
PageRank on a Warm Graph is Fast; on a Cold Graph it is Not
Section titled “PageRank on a Warm Graph is Fast; on a Cold Graph it is Not”For a repo with 10,000 files and 100,000 cross-file references, building the networkx MultiDiGraph and running PageRank is measurably slow (multiple seconds). Aider mitigates this with the in-memory map_cache keyed on the exact set of chat files and mentioned identifiers. If nothing changed, the previous map is returned instantly. If the user adds a file to chat, the full computation reruns. This is acceptable for human-paced interaction but would be painful for a background re-ranking loop.
The AGENTS.md Convention Has an Injection Problem
Section titled “The AGENTS.md Convention Has an Injection Problem”Codex concatenates all AGENTS.md files from repo root to CWD and puts them in the system prompt unconditionally. There is a byte budget (project_doc_max_bytes) but no content filtering. A malicious or poorly-written AGENTS.md in a subdirectory can override instructions from the repo root, or simply consume enough of the context window to push out real working context. The AGENTS.override.md naming is a partial mitigation — a developer can place a local override — but it doesn’t address adversarial content in dependencies.
Graph Noise from Common Names
Section titled “Graph Noise from Common Names”Without the private-symbol penalty and the “defined in many files” penalty, identifiers like new, id, data, error, or index create enormous clusters in the graph that dominate PageRank. Aider’s mul *= 0.1 if ident.startswith("_") and mul *= 0.1 if len(defines[ident]) > 5 are blunt instruments that work well in practice but can mismatch on codebases with unusual naming conventions.
LSP Cold Start vs. Static Analysis Warmth
Section titled “LSP Cold Start vs. Static Analysis Warmth”OpenCode’s LSP-driven approach is more accurate than tree-sitter heuristics, but it’s only available after the language server has finished its initial indexing pass. In a large Rust workspace, rust-analyzer can take 30-90 seconds to be ready. During that window, LSP queries return empty or incomplete results. A robust implementation needs a fallback to static analysis during the warm-up period, not just a spinner.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”OpenOxide will combine the three approaches: automated PageRank ranking from Aider, high-performance async infrastructure from Codex, and optional LSP augmentation from OpenCode. The components below form the openoxide-repomap crate.
The Core Data Structures
Section titled “The Core Data Structures”/// A single extracted symbol — a definition or reference site.#[derive(Debug, Clone)]pub struct Tag { pub rel_path: PathBuf, // relative to repo root pub abs_path: PathBuf, // for file reads and cache keys pub line: Option<u32>, // None for Pygments-equivalent fallback refs pub name: String, pub kind: TagKind,}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum TagKind { Def, Ref }Tag Extraction: TagExtractor
Section titled “Tag Extraction: TagExtractor”The TagExtractor struct wraps tree-sitter parsers. Language grammars will be compiled in via the cc build script pattern used by tree-sitter-rust, tree-sitter-python, etc. For each language, we ship a tags.scm query file in src/queries/ following Aider’s naming convention so we can port their existing query files directly.
pub struct TagExtractor { parsers: HashMap<&'static str, (Language, String)>, // lang -> (grammar, query_scm)}
impl TagExtractor { pub fn extract(&self, path: &Path, content: &str) -> Vec<Tag> { let Some(lang) = detect_language(path) else { return vec![] }; let Some((language, query_scm)) = self.parsers.get(lang) else { return vec![] }; // parse, run captures, yield Tags }}For languages where tree-sitter only produces definitions (no reference captures), we fall back to a tokenizer-based reference scan using the logos crate, which is significantly faster than Pygments for our purposes since we only need Token.Name-equivalent tokens.
Caching: SQLite via rusqlite
Section titled “Caching: SQLite via rusqlite”Tags are persisted in .openoxide/tags.db, a SQLite database with a single table:
CREATE TABLE tags ( abs_path TEXT NOT NULL, mtime INTEGER NOT NULL, content_hash BLOB NOT NULL, tags_json BLOB NOT NULL, -- MessagePack-encoded Vec<Tag> PRIMARY KEY (abs_path));We use content hash (BLAKE3, 32 bytes) in addition to mtime because mtime alone can be fooled by filesystem operations that preserve timestamps. On cache miss, the file is re-parsed and the row is upserted. Cache reads happen on the Tokio thread pool via spawn_blocking to avoid blocking the async runtime.
Graph and Ranking: petgraph + Custom PageRank
Section titled “Graph and Ranking: petgraph + Custom PageRank”The file-level graph uses petgraph::graph::DiGraph<String, f64> (nodes are relative file paths, edge weights are accumulated symbol-level weights). We implement our own PageRank rather than pulling in networkx — petgraph doesn’t include one, and the algorithm is straightforward enough that a dependency isn’t warranted:
pub fn pagerank( graph: &DiGraph<String, f64>, personalization: &HashMap<NodeIndex, f64>, damping: f64, iterations: usize,) -> HashMap<NodeIndex, f64> { // standard power-iteration PageRank}The multiplier heuristics mirror Aider’s:
| Condition | Multiplier |
|---|---|
Symbol name is in mentioned_idents | ×10 |
| Name is camelCase/snake_case/kebab-case and length ≥ 8 | ×10 |
Name starts with _ (private convention) | ×0.1 |
| Defined in more than 5 different files | ×0.1 |
| Referencer is a file already open in chat | ×50 |
| Reference count damping | ×√(count) |
Token Budget Fitting: BudgetPacker
Section titled “Token Budget Fitting: BudgetPacker”BudgetPacker implements the same binary-search strategy as Aider. We use the tiktoken-rs crate (wrapping OpenAI’s tiktoken) for accurate token counts, with the same sampling optimization: for outputs over 512 tokens estimated by character count, sample 1% of lines and extrapolate.
LSP Augmentation
Section titled “LSP Augmentation”If an LSP client is running for a given language (managed by a separate openoxide-lsp crate), TagExtractor defers to it for symbol extraction instead of running tree-sitter. LSP symbols are higher fidelity — they include resolved types, trait implementations, and macro-expanded code that static parsing misses. The integration happens at the extract() method level: if an LSP client is available and warm for path’s language, issue a textDocument/documentSymbol request; otherwise fall back to tree-sitter.
This means the graph quality improves over the lifetime of a session as language servers finish indexing. The map produced at turn 1 (tree-sitter only) will be less precise than the map at turn 5 (LSP-augmented), which is acceptable — by turn 5 the model has also accumulated more context from the conversation itself.
Deterministic Testing
Section titled “Deterministic Testing”Every ranking heuristic is tested with a fixture-based approach. The test fixtures in tests/fixtures/ contain small synthetic repositories with known symbol graphs, expected PageRank scores, and expected rendered outputs at various token budgets. These tests catch regressions when multiplier constants are adjusted and make it easy to reason about what the ranker will do on edge cases.