Skip to content

[Feature] Repo Map in the agent loop: persistent index, lookup tools, dedup #1186

Description

@addyCooks

Description

Repo Map make the map persistent, hand it to the agent, and stop re-sending files it has already read.

Nanocoder's agent re-reads the same project files across steps. Tool results persist in the conversation and are re-sent on every subsequent step, so context grows monotonically step 30 carries the full text of everything read in steps 1 through 29.

#772 tracked twelve separate wastage bugs across steps, tokens and uncapped output. All twelve have landed. The step count has not moved, which #772 itself records: "nothing merged so far has moved that number every fix to date has been tokens, not steps."

This issue proposes the structural work, in three parts:

  1. Cross-step deduplication of tool results stop re-sending content the model has already been shown.
  2. A persistent symbol index build the repo map once, keep it fresh, store it on disk.
  3. Lookup tools that query the index instead of grepping.

And, before any of them, a harness that can actually measure whether they worked. That ordering is the main argument of this issue and is explained under Alternatives Considered.


Relationship to existing issues

Being explicit up front, because there is genuine overlap with assigned work and I do not want to duplicate anyone.

Issue State Relationship
#772 Tracking: harness efficiency Open, high-priority, unassigned This is the structural follow-up. All twelve wastage sub-issues are closed (#760#770, #795); only #771, a design discussion, is still open under it. #772 itself stays open because the step count is unchanged. Phase 0 below gives it the measurement it currently lacks.
#793 Semantic Context Compiler Open, assigned to @Dhirenderchoudhary Stage 1 (semantic deduplication) and stage 4 (budget enforcement) overlap Phase 1 below. This issue does not claim that work. What Phase 1 adds is a concrete, narrower mechanism grounded in code that already exists. Happy to fold it into #793 as an implementation note, or split along whatever line @Dhirenderchoudhary prefers.
#890 Codebase Intelligence & Repo Map Open, assigned to @addyCooks Phases 2 and 4 are the unfinished half of #890. source/repo-map/ and /repomap shipped; the persistence layer and the promised 1024-token system-prompt injection did not.
#769 No global cap on tool result size Closed Already solved. MAX_TOOL_RESULT_CHARS = 20_000 in source/constants.ts, applied through truncateToolResult with a head/tail split. Phase 1 is not about capping an individual result that is done. It is about the same content being re-sent across many steps.

One note on #771 (subagent delegation scope), the item still open under #772: it is adjacent to this work. Phase 1's ledger has to be conversation-scoped precisely because subagents keep their own context, so the two may be worth considering together.


Use Case

From the benchmark recorded in #772 (Harness efficiency, not quality nanocoder 1.29.0, model held constant at DeepSeek V4 Flash, eight bug-fix tasks, 64 runs for nanocoder):

Harness Tools Fixed overhead Avg steps Output tokens Time Grade
Pi 4 1,340 28.0 14,775 2.1m 2.34
OpenCode 10 7,197 16.6 17,463 3.1m 2.07
Claude Code 27 23,132 37.9 58,370 8.0m 2.42
Nanocoder 15 6,121 37.4 55,844 5.2m 2.25

Quality was statistically flat every confidence interval overlaps every other. Our system prompt is the second leanest of the four. All of our cost sits in the agent loop.

Who this hurts, in order of severity:

  • Local-model users, the audience the project is explicitly built for. On an 8K-context local model, 37 steps of accumulating file content is not an expense, it is the difference between usable and unusable on any medium-sized project.
  • Anyone paying per token. Halving the step count roughly halves the bill.
  • Large codebases, where exploring from scratch degrades fastest.
  • Scheduled and daemon-triggered runs, which pay the same overhead with nobody watching.

There is also a second-order cost: the whole family of settings that exists to shrink prompts for weak models (nano and minimal tool profiles, slim prompts, single-tool enforcement) is treating a symptom. The root cause is that the loop has no memory of what it has already seen.


Proposed Solution

Three components attaching at three different points in the existing loop.

system prompt  ->  model call  ->  tool execution  ->  prepareStep  ->  next step
      ^                                   ^                 ^              |
      |                                   |                 |              |
   [D] repo map                    [B] lookup tools   [A] dedup filter     +--> loop
       injection                       (index/LSP)      (no index needed)

