Skip to content

Ask your codebase

This showcase is a command-line tool with two commands. index turns a codebase into searchable chunks, and ask answers a question from those chunks and lists the files it used. Everything except the large language model (LLM) runs on your machine. You need no Docker and no database server.

  • Rust 1.97.1, which the swiftide workspace pins in rust-toolchain.toml.
  • OPENAI_API_KEY for any OpenAI-compatible endpoint. OPENAI_BASE_URL is optional and defaults to https://api.openai.com/v1. OPENAI_MODEL is optional and defaults to gpt-5.4-mini. An empty OPENAI_API_KEY or OPENAI_BASE_URL counts as unset. An empty OPENAI_MODEL does not: it sets the model to an empty name, so unset it instead.
  • About 600MB of disk for the code embedding model. FastEmbed downloads it into .fastembed_cache/ in the current directory on first use.

The crate turns off swiftide’s default features and enables the tree-sitter, fastembed, lancedb, redb and openai feature flags. To build the same thing in your own crate:

Terminal window
cargo add swiftide@0.34 --no-default-features --features tree-sitter,fastembed,lancedb,redb,openai
cargo add fastembed@5 tree-sitter@0.26 --no-default-features

The crate also depends on fastembed to pick the embedding model and tree-sitter to find unit tests, each at the version swiftide uses. TLS needs no setup: swiftide’s HTTP clients bring their own rustls crypto provider (TLS).

The tool runs two pipelines that share one local embedder. The indexing pipeline loads files, chunks them, describes each chunk with the LLM, embeds the result and stores it. The query pipeline embeds your question with the same model, retrieves the closest chunks and asks the LLM to answer from them.

Indexing and query pipelines

The index lives next to the code, in <path>/.ask-your-codebase/. It holds cache.redb, the node cache, and lancedb/, the vector table.

examples/ask-your-codebase/src/index.rs
let stats = indexing::Pipeline::from_loader(
FileLoader::new(codebase.root()).with_extensions(&languages.extensions()),
)
.filter(test_code::not_test_files(codebase.clone()))
// Drop unit tests before the cache, which keys on path and content. Changing the content
// after it would make the cache miss on every run.
.then(test_code::without_unit_tests)
.then(files_found.count())
// Skip files whose path and content are unchanged since the last successful run
.filter_cached(codebase.node_cache()?)
.then(files_changed.count())
.then(record_relative_path(codebase.clone()))
// Split along syntax boundaries (functions, impls, ...) with tree-sitter
.then_chunk(ChunkByLanguage::try_new(languages, CHUNK_SIZE)?)
// Ask the LLM which questions each chunk answers; stored as metadata and embedded
.then(
MetadataQACode::from_client(llm)
.prompt_template(QUESTIONS_PROMPT)
.num_questions(QUESTIONS_PER_CHUNK)
.concurrency(concurrent_requests)
.build()?,
)
// Embed chunk + metadata locally, in batches
.then_in_batch(Embed::new(embedding::local_model()?).with_batch_size(embedding::BATCH_SIZE))
.then_store_with(codebase.vector_store()?)
.run()
.await?;

Source: examples/ask-your-codebase/src/index.rs#L89-L115

FileLoader respects .gitignore. filter_cached skips files whose path and content are unchanged since the last successful run. then_in_batch sends 32 chunks to the embedder per call. The two count closures count files for the summary line. run returns the PipelineStats of the run, and its nodes_stored is the number of chunks in the summary.

The order of the test steps matters. The node cache keys on the file path and its content. If unit tests were removed after filter_cached, the content would differ on every run and the cache would never hit.

examples/ask-your-codebase/src/test_code.rs
/// Drops test files before they are cached or chunked: files in a `tests/` directory, test
/// modules in their own file (`src/agent/tests.rs`) and test support modules such as
/// `src/test_utils.rs`.
///
/// Errors are kept, so they still surface.
pub fn not_test_files(codebase: Codebase) -> impl Fn(&Result<TextNode>) -> bool + Send + Sync {
move |file| {
!file
.as_ref()
.is_ok_and(|file| is_test_file(codebase.relative_path(&file.path)))
}
}
/// Removes unit tests (`#[cfg(test)] mod tests { .. }`, `#[test] fn ..`) from Rust files.
///
/// This runs on whole files, before chunking, so a chunk never mixes implementation and tests.
pub fn without_unit_tests(mut file: TextNode) -> Result<TextNode> {
if file
.path
.extension()
.is_some_and(|extension| extension == "rs")
{
file.chunk = strip_test_items(&file.chunk)?;
}
Ok(file)
}

