Every coding agent starts a task in the same state: it knows nothing about the folder in front of it. What happens in the next five tool calls usually decides whether the next fifty are productive. This article is about filemap, a small Rust binary we built for the Pi coding agent that replaces those first five calls with one — and why a map of a repository beats both grep and paged file reads as the opening move for an agent.
TL;DR
filemapturns any folder into one indented map — a row per entry, nesting shown by indentation: the directory tree up to a byte budget, plus one symbol row per function and top-level variable —f@149 > proxy : Forward one request...— with the definition's own docstring or comment attached.- One call, ~2 KB mapped a module whose four Python files sit beside a 120 MB
.venv: 65 definitions across 4 files, walking past the virtualenv without emitting a line for it. The same question asked togrepreturned 189 KB across 1,214 hits; asking "where are the functions" returned 5.1 MB. - Depth is dynamic, not fixed: the walk deepens until the map reaches a byte budget (50 KB default,
budgetBytesoverrides), so a small folder comes back complete and a huge tree returns the deepest structure that fits — ending with an honest... limit exceededfooter that names the folders it did not reach. - Symbols are a signal, not noise: only git-tracked, non-library files contribute. Test, spec, vendored, and generated trees are skipped. Summaries are only emitted when a comment exists — the parser never invents prose.
- Every row is line-anchored, so the follow-up is one targeted
read(offset: 149), not another sweep. In our agent stack the same map is force-injected into every subagent's system prompt as a<repo-map>block, so parallel workers never explore from scratch.
Table of contents
- The problem: agents arrive blind
- What filemap actually emits
- The symbol layer: definitions with their own docs
- One format, five languages
- Dynamic depth on a byte budget
- Inside the binary: small parsers, hard caps
- The measured case against grep
- From map to targeted read
- Honest limits
- Using filemap
The problem: agents arrive blind
Ask an agent "where is request forwarding handled in this service?" and watch what a toolchain without a map does. The instinctive move is grep:
grep -rn 'memory' .
On a real module of ours — four Python files beside a 120 MB virtualenv — that single pattern returned 1,214 hits across 189,459 bytes. The first hits were not in our code at all; they were inside .venv/lib/.../site-packages, because grep does not know what belongs to you. Grep has four structural failure modes when it is used for discovery rather than search:
- Flat hits, no ownership. A hit list does not tell you which file is the entry point and which is a test double. Ownership has to be inferred by opening things.
- It matches everything, including what you did not mean. Comments, string literals, fixture data, and three vendored copies of the same library all match with equal confidence.
- One call per pattern. You rarely know the right token before you understand the code. Discovery by grep is a loop: guess a word, scan, guess again.
- Absence is invisible. If grep returns nothing, you have learned nothing about what does exist — only that one guess was wrong.
The other default move — reading files — is worse in a different way. Reading all four Python sources in that module costs 45,821 bytes to learn what a 2,167-byte map states outright. Paged reads spend most of their tokens on lines that do not matter, and the agent still has to synthesize the directory structure by hand.
What an agent actually needs at the start of a task is not search results. It is the shape of the repo: what folders exist, what files live in them, what functions those files define, and where each definition starts. That is a map, and maps want to be cheap.
What filemap actually emits
filemap is a ~480 KB zero-dependency Rust binary. You point it at a folder and it prints folders first, then files, alphabetically, honoring .gitignore chains inside git repositories (standard ignores outside them). Under each source file it nests that file's symbols, indented two spaces, one line each. Here is real output on our context-router module — rows verbatim, order preserved, abridged for the page:
/home/ubuntu/src/sgai-pi/modules/ctx-engine (git ignores, depth<=8, 24 items, 8.6 KB)
backend/
app.py
f@74 > minify : Collapse whitespace outside string literals in serialized JSON payloads.
f@109 > _record : Bump one counters bucket and refresh the ring buffer timestamp.
f@149 > proxy : Forward one request, stripping managed chat messages before upstream dispatch.
f@197 > lifespan : Own the client and concurrency gauge for the server process lifetime.
v@69 > STATS
v@203 > app
backend/utils.py
f@127 > repair_tool_pairs : Drop results whose call was cut and calls whose results all went with it. A non-block-aligned cut leaves an orphan `tool` message and providers reject the whole request; stranded results become plain user text so their payload survives.
v@25 > MEDIA_TYPES
Read that header row first: (git ignores, depth<=8, 24 items, 8.6 KB) tells you the ignore mode, the depth actually reached, the entry count, and the size you just spent. Then look at what one screen bought you:
- The service has a
backend/with exactly three Python files —app.pyis HTTP orchestration,utils.pyis wire utilities. That division is now known without opening either file. f@149 > proxy : Forward one request...— the module's forwarding entry point is easy to find: it lives inapp.py, it starts at line 149, and its one-line docstring says what it strips before dispatch.v@25 > MEDIA_TYPEScarries no summary because no comment exists above it. The absence of prose is itself information: this constant is self-explanatory or undocumented.
The answer to "where is request forwarding handled?" is one line of the map: proxy is the module's obvious entry point for that question — its own docstring says it forwards requests — and the map names the file, the line, and that claim in a single row.
The symbol layer: definitions with their own docs
The f@ and v@ rows are the part a hit list cannot carry: declaration sites, with their authors' own words attached. The format is deliberate:
f@LINE > name : first ~100 words of the definition's own doc
f@149— a function whose definition starts at line 149, 1-based. Not "a match on line 149"; the definition.> proxy— the declared name, including nesting, soinner helperfunctions inside factories are listed too.: Forward one request...— mined from the declaration's own documentation: the Python docstring, the JSDoc block, the#or//comment directly above, or a trailing comment. Capped at 100 words, preferring the first sentence. When the author wrote a one-liner like "Return up to five relevant memories. Reasoning off; bounded foreground latency." — you get exactly that, and it fits in one line of context.
The property that makes this trustworthy: the parser never invents prose. A symbol with no comment ships as a bare row (f@104 > getJson). A symbol with a docstring ships with that docstring, lightly truncated at a sentence boundary. There is no middle ground of generated fluff, which means every summary in the map is a claim the source code itself makes.
This is why the map composes so well with a follow-up read. read("app.py", offset: 149, limit: 40) lands directly on proxy and its body. The map is the index; the read is the page.
One format, five languages
The extraction rules are per-language but the output format is universal — a subagent reading the map never needs to know which parser produced a row:
| Source | Functions (f@) |
Variables (v@) |
Summary source |
|---|---|---|---|
| Python | def / async def, any nesting |
module level (column 0) | docstring, else # above, else trailing |
JS / TS (.js .mjs .cjs .ts .tsx .jsx) |
function, any nesting, export/async prefixes stripped |
const / let / var at top level |
JSDoc /** */, else // above, else trailing |
Svelte (.svelte) |
JS/TS rules inside every <script> block |
top level of the script block (base indent, not column 0) | same as JS/TS |
| Svelte + HTML markup | — | element id attributes → v@LINE > the-id |
<!-- --> above, else trailing |
The HTML id extraction is the newest layer, and it exists because markup is code too. A Svelte panel with id="panel-settings" at line 1309 becomes v@1309 > panel-settings — the agent learns the DOM anchors of a component the same way it learns the function names. The rules are strict on purpose: data-id and aria-id do not match (attribute boundaries are enforced), id={dynamicExpr} is skipped because a template expression is not a name, repeats are deduped, and anything inside <script> or <style> bodies is masked so a JavaScript string containing id="..." never leaks a phantom element. Self-closing <script src="..." /> is not paired with a later </script>.
Dynamic depth on a byte budget
Most tree tools make you guess a depth, and every guess is wrong in one direction: depth 2 hides the file you need, depth 6 buries you. filemap refuses the guessing game. The walk deepens until the map reaches a byte budget — 50 KB by default, budgetBytes to override, 0 for unlimited — under a per-directory quota so one crowded folder cannot eat everything, with hard ceilings (maxDepth 8, maxItems 100000) as safety rails, not targets.
Measured on one binary across two real folders:
| Folder | --budget |
Emitted | Behavior |
|---|---|---|---|
| small module (7 entries) | 100 B | 937 B | One entry admitted — a file and its symbol block are atomic; the budget never splits a file's symbols mid-block. 300 B and 900 B land on the same 937 B floor |
| small module (7 entries) | default 50 KB | 2,167 B | Complete: 7 items, 65 symbols, no footer |
| extensions repo (140+ entries) | 2,000 B | 1,882 B | Walk stopped at depth 3 with a footer naming what remains |
That footer is the honest part. When the budget runs out, the map does not silently truncate — it states the debt, naming the leading folders it did not reach (a +N more count covers the rest):
... limit exceeded: 39+ entries pending · pending folders: tool-filemap/native/tests/, tool-search/,
tool-subagentx/, util-compaction/, util-frankenstein/, util-questionnaire/, +2 more — raise --budget
/--max-items, or map a narrower path
The agent reading that footer knows three things at once: the map is incomplete, exactly which folders were not reached, and what to do about it (raise the budget, or narrow the path). A small folder still comes back complete and exact, because the walk only stops early when the budget forces it.
Inside the binary: small parsers, hard caps
filemap is not a language server, and that is a feature. Language servers index everything, weigh hundreds of megabytes, and carry a runtime; agents need the opposite — a single static binary, no daemon, single-digit-millisecond startup on our hardware, deterministic output for the same tree and flags. The whole tool is one Rust crate with a parser directory that reads like a table of contents:
native/src/parse/
py.rs — docstrings, #-comments, def/async def at any indent
js.rs — JSDoc, //-comments, function/const/let/var, TS-aware
html.rs — <!-- --> comments, id attributes, script/style body masking
mod.rs — the Summary type: 100-word cap, first-sentence preference
Three hard caps keep output predictable no matter how pathological the input: 100 symbols per file, 1,000 symbols per run, and summaries capped at 100 words. Past the per-file cap you get one bare ellipsis row; past the global cap the footer says how many symbols were held back.
The signal gate sits in front of the parsers. Inside a git repository, symbol extraction runs only on git-tracked files — git ls-files is consulted lazily, once per run — and paths through node_modules, vendor, third_party, site-packages, tests, spec, fixtures, __tests__, or benchmarks directories are skipped, as are .d.ts, .min.js, .spec., and .test. files. The result is that the symbol layer is a claim about your code, not the libraries you happened to install. Outside git, standard ignores apply and the same library/test directories are filtered.
The measured case against grep
Same module, same question — "what is in this repo and where are its definitions?" — four strategies, measured:
| Strategy | Calls | Bytes into context | What you actually learn |
|---|---|---|---|
grep -rn 'memory' . |
1 | 189,459 | 1,214 flat hits, first ones inside .venv |
grep -rn '^\s*def ' . |
1 | 5,107,994 | 37,471 hits — 37,443 of them (99.9%) inside .venv |
| Read all 4 Python sources | 4 | 45,821 | everything, including every line that does not matter |
filemap . |
1 | 2,167 | 7 map entries (4 Python files), 65 line-anchored definitions, structure included |
The greps lose before they even get to bytes: after them you still do not know that a usage.py exists, that test_memory.py is the test surface, or which file owns the memory API. The map states all three in 2 KB. And on the biggest module we map daily — 24 items, 147 symbols — the complete annotated map is 8.6 KB against 119 KB of source: a 13× saving over reading the module's files, with docstrings attached to the rows.
Where grep wins in this comparison is a narrow, already-known token — search, not discovery. But agents live in discovery at the start of every task.
From map to targeted read
The map changes the read that follows it. Without a map, the read is a survey: page through a file, hope the relevant part is in this chunk, page again. With a map, the read is a strike:
map row: f@271 > get_memory : Return up to five relevant memories. Reasoning off; bounded foreground latency.
next call: read("dynamic_memory.py", offset: 271, limit: 30)
Two calls total — one map, one anchored read — and the agent is looking at the exact function with its guard conditions. This is the pattern our agent stack automates: every subagentx child process gets the working directory's filemap force-injected into its system prompt as a <repo-map> block (built per call from a 32,000-character budget, best-effort — a missing binary degrades to an empty block, never a failed launch, and an oversized map is cut at a line boundary with a pointer to run the tool for the full tree). Explorer subagents receive it read-only; workers receive it beside their write tools. The consequence is that parallel subagents stop re-deriving the layout of the repository from scratch, one ls and one grep at a time, and the parent's context is never spent on structure that one call could state.
The tool description the model itself reads closes with the instruction that makes the economics work: use it first, then read precisely.
Honest limits
A map is a compression, and every compression loses something. What filemap does not do:
- It is a syntactic parser, not a compiler. It reports positions, not identities. Two declarations with the same name in one file both appear (we found a real duplicate
makeQuestionLockin our own code this way). It does not resolve imports, types, or call graphs — if you need "who calls this," you still need a search after the map. - Summaries are only as good as the source's comments. A wrong docstring is reproduced faithfully. The parser is honest, not clairvoyant.
- The byte budget has a floor. Entries are admitted atomically, so a request for 100 bytes can return ~937 — one entry, whole. The budget is a target above that floor, never a guarantee below it.
- Test filtering is directory- and suffix-based.
tests/,spec/,.test.,.spec.and friends are skipped, but a root-leveltest_*.pyoutside atests/directory can still contribute symbols — the gate filters by path shape and file suffix, not by naming convention, in or out of git. - Untracked files contribute no symbols. Inside a git repository, freshly generated or not-yet-committed sources appear in the tree but are invisible to the symbol layer; commit them and they gain symbol rows like anything else. Generated code you want hidden stays visible as a file row unless its path lands in the ignore rules.
Using filemap
filemap ships as a Pi extension — a TypeScript wrapper around the static binary — in the pi-extensions repository. The wrapper registers one tool; the binary also runs standalone from a shell:
# complete map of a small module
filemap modules/dynamic-memory
# cap a huge monorepo at ~8 KB
filemap ~/src/monorepo --budget 8192
| Parameter | Default | Meaning |
|---|---|---|
path |
— | Folder to map (absolute, or relative to the session cwd) |
budgetBytes |
51200 | Byte target; depth grows until reached. 0 = unlimited |
maxItems |
100000 | Hard ceiling on entries; the byte budget is the real limit |
maxDepth |
8 | Hard ceiling on directory depth; the budget normally decides |
Building from source is cargo build --release in native/; the test suite (12 tests across budget, gitignore, and symbol behavior) runs with cargo test.
The pattern generalizes beyond Pi. Any agent harness that lets you register tools — or any workflow that shells out — can adopt the same move: spend one cheap call on the shape of the repository before spending expensive calls on its contents. Maps before greps; anchored reads before paged ones — so more of the context window pays for lines that matter.
Discuss custom AI engineering, implementation, or support for your team: nikhil@swamix.com · swamix.com
Common questions
How is filemap different from grep for exploring a codebase?
Grep answers 'where does this word appear' with flat hits that include comments, strings, fixtures, and vendored copies. filemap answers 'what exists here' in one call: the directory tree plus line-anchored function and variable definitions from git-tracked, non-library sources, each with its own docstring when one exists.
What happens when a repository is too large for the byte budget?
The walk deepens until the map reaches the budget (50 KB default), admits entries atomically, and finishes with a footer that names the pending folders and the exact number of entries not shown, so the agent knows to raise budgetBytes or map a narrower path instead of silently missing content.
Why do only git-tracked files contribute symbols?
The git-tracked gate plus library-directory filters (node_modules, vendor, site-packages, tests, fixtures) turn the symbol layer into a claim about your code rather than your dependencies. Outside a git repository the same library and test directories are filtered using standard ignore rules.



