Skip to content

feat(indexing): add dryRun mode to index_codebase for parse-only token totals - #305

Open
dkhokhlov wants to merge 3 commits into
Helweg:mainfrom
dkhokhlov:feat/index-dryrun
Open

feat(indexing): add dryRun mode to index_codebase for parse-only token totals#305
dkhokhlov wants to merge 3 commits into
Helweg:mainfrom
dkhokhlov:feat/index-dryrun

Conversation

@dkhokhlov

@dkhokhlov dkhokhlov commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

index_codebase accepts a new dryRun: true flag (CLI: index --dry-run) that parses the real file set, builds the embedding text for every indexable chunk, and sums estimateTokens over
those texts without requesting embeddings or writing to the index. It returns the file count, chunk count, and the token total a force index would consume — a read-only preflight.

Motivation

A live index_codebase run reports progress as a moving chunk percentage whose denominator grows during parse, so the percent dips backwards (60% → 36% → 62%) and is unfit as a progress gauge.
The dry-run total is a fixed, monotonic denominator: percent = tokensUsed * 100 / dryRunTotal climbs cleanly to ~100% for a force index and tops out below 100% for an incremental (cached
chunks counted in the total but not re-embedded).

It also gives a cost/throughput preview before committing GPU/time: e.g. ~/.claude/projects → 970 files / 72,488 chunks / 76.5M tokens in ~13 s, with no ollama call.

