A short tour of the codebase, the indexing flow, and the invariants the binary relies on.
| file | responsibility |
|---|---|
main.rs |
Entrypoint. Wires tracing to stderr. |
cli.rs |
clap subcommand dispatch. |
config.rs |
TOML load / init at $XDG_CONFIG_HOME/lilctx/config.toml. |
chunk.rs |
Markdown-by-heading and line-window chunkers; sha256_hex. |
embed.rs |
OpenAI-compatible /v1/embeddings HTTP client. |
index.rs |
Bulk run() and single-file reindex_file(). |
store.rs |
LanceDB + Arrow plumbing. |
mcp.rs |
rmcp server exposing search and list_paths. |
watch.rs |
notify-based watcher with debounce. |
The flow lives once, in index::reindex_file:
read file
→ file_hash = sha256(content)
→ store.file_already_indexed(path, file_hash)? → skip if yes
→ chunk
→ embed (batched)
→ store.delete_path
→ store.upsert
Both index::run (bulk) and watch route into this same shape.
Any new ingestion path — URL fetcher, paste buffer, whatever — must call
into the same flow rather than reinventing the dedup story.
These are load-bearing. Breaking one breaks the binary in a way that is hard to debug from the symptom.
The MCP stdio transport owns stdout for JSON-RPC framing. A stray byte from
an errant println! corrupts the next frame and the protocol falls over.
- Use
eprintln!ortracing::{info, warn, error}instead. - The
clippy.tomllintprint_stdout = "deny"enforces this at compile time. cli.rsopts out of the lint with#![allow(clippy::print_stdout)]because thesearchandstatussubcommands intentionally write user-visible output, and they are never reached fromserve. Don't copy that allow into other modules.
Same value across all chunks of one file (it is a hash of the entire
source). Store::file_already_indexed depends on this — drop it and
the dedup story collapses, which means every reindex re-bills the
embeddings provider.
LanceDB add is append-only. Without a delete, you accumulate duplicate
(path, chunk_index) rows on every reindex.
Any add / remove / rename in store::make_schema invalidates every existing
data_dir. When you change the schema:
- Bump the version in
Cargo.toml. - Note the break in the README so users know to delete
data_dirbefore upgrading.
The file_hash field was added in v0.2 and is the most recent example.
The lancedb and arrow-array crates are tightly coupled and move fast.
Compile errors mentioning FixedSizeListBuilder, vector_search,
RecordBatchIterator, Select, or QueryBase are almost always version
skew — bump them together, not independently.
The vector column is FixedSizeList<Float32, dim> where dim comes from
config. If the user's embedding.dim disagrees with the dim of the
already-stored table, search returns vague errors. The fix is always:
delete data_dir, fix the config, reindex.
The loop is "wait for the first event, then drain until 500ms of silence, then flush" — not a fixed polling interval. One vim save fires 5–20 fs events; the silence window collapses them.
If you see duplicate reindexes, the editor's save burst is exceeding 500ms.
Bump DEBOUNCE_MS in watch.rs rather than adding new dedup logic.
should_index in watch.rs is a cheap segment-based filter that excludes
.git/, node_modules/, swap files, and the binary extensions list. For
real .gitignore semantics, build an ignore::gitignore::Gitignore per
root at startup and consult it there.
- OpenRouter rate limits →
embed::embed_batchfails the whole batch on a single 4xx/5xx. Adding retry with exponential backoff is the obvious next step. - Large files OOM the chunker →
chunk::chunk_linesreadslines: Vec<&str>upfront. A streaming version would be a non-trivial refactor; usually easier to add a max-file-size skip inindex::collect_files. - Slow cold reindex → embedding is the bottleneck, not chunking or
storage. Parallelize
embed_batchcalls (currently serial across files inindex::run) before optimizing anything else.