Source: examples/ask-your-codebase/src/test_code.rs#L16-L41

Pipeline::filter takes a closure over &Result<TextNode> and keeps the result when the closure returns true. The predicate keeps errors on purpose, so a failed load still surfaces. The second function is an ordinary transformer: any Fn(TextNode) -> Result<TextNode> works, and this one rewrites chunk for .rs files.

examples/ask-your-codebase/src/codebase.rs
/// Remembers which files were fully indexed, so unchanged files are skipped next time.
pub fn node_cache(&self) -> Result<Redb> {
std::fs::create_dir_all(&self.data_dir)?;
Redb::builder()
.database_path(self.data_dir.join("cache.redb"))
.table_name("indexed_files")
.build()
}
/// Embedded vector store with one vector per chunk and the source path next to it.
pub fn vector_store(&self) -> Result<LanceDB> {
let uri = self.lancedb_dir();
LanceDB::builder()
.uri(uri.to_str().context("Index path is not valid UTF-8")?)
.table_name("code_chunks")
.vector_size(embedding::DIMENSIONS)
.with_vector(EmbeddedField::Combined)
.with_metadata(PATH_FIELD)
.build()
}

Source: examples/ask-your-codebase/src/codebase.rs#L56-L77

Redb is the node cache, stored in one table of a local database file. LanceDB is the storage: one vector column over EmbeddedField::Combined, sized at 768 for the embedding model, plus a path metadata column. Since 0.33, the pipeline marks files in the node cache only after the whole run finishes without an error. If any file fails, or you interrupt the run, nothing from that run is cached. The next run prompts and embeds every file again, not only the one that failed.

examples/ask-your-codebase/src/languages.rs
/// Splits each file with a tree-sitter `ChunkCode` for the file's language.
///
/// `ChunkCode` is built for a single language; this chunker picks the right one per file from
/// its extension, so one pipeline can index a polyglot codebase.
#[derive(Clone)]
pub struct ChunkByLanguage {
chunkers: Vec<(SupportedLanguages, ChunkCode)>,
}
impl ChunkByLanguage {
/// Chunks are between `chunk_size.start` and `chunk_size.end` bytes; smaller ones are dropped.
pub fn try_new(languages: &SourceLanguages, chunk_size: Range<usize>) -> Result<Self> {
let chunkers = languages
.0
.iter()
.map(|&language| {
let chunker =
ChunkCode::try_for_language_and_chunk_size(language, chunk_size.clone())?;
Ok((language, chunker))
})
.collect::<Result<_>>()?;
Ok(Self { chunkers })
}
fn chunker_for(&self, path: &Path) -> Option<&ChunkCode> {
let extension = path.extension()?.to_str()?;
self.chunkers
.iter()
.find(|(language, _)| language.file_extensions().contains(&extension))
.map(|(_, chunker)| chunker)
}
}
#[async_trait]
impl ChunkerTransformer for ChunkByLanguage {
type Input = String;
type Output = String;
async fn transform_node(&self, node: TextNode) -> IndexingStream<String> {
match self.chunker_for(&node.path) {
Some(chunker) => chunker.transform_node(node).await,
None => anyhow!("No chunker for {}", node.path.display()).into(),
}
}
}

Source: examples/ask-your-codebase/src/languages.rs#L39-L85

ChunkCode is built for one language. This chunker implements ChunkerTransformer, holds one ChunkCode per language and picks one by file extension through SupportedLanguages::file_extensions. The chunker aims for 10 to 4096 bytes, which keeps most functions in one piece. Chunks of 10 bytes or less are dropped. When tree-sitter finds a syntax error, the whole file becomes one chunk, whatever its size.

examples/ask-your-codebase/src/index.rs
/// Asks for the questions a developer would ask that a chunk answers.
///
/// The default `MetadataQACode` prompt asks generic questions ("What does this code do?").
/// Questions about behaviour, phrased the way a developer would ask them, match real questions
/// much better.
const QUESTIONS_PROMPT: &str = r"
# Task
List the questions a developer could ask about this codebase that the code below answers, each
with a short answer. They are stored with the code to help a search find it.
Focus on behaviour: when and in which order things happen, what starts or ends a process, which
conditions and configuration change the outcome, what happens on errors, and which extension
points (traits, hooks, builders, options) exist and when they apply.
# Constraints
- Write {{ questions }} questions and answers.
- Phrase questions the way a developer would ask them, e.g. 'When does the agent stop?'.
- Name the relevant types, functions and variants in the answers.
- Only use what can be derived from the code.
- Respond only in this format:
Q1: <question>
A1: <answer>
# Code
```
{{ node.chunk }}
```
";

Source: examples/ask-your-codebase/src/index.rs#L39-L70

The default MetadataQACode prompt asks generic questions. This template asks for questions about behavior, phrased the way a developer asks them. It is a Tera template: MetadataQACode fills in questions with the count and node with the chunk. The answer is stored under the metadata key Questions and Answers (code). It is embedded with the chunk, but LanceDB has no column for it.

examples/ask-your-codebase/src/index.rs
/// Stores the file path, relative to the codebase root, as metadata on the node.
///
/// The vector store keeps this field next to each chunk, so answers can cite their sources.
fn record_relative_path(
codebase: Codebase,
) -> impl Fn(TextNode) -> Result<TextNode> + Clone + Send + Sync + 'static {
move |mut node| {
let path = codebase
.relative_path(&node.path)
.to_string_lossy()
.into_owned();
node.metadata.insert(PATH_FIELD, path);
Ok(node)
}
}

Source: examples/ask-your-codebase/src/index.rs#L127-L141

A closure that returns Result<TextNode> is a transformer too. This one writes the relative path into the metadata, where the path column picks it up.

examples/ask-your-codebase/src/embedding.rs
/// A code-aware embedding model that runs on the CPU and needs no API key.
///
/// The model (~600MB) is downloaded on first use.
pub fn local_model() -> Result<FastEmbed> {
let model = TextEmbedding::try_new(
InitOptions::new(EmbeddingModel::JinaEmbeddingsV2BaseCode).with_max_length(MAX_TOKENS),
)?;
FastEmbed::builder()
.embedding_model(model)
.batch_size(BATCH_SIZE)
.build()
}

Source: examples/ask-your-codebase/src/embedding.rs#L18-L30

JinaEmbeddingsV2BaseCode is a code embedding model with 768 dimensions. It runs on the CPU and needs no key. Both pipelines call local_model, because query vectors and chunk vectors must come from the same model. After you change the model, delete the index and build it again.

examples/ask-your-codebase/src/query.rs
let search = SimilaritySingleEmbedding::default().with_top_k(top_k);
let pipeline = query::Pipeline::from_search_strategy(search)
// Rephrase the question into several, to widen what the search can match
.then_transform_query(
GenerateSubquestions::builder()
.client(llm.clone())
.prompt_template(SUBQUESTIONS_PROMPT.into())
.build()?,
)
// Embed with the same local model that embedded the chunks
.then_transform_query(Embed::from_client(embedding::local_model()?))
.then_retrieve(codebase.vector_store()?)
// Answer the original question from the retrieved code, each chunk under its file path
.then_answer(
answers::Simple::builder()
.client(llm)
.prompt_template(ANSWER_PROMPT.into())
.document_template(CHUNK_TEMPLATE)
.build()?,
);
let answered = pipeline.query(question).await?;

Source: examples/ask-your-codebase/src/query.rs#L36-L58

SimilaritySingleEmbedding returns 10 chunks by default, and with_top_k sets the --top-k value instead. with_top_k takes the strategy by value and returns it, so it chains onto default(). default() is only implemented for the unfiltered strategy, so the filter type is inferred as (), meaning no filter. GenerateSubquestions rewrites the question into several related ones. The Embed query transformer embeds the rewritten text, the question and its subquestions together, as one vector with the local model. query consumes the pipeline and returns the answered query.

examples/ask-your-codebase/src/query.rs
/// Widens the question into the follow-up questions its answer involves.
///
/// They are phrased like the questions generated for each chunk while indexing, so the two
/// embed close together.
const SUBQUESTIONS_PROMPT: &str = r"
Your job is to help a search find the code that answers a developer's question about a codebase.
Question: {{ question }}
Write {{ num_questions }} short follow-up questions that the code answering this question would also
answer. Think about the mechanisms involved: the main loop, the types and enum variants, the
conditions that are checked, the functions that are called, and the configuration that changes
the outcome.
## Constraints
* Phrase them the way a developer would ask about this codebase, e.g. 'When does the agent stop?'.
* Do not guess names of types or functions.
* Respond with the original question and the follow-up questions only, one per line, prefixed
with '- '.
";
/// Renders a retrieved chunk under its file path, so the answer can cite it.
///
/// `metadata.path` is the `PATH_FIELD` metadata; a test keeps the two in step.
const CHUNK_TEMPLATE: &str = r"### {{ metadata.path }}
```
{{ content }}
```
";
/// Answers from the retrieved code and cites files.
const ANSWER_PROMPT: &str = r"
Answer a developer's question about a codebase, using snippets of its source code.
Question: {{ question }}
## Constraints
* Only use the snippets below. If they do not answer the question, say briefly what is missing.
* Be concrete: name the types, functions and variants involved and explain when they apply.
* Describe behaviour: what calls what, in which order, and under which conditions.
* Cite the file each statement comes from in backticks, e.g. (`src/lib.rs`).
* Use concise markdown. Add a short code example only if it shows how to use the API.
## Snippets
{{ documents }}
";

Source: examples/ask-your-codebase/src/query.rs#L65-L112

The subquestion template receives question and num_questions, which defaults to 5. The answer template receives question and documents. With document_template, each retrieved document is rendered on its own, with content and metadata.path available, and the results are joined with --- lines. That is how the answer sees which file each chunk came from.

examples/ask-your-codebase/src/query.rs
impl From<Query<states::Answered>> for Answer {
/// Keeps the distinct file paths of the retrieved chunks, most relevant first.
fn from(query: Query<states::Answered>) -> Self {
let mut sources: Vec<String> = Vec::new();
for document in query.documents() {
let Some(path) = document.metadata().get(PATH_FIELD).and_then(|v| v.as_str()) else {
continue;
};
if !sources.iter().any(|source| source == path) {
sources.push(path.to_owned());
}
}
Self {
text: query.answer().to_owned(),
sources,
}
}
}

Source: examples/ask-your-codebase/src/query.rs#L123-L142

documents returns the retrieved documents in relevance order. The path column comes back as a path metadata entry. The tool keeps each path once and prints the list under the answer.

examples/ask-your-codebase/src/llm.rs
impl LlmEndpoint {
/// Builds a client for this endpoint.
///
/// Rate limits and transient errors are retried with exponential backoff, which matters when
/// indexing fires many prompts concurrently. After `retry_budget` the error is returned, so
/// an unreachable or broken endpoint fails instead of hanging.
pub fn client(&self, retry_budget: Duration) -> Result<Arc<dyn SimplePrompt>> {
let openai = OpenAI::builder()
.api_base(&self.base_url)
.api_key(&self.api_key)
.default_prompt_model(&self.model)
.use_responses_api(matches!(self.api, OpenAiApi::Responses))
.build()?;
let backoff = BackoffConfiguration {
max_elapsed_time_sec: retry_budget.as_secs(),
..BackoffConfiguration::default()
};
Ok(Arc::new(LanguageModelWithBackOff::new(openai, backoff)))
}
}

Source: examples/ask-your-codebase/src/llm.rs#L79-L100

The client is always swiftide’s OpenAI integration. api_base points it at OPENAI_BASE_URL and api_key sets the key, without building an async-openai client yourself. use_responses_api switches from Chat Completions to the Responses API when you pass --responses-api. LanguageModelWithBackOff retries rate limits and transient errors with exponential backoff. index keeps retrying a failing prompt for up to 60 seconds, because it sends many prompts at once and expects rate limits. ask stops retrying after 30 seconds.

The crate lives in examples/ask-your-codebase of the swiftide repository. Clone the repository and run from its root:

Terminal window
git clone https://github.com/bosun-ai/swiftide && cd swiftide
export OPENAI_API_KEY=sk-...
cargo run -p ask-your-codebase -- index swiftide-agents
cargo run -p ask-your-codebase -- ask --path swiftide-agents "How does an agent decide to stop?"

index and ask are separate runs, so each can use its own model. Through a gateway that serves Anthropic models, a small model writes the indexing prompts and a stronger one writes the answers:

Terminal window
export OPENAI_BASE_URL=http://127.0.0.1:8080/v1 # an OpenAI-compatible gateway that serves these models
cargo run -p ask-your-codebase -- index swiftide-agents --model claude-haiku-5-5
cargo run -p ask-your-codebase -- ask --path swiftide-agents --model claude-sonnet-5-5 "How does an agent decide to stop?"
FlagApplies toDefaultDescription
--model <name>bothgpt-5.4-miniModel for chunk metadata or answers. Also read from OPENAI_MODEL.
--responses-apibothoffSend prompts to the Responses API.
--lang <languages>indexrustComma-separated tree-sitter languages, such as rust,python.
--concurrency <n>index16Prompts sent at once while indexing.
--path <dir>ask.Root of the codebase, as given to index.
--top-k <n>ask20Number of chunks to retrieve.

By default the tool logs only errors and turns off all logging from swiftide_integrations, which hides errors from LanceDB, FastEmbed, Redb, tree-sitter and OpenAI. Each retried attempt is logged at WARN, so it stays hidden too. Set RUST_LOG=info or RUST_LOG=debug to see what the pipelines do.

The captured run indexed the swiftide-agents crate with a debug build. It went through an OpenAI-compatible gateway that forwards to Anthropic models on AWS Bedrock. claude-haiku-5-5 wrote the per-chunk questions, claude-sonnet-5-5 wrote the answers, and FastEmbed ran jina-embeddings-v2-base-code on the CPU.

The first run indexes everything. The second finds nothing changed, so the node cache skips every file and no prompt is sent:

$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 34 chunks from 14 new or changed files (14 files found, 0 unchanged) in 91.9s
$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 0 chunks from 0 new or changed files (14 files found, 14 unchanged) in 0.6s

The start of one answer, and its sources:

$ ./target/debug/ask-your-codebase ask --model claude-sonnet-5-5 --path swiftide-agents "How does an agent decide to stop?"
# How an agent decides to stop
An agent stops when something calls `Agent::stop` (or `stop_with_error`), which moves the state to `State::Stopped(StopReason)`. The main loop in `run_agent` checks that state after each step. `Agent::stop` is a no-op if the agent is already stopped (`src/agent.rs`).
[...]
## Triggers, by `StopReason` variant (`src/state.rs`)
| Variant | When it applies |
|---|---|
| `RequestedByTool(ToolCall, Option<Value>)` | A tool returned `ToolOutput::Stop`. |
| `FeedbackRequired { tool_call, payload }` | A tool returned `ToolOutput::FeedbackRequired`. |
| `AgentFailed(Option<Value>)` | A tool returned `ToolOutput::AgentFailed`. |
| `ToolCallsOverLimit(ToolCall)` | The same tool call failed too many times. |
| `Error` | `step` returned an `Err`. |
| `NoNewMessages` | The completion loop ended without another reason. |
| `Other(String)` | Anything convertible from `AsRef<str>`, e.g. `agent.stop("done")`. |
[...]
Sources:
- src/agent.rs
- src/tools/control.rs
- src/lib.rs
- src/state.rs
- src/errors.rs
- src/task_agent.rs
- src/hooks.rs
- src/tasks.rs
- src/default_context.rs

The answers in OUTPUT.md were checked against the source of swiftide-agents. Where a needed chunk was not among the 20 retrieved, the answer marked its claim as an inference.

  • When a file changes, its new chunks are added, but the chunks of the old version stay in LanceDB. Delete .ask-your-codebase/ to start over.
  • Files under a tests directory, or named tests, test_utils or test_helpers, are dropped in every language. Unit tests inside a file are only removed from .rs files.
  • Retrieval is a single vector search. Raise --top-k for broad questions.
  • A file that produces no chunks is never marked in the node cache, so every run loads it again. It costs no LLM calls.

Release notes agent