Behavior

  • Implemented as a standalone read-only Indexer.dryRunCost() that reuses the exact index() pipeline: collectFilesparseFiles(linesPerChunk)fallbackToTextOnMaxChunks
    selectIndexableChunkscreateEmbeddingTextsestimateTokens. Same chunking as a real index, so the chunk count and token sum match a force index exactly (same provider).
  • No side effects: no DB writes, no embedding requests, no withIndexMutationLease, opens the DB read-only. (Provider detection may still contact the provider's tags endpoint, e.g. ollama
    /api/tags + /api/show; no embeddings are requested and no writes occur.)
  • Result is a new kind: "dryrun" member of the index result union, formatted by formatDryRunEstimate in src/utils/cost.ts.
  • chunksCount counts source chunks (matches forceIndex's indexedChunks); tokensToEmbed sums per embedding text (matches tokensUsed). A chunk that splits into multiple embedding texts
    still counts as one chunk.
  • Plumbed through operations.ts, execute-common.ts, contracts.ts (SharedIndexCodebaseArgs), the MCP/opencode/pi tool schemas, and the CLI (parseIndexArgs + --dry-run + usage).

Exactness of the token total (important)

The dry-run sums the local estimateTokens(text) = ceil(len/4). This equals the live "Tokens used" counter only for providers that report usage on the same basis — ollama counts
ceil(len/4), so for ollama the dry-run total is exact. For providers that report a server tokenizer count (OpenAI, Gemini, custom), the dry-run value is an estimate, not exact.

It is also an upper bound in two cases even for a matching provider:

  • a force index on a shared global index can reuse cached embeddings from other projects (not all chunks are re-embedded), and
  • an incremental index counts cached chunks in the total that are not re-embedded.

In both cases a progress percent against this total tops out below 100%. The output states this explicitly.

Tests

  • tests/dryrun-index.test.ts (new): integration test with a mocked ollama provider — asserts no embedding calls are made and the index is left unindexed; a subsequent force index reaches
    stats.tokensUsed == dryRun.tokensToEmbed and stats.indexedChunks == dryRun.chunksCount; idempotent across repeated dry-runs. Includes a split-chunk regression test: one oversized source chunk
    that splits into multiple embedding texts still keeps chunksCount == indexedChunks and tokensToEmbed == tokensUsed, with more embedding requests than chunks.
  • tests/cost.test.ts: formatDryRunEstimate formatting + the provider-conditional / upper-bound wording assertions.
  • tests/mcp-cli-index.test.ts: parseIndexArgs --dry-run parsing + the dryRun: false field in the existing CLI arg/execution assertions.

typecheck + lint clean; full vitest suite green (one pre-existing watcher flake is unrelated to this change).

…n totals

index_codebase now accepts dryRun:true (CLI: --dry-run) to parse the file set
and sum estimateTokens over the real chunk embedding text WITHOUT embedding or
writing to the index. The token total is the exact value "Tokens used" climbs to
for a force index (cache bypassed) and a stable upper bound for an incremental,
so it serves as a fixed, monotonic percent denominator for live progress reporting
(e.g. the ~/bin/ci wrapper's preflight).

Implemented as a standalone read-only Indexer.dryRunCost() that reuses the exact
index() pipeline (collectFiles + parseFiles + fallbackToTextOnMaxChunks +
selectIndexableChunks + createEmbeddingTexts + estimateTokens); no DB writes, no
ollama call, no lock. Plumbed through operations.ts (result union kind:"dryrun"),
execute-common.ts, contracts.ts (SharedIndexCodebaseArgs), the MCP/opencode/pi
tool schemas, and the CLI (parseIndexArgs + --dry-run + usage). Formatted by
formatDryRunEstimate in utils/cost.ts.

Tests: parseIndexArgs --dry-run case + the dryRun:false field in the existing CLI
arg/execution assertions; formatDryRunEstimate coverage in cost.test.ts.
Address review (codex) of the dryRun feature.

The "exact force-index token total" claim was overstated. dryRunCost() sums
the local estimate (estimateTokens = ceil(len/4)); the live "Tokens used"
counter is provider-reported. They match only for providers that report
usage on the same basis (ollama counts ceil(len/4) per embedded text). For
providers that report a server tokenizer count (OpenAI, Gemini, custom) the
dry-run value is an estimate, not an exact match. Reword the DryRunEstimate
docstring and the formatDryRunEstimate output so the claim is
provider-conditional, and note that a force index on a shared global index
can reuse cached embeddings from other projects, so the dry-run value is an
upper bound there (as it is for any incremental index). Update the
formatDryRunEstimate unit assertions to the scoped wording.

Add tests/dryrun-index.test.ts: an integration test that runs dryRunCost()
against a temp project (AST chunking + fallback-to-text + maxChunksPerFile
cap) with a mocked ollama provider and asserts (1) no embedding provider
call and the index stays unindexed, (2) the dry-run total equals a
force-index tokensUsed for an estimate-based provider and the chunk counts
match, and (3) dryRunCost is idempotent and writes no embeddings across
repeated calls.
@Helweg

Helweg commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Thanks for the contribution. I exercised the native build and the affected dry-run, token-cost, and MCP CLI suites locally: 50 tests pass. Before this is ready to merge, could you please address these items?

  1. The public dry-run wording says it avoids calling the embedding provider. A fresh run can still perform provider detection, including provider metadata discovery. Please narrow the guarantee to no embedding requests and no index writes, or avoid provider detection in dry-run mode.
  2. chunksCount increments per generated embedding text. A source chunk can split into multiple embedding texts, so this does not necessarily match the indexed source-chunk count. Please either report the metric as embedding texts or count source chunks once, and add a split-chunk regression test.
  3. Please document the index_codebase dryRun option and CLI index --dry-run, and add the user-visible change to CHANGELOG.md.
  4. The PR description says a CI wrapper uses this mode, but no such change is included. Please either include or remove that claim.

Address the four review items on the dryRun PR.

1. Narrow the read-only guarantee to "no embedding requests" (not "no
   provider contact"): dryRunCost still runs provider detection as part of
   normal initialization (for ollama this contacts /api/tags and /api/show)
   because it needs maxTokens to cap chunk size; no embeddings are
   requested and no writes occur. Update the DryRunEstimate docstring,
   formatDryRunEstimate output, and the cost test assertion.

2. Count chunksCount once per source chunk (matching forceIndex
   indexedChunks), not once per embedding text. tokensToEmbed still sums
   per embedding text, so it matches tokensUsed. Add a split-chunk
   regression test: one oversized source chunk that splits into multiple
   embedding texts still keeps chunksCount == indexedChunks and
   tokensToEmbed == tokensUsed, with more embedding requests than chunks.

3. Document dryRun in docs/tools.md, docs/installation.md, and CHANGELOG.

The PR description is updated separately via gh pr edit.
@dkhokhlov

Copy link
Copy Markdown
Contributor Author

@Helweg
updated PR text and narrowed the guarantee to "no embedding requests" (detection still runs for maxTokens), chunksCount now counts source chunks with a split-chunk regression test, added docs + CHANGELOG, and removed the CI-wrapper reference from the description.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants