You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Cross-step deduplication of tool results stop re-sending content the model has already been shown.
A persistent symbol index build the repo map once, keep it fresh, store it on disk.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Handle nondeterminism: five runs per task per configuration, report median and IQR, pin model and temperature. A single run proves nothing.
Two model tiers one local via Ollama for reproducibility, one cloud model for the ceiling.
Wire pnpm run test:agent-eval. Deliberately not part of test:all too slow and too costly for the commit gate. Nightly or manual.
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.
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.
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.
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.
Clear the ledger wherever the tracker clears. clearReadTracker() is called from app-util.ts:688 on /clear; the ledger needs the same hook.
Add the dedup policy inside the existing createPrepareStepHandler.
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.
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.
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.
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.
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.
Promote source/repo-map/index.ts in place. Keep the regex extractors and PageRank they work. Add persistence around them.
Store at .nanocoder/index/ with an explicit schema version field, so a format change invalidates cleanly rather than failing strangely.
Per-file record: path, mtime, size, content hash, symbols, imports, rank. Staleness by mtime and size, hash as tiebreak.
Reuse walkProjectEntries for traversal. Reimplementing ignore logic is how indexes end up scanning node_modules.
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.
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.
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.
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.
Three tools in staticTools (source/tools/index.ts): find_definition, find_references, outline_file.
Dual backend. Index first always available, cheap, approximate. Escalate to LSP when a server is live for that language, degrade silently back when not.
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.
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.
Prompt guidance in source/utils/prompt-builder.ts so the model reaches for find_definition instead of grep.
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.
Inject a token-budgeted map summary via source/utils/prompt-builder.ts.
Start from the existing DEFAULT_REPO_MAP_TOKENS = 1024 budget and tune from eval results rather than intuition.
Profile-aware: nano omits it, following the precedent that nano already omits AGENTS.md by default.
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.
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
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.
Is .nanocoder/index/ committed or ignored? Recommend ignored, and added to the .gitignore guidance we ship.
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.
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.
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:
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.
high-priority, unassignedsource/repo-map/and/repomapshipped; the persistence layer and the promised 1024-token system-prompt injection did not.MAX_TOOL_RESULT_CHARS = 20_000insource/constants.ts, applied throughtruncateToolResultwith 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):
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:
There is also a second-order cost: the whole family of settings that exists to shrink prompts for weak models (
nanoandminimaltool 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.
A. Cross-step tool-result deduplication
A tracker already exists, but it is the wrong shape for this.
source/utils/read-tracker.tsmaintains aSet<string>of absolute paths, marked byread_file,write_file,string_replaceanddiff_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. LeaveseenFilesandhasSeenFile()exactly as they are; add a separate, conversation-scoped ledger holding{contentHash, ranges, lastStep}per path.The filter seam also already exists.
createPrepareStepHandlerinsource/ai-sdk-client/chat/streaming-handler.tsalready rewrites the message array mid-loop (it currently drops empty assistant messages and orphaned tool results) and is wired atchat-handler.ts:236with 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 throughclient.chat(), so a filter atprepareStepcovers 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
NanocoderToolExportpath insource/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 insource/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/coreinstead 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 inpackage.jsontoday. 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.source/repo-map/index.tssource/commands/repomap.tsx. No agent-loop integration.source/utils/read-tracker.tsSet<string>of seen paths, already marked by all four file tools. No hash, no ranges, no step index. Cleared on/clearviaapp-util.ts:688.source/session/session-manager.ts--continue/--resumerestore a fullmessagesarray from disk (session-manager.ts:189).source/usage/response-usage.tscreatePrepareStepHandler(streaming-handler.ts)chat-handler.ts:236; covered by existing specs.truncateToolResult+MAX_TOOL_RESULT_CHARSsource/lsp/protocol.tsdeclares thetextDocument/definitionandtextDocument/referencesconstants and the capability flags, butlsp-client.tsimplements onlygetCompletions,getCodeActions,getDiagnostics. The names exist; the requests do not.source/events/sources/file-watcher.tsdaemon.ts:174. Per CLAUDE.md, the interactive TUI never starts event sources.benchmarks/interactive_boot_ms_approx(606),dist_size_bytes,tool_count. Startup and packaging.source/tools/tool-profiles.tsnano= 5 tools,minimal= 8.Two more facts that shape the design:
read_filealready does progressive disclosure (files ≤1500 lines in full, larger ones a 250-line preview withstart_line/end_linecontinuation), so dedup keys must include ranges. AndwalkProjectEntriesinsource/utils/file-search.tsalready layers.gitignore,.nanocoderignoreand 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.
benchmarks/agent/as a separate suite. The existingbenchmarks/boot suite is untouched and must keep passing. Note that AVA'sfilesglob covers onlysource/**andplugins/**, so it needs extending tobenchmarks/**/*.spec.tsor the harness's own specs will silently never run.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.runPlainConversationinsource/plain/conversation.tsthe programmatic entry behind the existing non-Ink--plainpath, already built for non-TTY environments. No new entry point.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:PlainConversationOutcomereturnstoolCallsand token counts but no step count, andtoolCalls.lengthis not it a step may issue zero or several calls. Addsteps: numberto all three outcome variants.pnpm run test:agent-eval. Deliberately not part oftest:alltoo slow and too costly for the commit gate. Nightly or manual.main.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 onlyread_filecounts as file-bearing; the edit tools no longer echo file content (#762, #763, #795), so they contribute identity but not bulk.{contentHash, ranges, lastStep}per path. Do not widenread-tracker.ts'sSet<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.seenFilesandhasSeenFile()keep their exact current semantics so the read-before-edit guard is behaviourally unchanged.read_filetakesstart_line/end_line, so a path-only key would wrongly suppress a legitimate read of a different part of the same file.--continueand--resumerestore a fullmessagesarray 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.clearReadTracker()is called fromapp-util.ts:688on/clear; the ledger needs the same hook.createPrepareStepHandler.source/usage/, not on token count a filter that saves tokens on paper can raise the bill.source/usage/response-usage.tsalready prices cache reads and writes at their own models.dev rates, so cache behaviour is measurable here rather than merely inferred.source/utils/auto-compact.ts, threshold 50–95%). The filter runs first and is near-lossless; compaction stays the fallback.setAutoCompactStrategy/setAutoCompactThresholdalready exist as session overrides, so this is testable by injection.context.dedupeToolResults, following the nested-config precedent set byretries?: RetryLimitsConfiginsource/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.
source/repo-map/index.tsin place. Keep the regex extractors and PageRank they work. Add persistence around them..nanocoder/index/with an explicit schema version field, so a format change invalidates cleanly rather than failing strangely.walkProjectEntriesfor traversal. Reimplementing ignore logic is how indexes end up scanningnode_modules.file.changedevents and reuse the 500ms trailing debounce insource/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 fromwrite_file,string_replaceanddiff_edit.source/daemon/lockfile.tsplus atomic temp-file-and-rename. Do not invent a second locking scheme./repomapworking, reading from the store instead of rebuilding.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.
staticTools(source/tools/index.ts):find_definition,find_references,outline_file.lsp-client.ts, thedidOpendocument lifecycle that must precede any position-based request, and symbol-to-position resolution.nano(5 tools) andminimal(8), the new lookups displacesearch_file_contentsrather than joining it. Adding three tools to a five-tool budget is the opposite of what that profile is for.source/utils/prompt-builder.tsso the model reaches forfind_definitioninstead of grep.tool_countinbenchmarks/baseline.jsoncarrieswarnOnDecrease: truethe baseline update is intentional and should be called out, not silently committed.Phase 4, Prompt injection · Gate 4
Goal: deliver the system-prompt injection promised in #890.
source/utils/prompt-builder.ts.DEFAULT_REPO_MAP_TOKENS = 1024budget and tune from eval results rather than intuition.nanoomits it, following the precedent thatnanoalready omits AGENTS.md by default.Verification and testing
pr-checks.ymlsetsfail-on-coverage-drop: trueand c8 enforceslines: 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:
makeFakeClientinsource/plain/conversation.spec.tsalready feeds cannedLLMChatResponseobjects throughrunPlainConversation, andconversation-loop.spec.tsis 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 restoredmessagesbefore the first step. Zero model calls.mkdtempfixture pattern appears in 47 spec files. Staleness, schema versioning, ignore handling, atomic writes.Not deterministically testable, with the instrument each needs:
test:agent-eval(Phase 0), nightly, read by a humanlsp-client.spec.tsandlsp-manager.spec.tsboth carry comments that real spawn tests "cause uncaught exceptions fromchild_process.spawnthat AVA cannot properly catch" all 3,059 LSP test lines are mocked at the protocol levelscripts/lsp-integration.ts, wired astest:lsp-integration, outside AVA, skipped when no server is presentlockfile.spec.tsusesmkdtempand realprocess.pidbut never spawns a second process; AVA runsserial: true, workerThreads: falsescripts/stress-index-concurrency.ts, outside AVA, spawning real processesI 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:allmakes 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
source/usage/rather than raw token countread-tracker.ts; rebuilt from restoredmessageson--continue/--resume; both cases are deterministic testsinteractive_boot_ms_approxnanoexists forsearch_file_contentsrather than joining it; Gate 3 reverts profile changes if evals show non-usesource/daemon/lockfile.tsplus atomic temp-and-renameExplicitly out of scope
benchmarks/boot suite, it stays as-is and keeps passing; the agent harness is separate.Additional Context
Questions for maintainers
/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..nanocoder/index/committed or ignored? Recommend ignored, and added to the.gitignoreguidance we ship.Happy to split this into per-phase issues under #772 once the shape is agreed.