A. Cross-step tool-result deduplication

A tracker already exists, but it is the wrong shape for this. source/utils/read-tracker.ts maintains a Set<string> of absolute paths, marked by read_file, write_file, string_replace and diff_edit. It exists to enforce read-before-edit, so it stores a path and nothing else — no content hash, no line ranges, no step index.

It is also deliberately process-global rather than per-conversation. Its own docblock explains why: for a read-before-edit guard, global state errs toward under-enforcement, and under-enforcement is the safe direction.

That is exactly what makes it unsafe to widen in place for deduplication. A subagent builds its own message array (subagent-executor.ts:194), so a global ledger would stub content as "already in context" that the subagent has never been shown turning a safe under-enforcement bias into a correctness bug. The dedup ledger must be scoped to the message array being filtered. Leave seenFiles and hasSeenFile() exactly as they are; add a separate, conversation-scoped ledger holding {contentHash, ranges, lastStep} per path.

The filter seam also already exists. createPrepareStepHandler in source/ai-sdk-client/chat/streaming-handler.ts already rewrites the message array mid-loop (it currently drops empty assistant messages and orphaned tool results) and is wired at chat-handler.ts:236 with existing test coverage. We are adding a policy to a callback that already filters messages not building a new layer.

It also lands in one place for all three loops. The TUI (conversation-loop.tsx:338), ACP (acp-conversation.ts:329) and --plain (plain/conversation.ts:256) all reach the model through client.chat(), so a filter at prepareStep covers every entry point with one implementation rather than three that can drift.

When a tool result repeats content already in the conversation, substitute a short reference naming the file, the line range, and the step where the content still sits. If the hash differs, the file changed on disk and the full content is re-sent.

B. Lookup tools

Three tools registered through the normal NanocoderToolExport path in source/tools/index.ts:

  • find_definition(symbol) where is this defined?
  • find_references(symbol) what calls this?
  • outline_file(path) what is in this file, without reading it?

Index-backed by default, escalating to LSP when a server is running for that language, degrading silently back to the index when not.

C. The persistent index

A durable store at .nanocoder/index/, matching the existing .nanocoder/{commands,agents,tools,skills}/ convention. One record per file: path, mtime, size, content hash, symbols, imports, PageRank score. Built from the extractors already in source/repo-map/index.ts, kept fresh by two paths depending on whether a daemon is running.

D. Prompt injection

The 1024-token repo map preview promised in #890, injected via source/utils/prompt-builder.ts, profile-aware.


Alternatives Considered

Continue with per-tool fixes. This is what #772 did, and it worked eleven bugs closed, real token savings. But #772 itself records that none of them moved the step count. The remaining cost is structural.

Build the index first, as originally scoped in #890. This is the ordering I am arguing against. Component A requires none of the index work, attacks the behaviour the benchmark author actually named ("re-reading the same files more than any other harness"), and is roughly a week. The index is four to six weeks. Doing A first means we learn whether the expensive half is even necessary before committing to it.

Trust that a better loop beats an index. The strongest counter-argument, usually made by pointing at Claude Code, which builds no index. It is worth being precise about what the data in #772 actually says: Claude Code took 37.9 steps and 58,370 output tokens worse than nanocoder on both and its 2.42 grade sits inside overlapping confidence intervals with our 2.25. It is not evidence that skipping the index is efficient. It is evidence that a very large context budget and fast inference can absorb the inefficiency. Neither is available to someone running a 7B model on a laptop.

Semantic search over embeddings. The strongest indexes use them. This conflicts with the guarantee that nothing leaves the machine, and a bundled local embedding model is a heavy addition. Ruled out for v1; the index schema should be designed without vector fields so we are not half-committing.

Tree-sitter or @swc/core instead of the existing regex extractors, as originally proposed in #890. More accurate, but brings native bindings and a cross-platform packaging burden for an npm package shipping to three platforms. There is no parser dependency in package.json today. Recommend staying on regex until evals show extraction quality rather than context size is the bottleneck.


Implementation Notes

What already exists

Audited on main. Three of these materially change the cost of the work, in both directions.

