Ground an assistant in one codebase: index README, source, tests and scripts into passages that carry their own citations, answer questions only from those lines (always cited, and "not found" when the answer isn't in the repo), and turn a change request into a plan-before-edit that touches no files - offline, polyglot, byte-identical in Python, Node and .NET.
Lessons 1-5 built retrieval, made it better, safe and measurable. This lesson points all of that at one repository and adds the two habits a code assistant needs to be trustworthy: cite every answer and refuse when the answer isn't in the repo.
You'll index a small sample repo (README, source, tests, scripts) into line-numbered passages - each remembering its path:start-end, which becomes its citation. You'll answer a question only from the retrieved passages, always cited; ask something the repo doesn't cover and the assistant says not found instead of guessing. Finally you'll turn a change request into a plan-before-edit - relevant files, current behaviour (cited), a minimal change, and the tests and docs to touch - that changes no files. Everything is offline and deterministic, identical in Python, Node.js and C#. Pick a language above and press → to begin.
Repo-aware assistant: answer about ONE codebase, and only from what's in it.
the repo ─▶ INDEX ─▶ passages with citations ─▶ RETRIEVE ─▶ ANSWER / PLAN
(README, src, (path + line range (keyword │
tests, scripts) for every passage) overlap) │
▼
┌──────────────────────────────┬───────────────────────────────────┐
▼ ▼ ▼
locate question off-repo question change request
"where is chunking?" "kubernetes autoscaling?" "where to add a provider?"
│ │ │
▼ ▼ ▼
GROUNDED answer NOT FOUND PLAN-before-edit
src/chunker.py:1-1 best score < min → abstain relevant files · behaviour
+ cited sources no citation, no guess · change · tests · docs
(changes NO files)
Three rules the assistant never breaks: answer only from indexed lines, always
cite path:line, and refuse ("not found") rather than invent. Same algorithm in
Python, Node.js and C# - byte-identical output.You follow this lesson by reading and running - there's nothing to write. The sample repo, the questions and the assistant all live in the repo. It is offline and dependency-free (nothing to install for Python; for the other ports you only need Node.js 18+ or the .NET 8 SDK). Run it from the repo root. Bare ./run -l 6 opens the interactive playground; add demo for the one-shot print-and-exit. The Node and C# ports are one-shot only:
$ ./run -l 6 # Python: interactive assistant playground
./run -l 6 demo # Python: one-shot, print and exit
./run -l 6 --lang node # Node one-shot (or: --lang csharp)Four questions drive the demo: two locate questions the repo can answer, one plan request, and one off-repo question the repo cannot answer. top_k is how many passages retrieval considers; min_score is the bar an answer must clear - below it, the assistant abstains. All three language ports read this one file, so they behave identically.
{
"top_k": 3,
"min_score": 2,
"questions": [
{ "id": "chunking", "kind": "locate", "question": "where is chunking implemented" },
{ "id": "retriever-tests", "kind": "locate", "question": "which tests cover the retriever" },
{ "id": "add-provider", "kind": "plan", "question": "where should i add a new embedding provider" },
{ "id": "off-repo", "kind": "locate", "question": "how do i configure kubernetes autoscaling" }
]
}min_score) is what turns "always says something" into "says something only when it can back it up." An assistant that can't say I don't know will confidently mislead you about your own codebase.The corpus is a tiny notes-api project under data/repo/ - a README, three source files, a test, and a script. It's deliberately small so you can see exactly which lines a citation points at. Point the same indexer at a real repository and nothing about the contract changes.
# notes-api
A tiny local notes service. Notes are split into passages, indexed by
keyword, and ranked against a query so you can search offline.
## Layout
- `src/chunker.py` splits a note into passages.
- `src/retriever.py` ranks passages against a query.
- `src/providers.py` registers the embedding backends retrieval can use.
- `tests/test_retriever.py` pins ranking and tie-breaking.
- `scripts/reindex.sh` rebuilds the index from scratch.
## Configuration
Point `NOTES_DB` at the index file. Pick a backend with `EMBED_PROVIDER`.path:start-end verifiable by eye - the same check you'd automate against a real repo.Indexing walks every file (sorted, so the order is stable) and splits it into passages separated by blank lines. Each passage remembers its path and 1-based start/end line - that pair is the citation cite() renders as path:start-end. This is the whole trick behind grounded answers: retrieval returns passages, and every passage already knows exactly where it came from.
}
def tokenize(text):
return re.findall(r"[a-z0-9_]+", text.lower())
def terms(text):
"""Distinct meaningful (non-stopword) tokens of `text`."""
return {t for t in tokenize(text) if t not in STOPWORDS}
# --- Index: split every repo file into line-numbered passages ----------------
def is_indexable(rel_path):
"""Skip vendored/build dirs, hidden/dot dirs, and non-text files - noise for a code assistant."""
parts = rel_path.split("/")
if any(p in IGNORE_DIRS or p.startswith(".") for p in parts[:-1]):
return False
return os.path.splitext(parts[-1])[1].lower() in TEXT_EXT
def chunk_file(rel_path, raw):
"""Split one file into passages separated by blank lines, remembering the
1-based line range of each so we can cite `path:start-end`."""
chunks = []
lines = raw.splitlines()
start = None
for i, line in enumerate(lines):
blank = line.strip() == ""
if not blank and start is None:
start = i
elif blank and start is not None:
chunks.append(_make_chunk(rel_path, lines, start, i - 1))
start = None
if start is not None:
chunks.append(_make_chunk(rel_path, lines, start, len(lines) - 1))
return chunks}
// Match Python str.splitlines(): split on line breaks and drop the empty tail a
// trailing newline would otherwise produce.
function splitLines(raw) {
const norm = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = norm.split("\n");
if (parts.length && parts[parts.length - 1] === "" && /\n$/.test(norm)) parts.pop();
return parts;
}
// --- Index: split every repo file into line-numbered passages ----------------
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue; // don't follow symlinks: avoids cycles / escaping the repo root
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith(".")) continue; // don't descend noise
out.push(...walk(full));
} else if (entry.isFile()) out.push(full);
}
return out;
}
function relTo(base, full) {
return full.slice(base.length + 1).split(/[/\\]/).join("/");
}
List<string> files;
List<Chunk> chunks;
try
{
(files, chunks) = BuildIndex(repoDir);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
Console.Error.WriteLine($"error: cannot index repository at {repoDir}: {ex.Message}");
Environment.Exit(1);
return;
}
List<Question> questions;
if (args.Length > 0 && (args[0] == "ask" || args[0] == "plan"))
{
var question = string.Join(" ", args.Skip(1)).Trim();
if (question.Length == 0) { Console.WriteLine("usage: repo-assistant-demo [ask|plan] \"your question\""); return; }
questions = new List<Question> { new(args[0], args[0] == "plan" ? "plan" : "locate", question) };
}
else
{
questions = cfg.Questions;
}
Console.WriteLine($"Repo-aware assistant - indexed {files.Count} files, {chunks.Count} passages under {label}");
for (var i = 0; i < questions.Count; i++)
PrintResponse(i + 1, questions[i], Respond(questions[i], files, chunks, cfg.TopK, cfg.MinScore), cfg.MinScore);
bool IsIndexable(string rel)
{The same deterministic keyword retriever from earlier lessons - distinct query terms, minus stopwords - scores each passage by overlap and returns the top-k. The ordering is fully determined (score desc, then path, then start line), so the same question always cites the same lines in every language.
"end": last + 1,
"first_line": body[0].strip(),
"tokens": terms(text),
}
def build_index(repo_dir=REPO_DIR):
"""Walk the repo (files sorted by path) and index every indexable passage.
Returns (sorted file list, passage list) - the passages carry the citations.
Ignored directories are pruned during the walk, so a real repo's `.git/` and
`node_modules/` are never descended into (not just filtered afterwards)."""
repo_dir = Path(repo_dir) const chunks = [];
const lines = splitLines(raw);
let start = null;
for (let i = 0; i < lines.length; i++) {
const blank = lines[i].trim() === "";
if (!blank && start === null) start = i;
else if (blank && start !== null) { chunks.push(makeChunk(relPath, lines, start, i - 1)); start = null; }
}
if (start !== null) chunks.push(makeChunk(relPath, lines, start, lines.length - 1));
return chunks;
} return textExt.Contains(Path.GetExtension(parts[^1]).ToLowerInvariant());
}
List<string> Tokenize(string text) =>
Regex.Matches(text.ToLowerInvariant(), "[a-z0-9_]+").Select(m => m.Value).ToList();
HashSet<string> Terms(string text) =>
Tokenize(text).Where(t => !stopwords.Contains(t)).ToHashSet();
// Match Python str.splitlines(): split on line breaks, dropping the empty tail a
// trailing newline would otherwise produce.
List<string> SplitLines(string raw)
{The answer is the top passage's line, returned with its citation and the sources behind it - never free-form text the model made up. The one branch that matters: if the best passage doesn't clear min_score, the assistant returns not found. No citation, no invented answer. That refusal is the feature.
for dirpath, dirnames, filenames in os.walk(repo_dir):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS and not d.startswith(".")]
for name in filenames:
full = Path(dirpath) / name
if full.is_symlink():
continue # don't index symlinked files: avoids reading outside the repo root
rel = full.relative_to(repo_dir).as_posix()
if is_indexable(rel):
rels.append(rel)
rels.sort()
chunks = []
for rel in rels:
raw = (repo_dir / rel).read_text(encoding="utf-8", errors="replace")
chunks.extend(chunk_file(rel, raw))
return rels, chunksfunction buildIndex(repoDir) {
const files = walk(repoDir).map((f) => relTo(repoDir, f)).filter(isIndexable).sort(cmpOrdinal);
const chunks = [];
for (const relPath of files) {
chunks.push(...chunkFile(relPath, readFileSync(join(repoDir, relPath), "utf8")));
}
return { files, chunks };
} var parts = norm.Split('\n').ToList();
if (parts.Count > 0 && parts[^1].Length == 0 && norm.EndsWith('\n')) parts.RemoveAt(parts.Count - 1);
return parts;
}
// --- Index: split every repo file into line-numbered passages ----------------
(List<string>, List<Chunk>) BuildIndex(string dir)
{
var acc = new List<string>();
WalkDir(dir, dir, acc); // prunes ignored/dot dirs during the walk
var files = acc.Where(IsIndexable).OrderBy(p => p, StringComparer.Ordinal).ToList();
var chunks = new List<Chunk>();
foreach (var rel in files)
chunks.AddRange(ChunkFile(rel, File.ReadAllText(Path.Combine(dir, rel), utf8Lenient), Terms));
return (files, chunks);
}A change request doesn't get an answer, it gets a plan: the relevant files (cited), the current behaviour (cited), a minimal change, and which tests and docs to touch. Crucially, producing the plan changes no files - it's advisory. The demo's plan is deterministic, derived from retrieval, so you can see the shape; a real assistant fills the same slots with a model.
return "%s:%d-%d" % (chunk["path"], chunk["start"], chunk["end"])
# --- Retrieve: keyword overlap, deterministic order --------------------------
def retrieve(query, chunks, top_k):
"""Top-k passages by how many distinct query terms they contain.
Deterministic: score desc, then path asc, then start line asc."""
q = terms(query)
scored = []
for c in chunks:
s = len(q & c["tokens"])
if s > 0:
scored.append((s, c))
scored.sort(key=lambda sc: (-sc[0], sc[1]["path"], sc[1]["start"]))
return scored[:top_k]
# --- Answer: only from retrieved passages, always cited, else "not found" ----
def answer(query, chunks, top_k, min_score):
"""Answer a locate question from the repo, or abstain. Returns a dict the
reporter and the web GUI both render."""
hits = retrieve(query, chunks, top_k)
if not hits or hits[0][0] < min_score:
best = hits[0][0] if hits else 0
function intersectCount(a, b) {
let n = 0;
for (const t of a) if (b.has(t)) n++;
return n;
}
// --- Retrieve: keyword overlap, deterministic order --------------------------
function retrieve(query, chunks, topK) {
const q = terms(query);
const scored = [];
for (const c of chunks) {
const s = intersectCount(q, c.tokens);
if (s > 0) scored.push({ s, c });
}
scored.sort((x, y) => y.s - x.s || cmpOrdinal(x.c.path, y.c.path) || x.c.start - y.c.start);
return scored.slice(0, topK);
}void WalkDir(string root, string current, List<string> acc)
{
foreach (var sub in Directory.GetDirectories(current))
{
var name = Path.GetFileName(sub);
if (ignoreDirs.Contains(name) || name.StartsWith('.')) continue;
if ((File.GetAttributes(sub) & FileAttributes.ReparsePoint) != 0) continue; // skip symlinks/junctions: avoids cycles / escaping the repo root
WalkDir(root, sub, acc);
}
foreach (var f in Directory.GetFiles(current))
{
if ((File.GetAttributes(f) & FileAttributes.ReparsePoint) != 0) continue; // skip symlinked files: avoids reading outside the repo root
acc.Add(Path.GetRelativePath(root, f).Replace('\\', '/'));
}
}
List<Chunk> ChunkFile(string rel, string raw, Func<string, HashSet<string>> terms)
{Now run it - this indexes the sample repo and answers all four questions: two grounded and cited, one plan, and one refused - identical output in all three languages. (Bare ./run -l 6 opens the interactive playground instead; demo is the one-shot print-and-exit.)
$ ./run -l 6 demo$ ./run -l 6 --lang node demo$ ./run -l 6 --lang csharp demoQ1 and Q2 are grounded: the answer is a real repository line, tagged with its citation and the sources it came from - Q1 points at src/chunker.py, Q2 at the test that actually covers the retriever. Q3 is a plan: five cited steps, and not a single file changed. Q4 asks about Kubernetes - nothing in this repo scores the minimum, so the assistant says NOT FOUND rather than confabulating. That refusal, not the answers, is the point of the lesson.
Repo-aware assistant - indexed 6 files, 25 passages under data/repo
Q1 where is chunking implemented
GROUNDED - answered only from indexed repository lines
src/chunker.py:1-1
"""Chunking is implemented here: turn a note into passages."""
sources: src/chunker.py:1-1
Q2 which tests cover the retriever
GROUNDED - answered only from indexed repository lines
tests/test_retriever.py:1-1
"""Tests that cover the retriever: ranking order and tie-breaking."""
sources: tests/test_retriever.py:1-1 . README.md:8-12 . src/chunker.py:4-9
Q3 where should i add a new embedding provider [plan-before-edit]
PLAN - no files changed, approve before editing
1. relevant files src/providers.py:1-1 . src/providers.py:8-10 . src/providers.py:13-15
2. current behaviour src/providers.py:1-1 -> """Embedding providers. Add a new embedding provider by registering it here."""
3. minimal change add the new code alongside src/providers.py, matching the pattern already there
4. update tests tests/test_retriever.py
5. update docs README.md
Q4 how do i configure kubernetes autoscaling
NOT FOUND - best match scored 1 (< min 2), so the assistant abstains
no citation, no invented answerWant to experiment? You don't edit any code - the playground lets you type and toggle. Start with a question whose answer is really in the repo and watch the citation land on the right file:
which tests cover the retrieverNow ask something the repo knows nothing about. The assistant abstains instead of guessing - drop min_score in the playground to force a weak answer and see exactly why the gate earns its place. (Prefer the terminal? ./run -l 6 demo runs all four and exits.)
how do i configure kubernetes autoscalingAn offline test pins the lesson's claims: every passage carries a citable line range; a locate answer is grounded and points at the right file; an off-repo question is refused, not guessed; a change request yields a plan-before-edit that leaves the corpus untouched; and retrieval is deterministically ordered. No network, no model.
$ ./run -l 6 testThis is the payoff: ./run -l 6 (the default) opens an interactive assistant over the sample repo. Type a question and read the cited answer plus the retrieved passages behind it, or flip on plan mode for a plan-before-edit. Move top_k and min_score and watch a grounded answer turn into not found - the controls feed the very same index and retriever the demo and test use.
$ ./run -l 6Push min_score up to 3 or 4 and watch a real, correct answer flip to not found: the gate got stricter than the evidence. Tuning that bar is the whole game - too low and the assistant guesses, too high and it refuses to help.
where is chunking implementedTurn on plan mode and ask where to add a provider. Instead of an answer you get five cited steps - relevant files, current behaviour, minimal change, tests, docs - and the assistant edits nothing. Approve first, then edit.
where should i add a new embedding providerThe demo indexes a bundled sample so the output is reproducible - but the same code runs against any repo. Every port honours a REPO_PATH environment variable, and two subcommands answer one free question: ask "..." for a cited answer and plan "..." for a plan-before-edit. So REPO_PATH=/path/to/your/repo python python/repo_assistant.py ask "where is auth handled?" indexes your project (skipping .git, node_modules, hidden/dot directories and build output) and answers from it - or abstains. A ready wrapper lives at extend/repo-ask, and three ways to take this further - a standalone CLI, a repo-search MCP tool you register as a Claude Code skill (built on Lesson 2), and the port as a drop-in library - are written up in EXTEND.md next to this lesson.
This is a teaching demo; retrieval is deterministic keyword overlap and the answer/plan are extractive so the lesson is reproducible. For real systems:
Keep the citation contract, swap the pipeline - index your real repo, use BM25/embeddings/hybrid retrieval and a model for the answer and plan; the path:start-end grounding and the abstain gate are unchanged. Index more signal - symbols, imports, git blame and PR history, not just lines. Make the model cite - require every claim to name a path:line from the retrieved set, and reject answers that cite nothing (the same not-found rule, enforced on the model). Keep plan and apply separate - a plan proposes; applying is a second, human-approved step (wire it to the Lesson 2 MCP server so a host can call repo-search as a tool). Evaluate it - fold these questions into Lesson 5's golden set so a drop in citation accuracy, or a lost not-found, shows up as a failed check.
A repo-aware assistant is retrieval plus two habits: cite every answer and refuse when the answer isn't in the repo. You indexed a codebase into passages that carry their own path:line citations, answered questions only from those lines, watched an off-repo question get not found instead of a confident guess, and turned a change request into a plan-before-edit that touched no files. Same algorithm in Python, Node.js and C#, byte-identical output, plus a playground where you moved the gate and watched a grounded answer become an honest refusal.
Next: Lesson 7 · LangChain - rebuild the RAG pipeline with a framework.
#step-N.