RepoDigest
A local-first codebase context synthesizer and token budgeter.
RepoDigest statically analyzes a Python codebase — no network calls, no external services, no code ever leaves your machine — and turns it into a token-budgeted, LLM-ready context around a target symbol, file, or plain-English question. It replaces "paste the whole repo into the prompt" with a deterministic pipeline: parse the AST, build the call graph, rank or target a symbol, and pack outward from it until the budget runs out.
The test badge reflects the local
pytestsuite as of the latest commit — there is no CI workflow configured yet, so treat it as a snapshot, not a live status check.
Table of Contents
- The Core Problem & Motivation
- System Architecture & Algorithms
- Installation
- Complete CLI Reference & Workflows
- Programmatic Python API
- Test Verification & Benchmarks
- Development & Solo Authorship
The Core Problem & Motivation
Feeding an LLM "the whole codebase" is the default move when a model needs context it doesn't already have — and it's a bad one, for three compounding reasons:
1. Context window bloat and cost. Every additional token in the prompt is billed, whether or not the model needs it. A 50-file service dumped wholesale into a prompt might be 200K tokens of which perhaps 2K are actually relevant to the question being asked. That's not a rounding error — it's two orders of magnitude of wasted spend, repeated on every single call.
2. "Lost in the middle." Even when a model's context window is technically large enough, published attention studies (e.g. Liu et al., "Lost in the Middle: How Language Models Use Long Contexts") consistently show that model recall of information degrades — often severely — for content placed in the middle of a long context, versus the beginning or end. Dumping an entire repository doesn't just cost tokens; it actively degrades the model's ability to find and use the one function that actually matters, by burying it in surrounding noise.
3. Irrelevant surface area invites wrong answers. More unrelated code in context means more opportunities for the model to anchor on a similarly-named function in an unrelated module, or to "helpfully" incorporate a pattern from code that was never meant to be relevant to the task.
Why not just concatenate files?
Naive bundling scripts (cat all .py files into one blob, optionally with file-path headers) are
simple, but they have no notion of relevance or relationship. They can't answer "what depends
on this function" or "what does this function depend on" — every file is either fully in or fully
out. They also can't do interface-only rendering: a caller either gets the whole 200-line
implementation or nothing, when often it only needs the four-line signature to reason correctly
about how to call it.
Why not just use vector-only RAG?
Embedding-based retrieval (chunk the repo, embed the chunks, retrieve by cosine similarity) solves part of the relevance problem, but introduces its own failure modes:
- Chunk boundaries don't respect code structure. A fixed-size or paragraph-based chunker will routinely split a function in half, or merge the tail of one function with the head of the next — handing the model a syntactically incomplete fragment.
- No structural guarantees. Semantic similarity between a query and a docstring says nothing about whether that symbol is actually reachable from — or a dependency of — the code the user is working on. A vector store has no concept of "caller" or "callee."
- Non-determinism and drift. Embedding models change between versions, similarity thresholds need tuning, and the same query can silently retrieve different results after a model upgrade.
- Infrastructure weight. A vector database is one more moving part, one more thing to index, invalidate, and keep in sync with the source tree, for a problem that — for a single codebase on a single machine — a deterministic call graph solves for free.
RepoDigest takes a deterministic AST + DAG approach instead: parse the exact source with Python's own compiler front-end, build an exact (name-resolved) call graph from it, and traverse that graph outward from a precise starting point. The same target, the same repository state, always produces the same packed context — no embeddings, no similarity thresholds, no drift.
System Architecture & Algorithms
flowchart LR
subgraph Input
A[Python source tree]
end
A --> P["parsers/py_parser.py\nAST symbol extraction"]
P --> G["graph/call_graph.py\nCross-file call graph"]
P --> B["packer/budget.py\nSymbol registry"]
G --> B
Q[["Query or --entry"]] --> R["search/ranker.py\nBM25 ranking"]
R -->|seed symbol| B
B --> S["packer/serializer.py\nXML / Markdown"]
S --> O[Token-budgeted context]
1. AST Parsing Pipeline
repodigest.parsers.py_parser uses Python's built-in ast module — the same parser CPython uses
to compile your code — so extraction is exact, not regex-approximated. parse_source/parse_file
walk a module's top-level body and, for every ClassDef, its nested body, extracting:
source text
│
▼
ast.parse() # exact syntax tree, same as the interpreter uses
│
▼
walk tree.body (top level only) ─────────────┐
│ │
├─ Import / ImportFrom → ImportInfo │ (module, name, alias, lineno)
├─ FunctionDef/AsyncFunctionDef → FunctionInfo
│ ├─ args (positional/varargs/kwonly/kwargs, with annotations & defaults)
│ ├─ returns (unparsed annotation)
│ ├─ docstring (ast.get_docstring)
│ └─ source: full body OR signature-only stub
└─ ClassDef → ClassInfo
├─ bases / decorators
├─ docstring
├─ methods: tuple[FunctionInfo, ...] (same extraction, is_method=True)
└─ source: full class body OR every method stubbed
Every function, method, and class carries two renderable forms, selected by mode:
mode="full"— the verbatim original text, decorators included. (This is not simplyast.get_source_segment(source, node): that function anchors onnode.lineno, which for a decorated function/class points at thedef/classline rather than the decorator above it — silently dropping decorators. RepoDigest's_full_sourcehelper slices from the first decorator's line instead, when one is present.)mode="signature"— the body is rebuilt as an AST stub (_render_signature/_render_class_signature) keeping only the docstring and an...placeholder, then re-serialized withast.unparse. For a class, every method is stubbed this way, so the whole class's public shape is visible in a fraction of the tokens its implementation would cost.
This is the mechanism behind the CLI's --signatures-only flag: it doesn't strip lines with a
regex — it round-trips through the AST, so decorators, type annotations, and multi-line default
arguments are always rendered correctly.
2. Dependency Graph Construction
repodigest.graph.call_graph.CallGraph.from_directory(root) builds a fully-qualified,
cross-file symbol graph in two passes:
Pass 1 — Symbol table. Every file under root is parsed, and every function, method, and
class is assigned a fully-qualified name derived from its file path and nesting:
src/repodigest/packer/budget.py → module qualname "packer.budget"
class ContextPacker → "packer.budget.ContextPacker"
def pack(...) → "packer.budget.ContextPacker.pack"
def build_symbol_registry → "packer.budget.build_symbol_registry"
These qualnames are collected into a symbol_table: dict[str, set[str]] mapping simple name
("pack") → the set of fully-qualified names that share it (there can be more than one
pack across a codebase).
Pass 2 — Edge resolution. Every function/method body is walked for ast.Call nodes. For a
call like helper() or self.other(), the callee's simple name (helper, other) is looked up
in the symbol table, and a directed edge caller_qualname -> callee_qualname is added for
every qualified symbol sharing that name.
This is a deliberate name-based resolution trade-off, not full import-alias tracing: it never
needs to resolve import numpy as np chains or re-exports to find a match, which keeps it fast
and dependency-free, at the cost of occasionally over-linking two unrelated functions that happen
to share a name (e.g. two different run() methods on unrelated classes). For the purpose this
graph serves — finding plausibly relevant neighboring context under a token budget, not proving
program correctness — that trade-off is the right one.
Edges are stored bidirectionally for O(1) lookups in both directions:
self._callees: dict[str, set[str]] # caller -> {callees}
self._callers: dict[str, set[str]] # callee -> {callers}
graph.get_callees("packer.budget.ContextPacker.pack") # downstream: what `pack` calls
graph.get_callers("packer.budget.ContextPacker.pack") # upstream: what calls `pack`
3. Context Packing Algorithm
ContextPacker.pack(target) is the formal core of RepoDigest. Given a target (an explicit
symbol, a file path, or a BM25-ranked seed), it runs as follows:
1. roots ← resolve(target)
- if target is a known symbol: roots = [target]
- if target is a file path: roots = every top-level function/class
defined directly in that file
(nested methods excluded — they're
already inlined in their class's
full source)
2. order ← roots ++ bfs_alternate(roots)
bfs_alternate(roots):
seen ← roots
frontier_down, frontier_up ← roots, roots
loop while either frontier is non-empty:
# one hop of DOWNSTREAM (callees) for every node in frontier_down
next_down ← unseen callees of frontier_down, sorted, marked seen
# one hop of UPSTREAM (callers) for every node in frontier_up
next_up ← unseen callers of frontier_up, sorted, marked seen
append next_down, then next_up, to order
frontier_down, frontier_up ← next_down, next_up
3. used ← 0 ; packed ← [] ; skipped ← []
for symbol in order:
text ← full_text if symbol ∈ roots or signatures_only == False
else signature_text
tokens ← count_tokens(text)
if used + tokens > budget:
skipped.append(symbol) # SKIP — never truncate — keep scanning
continue
packed.append((symbol, text, tokens))
used += tokens
4. return PackedContext(packed, skipped, total_tokens=used, budget)
Three properties fall directly out of this design:
- Distance-ordered relevance. Because expansion alternates one hop of downstream, one hop of upstream, per round — rather than exhausting one direction first — a symbol two hops downstream and a symbol two hops upstream are considered at roughly the same priority, instead of one direction systematically starving the other.
- The budget is never exceeded. The check is
used + tokens > budget, evaluated before a symbol is ever appended — there is no code path that producestotal_tokens > budget. - The "skip-on-overflow" heuristic. When a candidate doesn't fit, RepoDigest does not truncate it (a truncated function body is often worse than useless — syntactically broken, semantically misleading) and does not stop the whole traversal (a heuristic like "stop at first overflow" would leave remaining budget on the table if a smaller, more-distant symbol would still fit). It skips that one candidate and keeps scanning, so the final packed set is the best-effort greedy fit of closest-first symbols into the remaining space.
4. Local BM25 Ranking Engine
When you don't know the exact symbol name, repodigest.search.ranker.SymbolRanker scores every
symbol against a free-text query with a from-scratch BM25 implementation — no external search
library, no vector store.
Sub-token tokenization. Symbol and identifier names are rarely single dictionary words —
compute_loss and computeLossValue both need to match a query containing "loss." The tokenizer
handles both naming conventions:
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") # insert a boundary before a capital
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+") # split on everything else, incl. "_"
tokenize("compute_loss") # -> ["compute", "loss"]
tokenize("computeLossValue") # -> ["compute", "loss", "value"]
BM25 scoring. For a query term with document frequency df across N documents (symbols),
against a document of length dl and average document length avgdl:
idf(term) = ln( (N - df + 0.5) / (df + 0.5) + 1 )
score(q, doc) = Σ_{term ∈ q} idf(term) · tf(term, doc) · (k1 + 1)
─────────────────────────────────────
tf(term, doc) + k1 · (1 - b + b · dl/avgdl)
with the standard tuning constants k1 = 1.5 and b = 0.75 — k1 controls how quickly
additional term occurrences saturate in their contribution to the score, and b controls how
strongly a document's length is normalized against the corpus average (b=0 disables length
normalization entirely; b=1 fully normalizes).
Corpus construction. Each symbol's document is its qualified name + docstring + rendered
signature — i.e. its signature_text (which already embeds the docstring, per §1) prefixed
with its qualname — deliberately not its full implementation body. This keeps a query like
"where is the loss calculated" matching on the vocabulary a human would use to describe a
symbol's purpose, rather than being drowned out by common keywords (self, return, if) that
appear inside unrelated function bodies far more often than in any docstring.
Automatic root seeding. When --entry is omitted and --query is given, the CLI builds this
corpus over every symbol in the registry, ranks it, and feeds the single top-scoring symbol
(SymbolRanker.top(query)) straight into ContextPacker.pack(...) as the root — the query never
touches the packer directly, it only ever selects where the deterministic graph traversal begins.
Installation
Requires Python 3.10+. This project uses a local venv/ checked in at the repo root.
# from the repo root
./venv/bin/pip install -e .
This installs RepoDigest in editable mode and registers the repodigest console script (wired
via [project.scripts] in pyproject.toml) inside venv/bin/.
Complete CLI Reference & Workflows
After installing, use the repodigest binary directly (./venv/bin/repodigest, or just
repodigest once venv/bin is on your PATH).
repodigest pack <path>
Pack a token-budgeted context around a target symbol, file, or natural-language query.
| Flag | Type | Default | Description |
|---|---|---|---|
PATH |
argument | — | Directory to scan (the repo/package root) |
--budget |
int |
4000 |
Token budget for the packed context |
--entry |
str |
None |
Target symbol (pkg.mod.Class.method) or file path, relative to PATH |
--query |
str |
None |
Natural-language query; the top BM25 match seeds the pack |
--format |
xml | markdown |
markdown |
Output serialization |
--signatures-only |
flag | off | Render non-root dependencies as signatures only (root is always full) |
--output |
path |
stdout | Write to a file instead of printing |
Exactly one of --entry or --query must be given; passing neither raises a usage error, and an
unmatched --query (BM25 score of zero against every symbol) raises a clear CLI error rather than
silently packing nothing.
Example — target a known symbol, Markdown to stdout (tight budget, to show skip-on-overflow):
$ repodigest pack src/repodigest --entry packer.budget.ContextPacker.pack --budget 500
Real captured output (some fenced code blocks omitted below with # ... for brevity — the actual
output includes the full body of every packed symbol):
## src/repodigest/packer/budget.py
def pack(self, target: str) -> PackedContext:
...
# ... (4 more fully-packed symbols from budget.py and tokenizer.py) ...
---
**Token usage:** 499 / 500 (5 symbols packed, 6 skipped)
The loop only ever adds a symbol if it fits, so total_tokens never exceeds budget — here it
comes right up against the ceiling without going over, with the 6 next-closest candidates skipped
rather than truncated.
Example — find the relevant code by asking a question instead of naming a symbol:
$ repodigest pack src/repodigest --query "Where is the token budget enforced?" --budget 4000
Example — keep the target's full body, but dependencies as signatures only, to fit more breadth into the same budget:
$ repodigest pack src/repodigest --entry cli.pack_command --budget 3000 --signatures-only
Example — XML output, written to a file for downstream tooling:
$ repodigest pack src/repodigest --entry graph.call_graph.CallGraph --format xml --output context.xml
$ head -6 context.xml
<context>
<file path="src/repodigest/graph/call_graph.py">
<symbol name="graph.call_graph.CallGraph" kind="class" tokens="631">
<![CDATA[
@dataclass
class CallGraph:
repodigest graph <path>
Print a Rich-rendered summary table of every module's classes/functions/methods, plus the total number of call-graph edges — a quick sanity check before packing, or a way to spot which modules are the most interconnected (and therefore most likely to pull in a lot of context via BFS).
$ repodigest graph src/repodigest
RepoDigest graph summary: src/repodigest
┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━┓
┃ Module ┃ Classes ┃ Functions ┃ Methods ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━┩
│ cli │ 0 │ 3 │ 0 │
│ graph.call_graph │ 1 │ 2 │ 6 │
│ packer.budget │ 4 │ 1 │ 4 │
│ packer.serializer │ 0 │ 4 │ 0 │
│ packer.tokenizer │ 0 │ 3 │ 0 │
│ parsers.py_parser │ 4 │ 10 │ 0 │
│ search.ranker │ 2 │ 1 │ 4 │
└───────────────────┴─────────┴───────────┴─────────┘
49 symbols across 7 modules, 56 call edges.
Programmatic Python API
Every layer is a plain, importable Python object — the CLI is a thin wrapper over the same calls you'd make from a script or a custom agent loop.
from pathlib import Path
from repodigest.graph.call_graph import CallGraph
from repodigest.packer.budget import ContextPacker, build_symbol_registry
from repodigest.packer.serializer import to_markdown, to_xml
from repodigest.search.ranker import SymbolRanker
root = Path("src/repodigest")
# Build once, reuse across multiple pack() calls — both are pure functions of the
# source tree, so there's no need to re-parse for every query in a session.
call_graph = CallGraph.from_directory(root)
registry = build_symbol_registry(root)
# Inspect the graph directly
print(call_graph.get_callers("packer.budget.ContextPacker.pack")) # upstream
print(call_graph.get_callees("packer.budget.ContextPacker.pack")) # downstream
# Pack a known symbol
packer = ContextPacker(root, budget=4000, signatures_only=True, call_graph=call_graph, registry=registry)
result = packer.pack("packer.budget.ContextPacker.pack")
print(f"{result.total_tokens}/{result.budget} tokens, {len(result.skipped)} symbols skipped")
print(to_markdown(result))
# Seed a pack target from a natural-language question — e.g. inside a custom
# agent loop that needs to find "the right code" before it can act on a task.
corpus = {qual: f"{qual} {record.signature_text}" for qual, record in registry.items()}
ranker = SymbolRanker(corpus)
target = ranker.top("Where is the token budget enforced?")
if target is not None:
result = packer.pack(target)
xml_context = to_xml(result) # feed this directly into an LLM prompt
ContextPacker accepts an already-built call_graph/registry, so a long-running process (an
agent server, a REPL) can amortize the one-time AST-parsing cost across many pack() calls
against the same snapshot of the repository.
Test Verification & Benchmarks
./venv/bin/pytest tests/ -v
============================== 29 passed in 0.21s ==============================
| Suite | Tests | Covers |
|---|---|---|
tests/test_parser.py |
6 | Import/function/class/docstring extraction; full vs. signature-only rendering; decorator-line preservation in full mode |
tests/test_call_graph.py |
5 | Cross-file edge construction; upstream/downstream lookups; unknown-symbol handling |
tests/test_packer.py |
6 | BFS traversal order; strict budget enforcement (skip-not-truncate); file-entrypoint root resolution; unknown-target errors |
tests/test_search.py |
5 | BM25 tokenization; relevance ranking; top_k; empty-corpus edge case |
tests/test_cli.py |
7 | End-to-end pack/graph behavior across both output formats, --output, and error paths |
All fixtures are synthetic, in-repo Python snippets (built via tmp_path) rather than mocks —
every test exercises the real ast parser, the real call-graph builder, and the real BM25 math.
On "benchmarks": RepoDigest does not yet ship a dedicated performance benchmark suite (e.g. timing packing against a large real-world monorepo). The number above — the full 29-test suite running in ~0.2s on the author's machine — is the only currently-measured timing figure; treat it as a smoke-test data point, not a throughput guarantee. Formal benchmarking against larger codebases is open, tracked work.
Development & Solo Authorship
RepoDigest is authored and maintained by Nagendra Prasad Konakanchi (@nagendra-kon).
This is currently a solo project without a formal contribution process, but issues and pull requests are welcome:
-
Bug reports / feature requests: open a GitHub issue with a minimal reproduction (a small Python snippet is usually enough, given how the test suite is structured).
-
Pull requests: please keep changes scoped and include tests — every module in this repository (
parsers,graph,packer,search,cli) has a correspondingtests/test_*.pyfile, and new behavior should extend the matching one rather than introduce a new pattern. Before opening a PR, run the full suite:./venv/bin/pytest tests/ -v
-
Coding conventions: the codebase favors small, single-purpose dataclasses and functions over inheritance hierarchies, and explicit
from __future__ import annotations+X | Nonestyle type hints. Runtime dependencies are kept deliberately minimal:tiktoken(tokenization),clickandrich(the CLI) are actively used by every module described above.requestsis also declared inpyproject.tomlfrom the project's initial scaffolding but is not yet imported anywhere — it's reserved for a future phase (e.g. calling a remote model API) rather than a currently-active dependency; if that phase doesn't materialize, it should be dropped.
Licensed under the MIT License.
Release files for repodigest-py 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| repodigest_py-0.1.0.tar.gz | 33.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| repodigest_py-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 57.0 kB
Release files / repodigest_py-0.1.0.tar.gz
| Download URL | repodigest_py-0.1.0.tar.gz |
|---|---|
| Size | 33.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
88e1c19fd35061cfdf7f4cd0efabdddf92bf390ea27b82300ac01bd6794ae062
|
|
BLAKE2b-256 checksum How to use checksums |
2651662fa7f2088199f745b06b853d21418760ee213d5add26a3ffe981e5ee33
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|
Release files / repodigest_py-0.1.0-py3-none-any.whl
| Download URL | repodigest_py-0.1.0-py3-none-any.whl |
|---|---|
| Size | 23.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1b63d004f228a0d0a3b64e67417edf14430697411e48012044cd7dc43bda4028
|
|
BLAKE2b-256 checksum How to use checksums |
2feb59259bdcafe90fe0780904d5a396a6297b204b4e68846ff02fe8a4bac976
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|