Component State Effect
source/repo-map/index.ts 488 lines. Per-language regex definition/import patterns, PageRank, in-memory, rebuilt on every call. Its only consumer is source/commands/repomap.tsx. No agent-loop integration. As #890 describes
source/utils/read-tracker.ts Process-global Set<string> of seen paths, already marked by all four file tools. No hash, no ranges, no step index. Cleared on /clear via app-util.ts:688. Prior art for the ledger, but must not be widened in place see Proposed Solution A
source/session/session-manager.ts --continue / --resume restore a full messages array from disk (session-manager.ts:189). New requirement the ledger has to be rebuilt from restored messages
source/usage/response-usage.ts Already prices cache reads and writes at their own models.dev rates. Cheaper prompt-cache behaviour is measurable, not merely inferred
createPrepareStepHandler (streaming-handler.ts) Already rewrites the message array mid-loop; wired at chat-handler.ts:236; covered by existing specs. Cheaper the seam exists
truncateToolResult + MAX_TOOL_RESULT_CHARS Per-result cap already landed via #769. Head/tail split preserves the tail where errors live. Scope shrinks capping is done, dedup is not
source/lsp/ 1,926 impl lines / 3,059 test lines. protocol.ts declares the textDocument/definition and textDocument/references constants and the capability flags, but lsp-client.ts implements only getCompletions, getCodeActions, getDiagnostics. The names exist; the requests do not. More expensive this is real implementation, not a passthrough
source/events/sources/file-watcher.ts 78 lines of chokidar emitting add/change/unlink constructed in exactly one place, daemon.ts:174. Per CLAUDE.md, the interactive TUI never starts event sources. More expensive needs a second freshness path for non-daemon users
benchmarks/ 1,204 lines measuring interactive_boot_ms_approx (606), dist_size_bytes, tool_count. Startup and packaging. No harness can measure a step count today
source/tools/tool-profiles.ts nano = 5 tools, minimal = 8. Constraint on Phase 3

Two more facts that shape the design: read_file already does progressive disclosure (files ≤1500 lines in full, larger ones a 250-line preview with start_line/end_line continuation), so dedup keys must include ranges. And walkProjectEntries in source/utils/file-search.ts already layers .gitignore, .nanocoderignore and ripgrep pruning correctly the index should reuse it rather than reimplement ignore handling.

Phases

Each phase ends at a gate with a measurable criterion. At every gate, stopping is a legitimate outcome, and the plan is built so the expensive phases can be abandoned cheaply.


Phase 0, Evidence harness · Gate 0

Goal: measure steps and cost per task reproducibly, before changing any agent behaviour. #772 has been open since the benchmark landed and we still cannot answer "did that help?" ourselves.

  1. Add benchmarks/agent/ as a separate suite. The existing benchmarks/ boot suite is untouched and must keep passing. Note that AVA's files glob covers only source/** and plugins/**, so it needs extending to benchmarks/**/*.spec.ts or the harness's own specs will silently never run.
  2. Task fixtures: small vendored project trees under benchmarks/agent/fixtures/, a prompt, and a machine-checkable assertion per task (a file contains X, a test passes, an exit code). Vendored rather than cloned, so runs are reproducible, offline, and immune to upstream drift. Start with six spanning navigation, single-file edit, and multi-file change.
  3. Drive the agent through runPlainConversation in source/plain/conversation.ts the programmatic entry behind the existing non-Ink --plain path, already built for non-TTY environments. No new entry point.
  4. Score from source/usage/, which already records provider-reported tokens and prices them via models.dev. Capture step count, tokens, cost, pass/fail. One additive source change is needed here: PlainConversationOutcome returns toolCalls and token counts but no step count, and toolCalls.length is not it a step may issue zero or several calls. Add steps: number to all three outcome variants.
  5. Handle nondeterminism: five runs per task per configuration, report median and IQR, pin model and temperature. A single run proves nothing.
  6. Two model tiers one local via Ollama for reproducibility, one cloud model for the ceiling.
  7. Wire pnpm run test:agent-eval. Deliberately not part of test:all too slow and too costly for the commit gate. Nightly or manual.
  8. Publish baseline numbers for current main.

Gate 0, does our harness reproduce a meaningful gap against a comparison harness on the same tasks? If not, the premise is wrong and we stop here, having spent under two weeks.


Phase 1, Cross-step deduplication · Gate 1

Goal: stop re-sending content the model has already been shown. Requires no index. Overlaps #793 stages 1 and 4 see the relationship table.

The identifying mechanism, since it drives everything else: repeated content is identified from the tool call's arguments, not by parsing the result text. Reading a result and working out which file it holds is fragile and breaks on any formatter change. But every tool result is preceded by its call in the assistant message, and that call already carries {path, start_line, end_line} as structured data. So identity comes from the arguments, and freshness comes from hashing the result content. This needs no cooperation from the tools and survives output-format changes. Initially only read_file counts as file-bearing; the edit tools no longer echo file content (#762, #763, #795), so they contribute identity but not bulk.

  1. Add a conversation-scoped ledger holding {contentHash, ranges, lastStep} per path. Do not widen read-tracker.ts's Set<string> in place: it is deliberately process-global, and a global dedup ledger would stub content as "already seen" for a subagent that was never shown it. seenFiles and hasSeenFile() keep their exact current semantics so the read-before-edit guard is behaviourally unchanged.
  2. Key on path + line range + content hash. The range matters: read_file takes start_line/end_line, so a path-only key would wrongly suppress a legitimate read of a different part of the same file.
  3. Rebuild the ledger on session resume. --continue and --resume restore a full messages array from disk (session-manager.ts:189) while an in-process ledger starts empty. Without a rebuild pass over the restored messages, dedup is wrong in both directions re-sending content already present, and potentially stubbing content the model was never shown. Rebuild from the restored array before the first step.
  4. Clear the ledger wherever the tracker clears. clearReadTracker() is called from app-util.ts:688 on /clear; the ledger needs the same hook.
  5. Add the dedup policy inside the existing createPrepareStepHandler.
  6. Substitution text names what was dropped and where it still is file, line range, step number. On hash mismatch the file changed on disk and full content is re-sent.
  7. Eviction order when the window fills: oldest superseded tool results first; never the current step's results, never user messages, never the last assistant turn.
  8. Protect the prompt cache. Rewriting a message prefix invalidates the provider's cached prefix and everything after it. Only rewrite behind the cache breakpoint, and batch at boundaries rather than mutating every step. Judge the result on cost from source/usage/, not on token count a filter that saves tokens on paper can raise the bill. source/usage/response-usage.ts already prices cache reads and writes at their own models.dev rates, so cache behaviour is measurable here rather than merely inferred.
  9. Define precedence against auto-compact (source/utils/auto-compact.ts, threshold 50–95%). The filter runs first and is near-lossless; compaction stays the fallback. setAutoCompactStrategy/setAutoCompactThreshold already exist as session overrides, so this is testable by injection.
  10. Config flag context.dedupeToolResults, following the nested-config precedent set by retries?: RetryLimitsConfig in source/types/config.ts. It merges defaulting to off so the wiring can land safely, and is flipped on in a separate PR once the cost run above confirms it. With the flag off, the message array must be byte-identical to today's.

Phase 2, Persistent index · Gate 2

Goal: turn the throwaway repo map into a durable, self-updating store. The unfinished half of #890. Only if Gate 1 leaves a gap.

  1. Promote source/repo-map/index.ts in place. Keep the regex extractors and PageRank they work. Add persistence around them.
  2. Store at .nanocoder/index/ with an explicit schema version field, so a format change invalidates cleanly rather than failing strangely.
  3. Per-file record: path, mtime, size, content hash, symbols, imports, rank. Staleness by mtime and size, hash as tiebreak.
  4. Reuse walkProjectEntries for traversal. Reimplementing ignore logic is how indexes end up scanning node_modules.
  5. Two freshness paths, because there is no single watcher. With a daemon running, subscribe to the existing file.changed events and reuse the 500ms trailing debounce in source/events/backpressure.ts. Without one — the common case, since the TUI never starts event sources validate by mtime scan on load and invalidate on write from write_file, string_replace and diff_edit.
  6. Concurrency: a daemon and a TUI can both write. Reuse the lockfile pattern from source/daemon/lockfile.ts plus atomic temp-file-and-rename. Do not invent a second locking scheme.
  7. First-run budget on large repositories: build in the background, expose an "index warming" state, never block startup. The existing caps (2,000 files, 256KB per file) carry over as the starting point.
  8. Keep /repomap working, reading from the store instead of rebuilding.

Gate 2, correctness tests pass for stale detection, concurrent writes and ignore handling, and interactive_boot_ms_approx shows no regression against its 606ms baseline.


Phase 3, Lookup tools · Gate 3

Goal: put the index in the agent's hands, and implement the LSP requests the protocol layer already names.

  1. Three tools in staticTools (source/tools/index.ts): find_definition, find_references, outline_file.
  2. Dual backend. Index first always available, cheap, approximate. Escalate to LSP when a server is live for that language, degrade silently back when not.
  3. The LSP work is real implementation. Add request methods to lsp-client.ts, the didOpen document lifecycle that must precede any position-based request, and symbol-to-position resolution.
  4. Replace, do not add, in the constrained profiles. In nano (5 tools) and minimal (8), the new lookups displace search_file_contents rather than joining it. Adding three tools to a five-tool budget is the opposite of what that profile is for.
  5. Prompt guidance in source/utils/prompt-builder.ts so the model reaches for find_definition instead of grep.
  6. Note in review that tool_count in benchmarks/baseline.json carries warnOnDecrease: true the baseline update is intentional and should be called out, not silently committed.

Gate 3, does the harness show small models actually using the tools, with step count falling? If they ignore or misuse them, revert the profile changes and keep the tools full-only rather than shipping a regression to the models this was meant to help.


Phase 4, Prompt injection · Gate 4

Goal: deliver the system-prompt injection promised in #890.

  1. Inject a token-budgeted map summary via source/utils/prompt-builder.ts.
  2. Start from the existing DEFAULT_REPO_MAP_TOKENS = 1024 budget and tune from eval results rather than intuition.
  3. Profile-aware: nano omits it, following the precedent that nano already omits AGENTS.md by default.
  4. Publish before/after numbers from the harness in the PR description.

Gate 4, final eval run. Ship with numbers attached, or hold the injection if it costs more context than it saves.


Verification and testing

pr-checks.yml sets fail-on-coverage-drop: true and c8 enforces lines: 80, so every phase ships with tests or CI fails. Roughly 85% of this is deterministically testable with existing patterns; three things are not, and they need different instruments.

Deterministic, in CI, using patterns already in the repo:

  • Phase 1 loop mechanics. makeFakeClient in source/plain/conversation.spec.ts already feeds canned LLMChatResponse objects through runPlainConversation, and conversation-loop.spec.ts is 2,826 lines against its 1,256-line implementation. Script a client that reads the same file twice, assert the second result came back as a stub; change the hash, assert full re-send; fill the budget, assert eviction hit the oldest superseded result and never a user message. Both new edge cases are covered here too: assert a subagent's ledger does not inherit the parent's entries, and assert a resumed session rebuilds its ledger from the restored messages before the first step. Zero model calls.
  • Phase 2 index behaviour. The mkdtemp fixture pattern appears in 47 spec files. Staleness, schema versioning, ignore handling, atomic writes.
  • Phase 3 tool handlers and profile filtering, Phase 4 prompt assembly ordinary specs.

Not deterministically testable, with the instrument each needs:

Cannot be unit-tested Why Instrument Cost
The step-count improvement itself Model nondeterminism this is a statistical claim, not a pass/fail test:agent-eval (Phase 0), nightly, read by a human in Phase 0
Real LSP round-trips lsp-client.spec.ts and lsp-manager.spec.ts both carry comments that real spawn tests "cause uncaught exceptions from child_process.spawn that AVA cannot properly catch" all 3,059 LSP test lines are mocked at the protocol level Opt-in scripts/lsp-integration.ts, wired as test:lsp-integration, outside AVA, skipped when no server is present
Prompt-cache cost behaviour A provider-side effect. No mock can prove the filter did not invalidate the prefix and raise the bill Documented before/after run against a real paid provider, compared on provider-reported cost
Cross-process index writes lockfile.spec.ts uses mkdtemp and real process.pid but never spawns a second process; AVA runs serial: true, workerThreads: false scripts/stress-index-concurrency.ts, outside AVA, spawning real processes

I would treat the prompt-cache run as a Phase 1 exit requirement, not a risk: the filter does not merge until we have before-and-after cost numbers from a real provider.

Estimates

One contributor familiar with the codebase. Includes tests the repo's impl-to-test ratio runs 1:1 to 1:1.5 (the LSP module is 1,926 impl lines against 3,059 test lines), which is roughly a 40% tax that test:all makes non-optional.

| Phase | Primary risk |
| --- | ---: | --- |
| 0 · Evidence harness | Eval variance swamps the signal; model access and cost |
| 1 · Cross-step dedup | Prompt-cache invalidation raises cost; a wrongly dropped result makes the agent hallucinate |
| 2 · Persistent index | Staleness and cross-process corruption; first-run cost on large repos |
| 3 · Lookup tools | Unimplemented LSP requests; small models ignore the new tools |
| 4 · Prompt injection | Summary costs more context than it saves |

Risks

Risk Mitigation
The filter raises cost instead of lowering it rewriting a prefix invalidates the provider's prompt cache Only rewrite behind the cache breakpoint, batch at boundaries, gate on cost from source/usage/ rather than raw token count
The ledger is scoped too widely and stubs content a subagent never saw, or a resumed session dedups against an empty ledger Ledger is per-conversation, never process-global like read-tracker.ts; rebuilt from restored messages on --continue/--resume; both cases are deterministic tests
A stale index gives wrong answers worse than no index, because the agent trusts it Content-hash validation on every lookup, fail open to the existing search tools, never let a stale hit suppress a real search
First run is slow on a large repository Background build, existing 2,000-file / 256KB caps, explicit warming state, gated on interactive_boot_ms_approx
Small models ignore the new tools — adding tools is the opposite of what nano exists for Replace search_file_contents rather than joining it; Gate 3 reverts profile changes if evals show non-use
Daemon and TUI corrupt the index writing concurrently Reuse the tested lockfile from source/daemon/lockfile.ts plus atomic temp-and-rename
The premise is wrong and a better loop beats a map Exactly why Phase 0 comes first and Gate 1 exists — abandonable at two weeks rather than eleven

Explicitly out of scope

  • Embeddings / semantic search, conflicts with local-first; schema designed without vector fields.
  • New native dependencies, no tree-sitter unless maintainers decide otherwise.
  • Changes to the benchmarks/ boot suite, it stays as-is and keeps passing; the agent harness is separate.
  • Replacing auto-compact, it remains the fallback; we define precedence, not a replacement.
  • Anything leaving the machine, the index is a local file. No sharing, no upload, no telemetry on contents.
  • Rewriting the extractors the existing regex patterns and PageRank stay.

Additional Context

  • I have searched existing issues to ensure this is not a duplicate
  • This feature aligns with the project's goals (local-first AI assistance)

Questions for maintainers

  1. Overlap with [Feature] Semantic Context Compiler (Intelligent Context Budget Management) #793. Phase 1 covers ground assigned to @Dhirenderchoudhary. Should it fold into [Feature] Semantic Context Compiler (Intelligent Context Budget Management) #793 as an implementation note, split (mechanism here, budget policy there), or should I drop Phase 1 and start at Phase 0 + 2?
  2. Regex or a real parser? [Feature] Codebase Intelligence & Repo Map (/repomap) #890 originally proposed tree-sitter or @swc/core. My recommendation is to stay on the existing regex extractors through Phase 3 and revisit only if evals show extraction quality is the bottleneck — but this is a maintainer call, since it affects packaging.
  3. Is .nanocoder/index/ committed or ignored? Recommend ignored, and added to the .gitignore guidance we ship.
  4. Who pays for eval runs? The harness needs real model calls — five per task per configuration. Recommend a mandatory local Ollama tier that anyone can reproduce, plus one optional cloud tier behind an env var.
  5. Commit to all phases, or approve 0 and 1 and re-decide at Gate 1? Recommend the latter, with the Gate 1 numbers brought back to this issue before Phase 2 starts.

Happy to split this into per-phase issues under #772 once the shape is agreed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions