local-ai·lab
Lesson 6

Repo-aware AI Assistant

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.

Follow along in:
Overview

What you'll build

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.
Setup

What you need

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
$ ./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)
Step 1

The questions (and the gate)

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.

data/questions.json
{
  "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" }
  ]
}
The gate (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.
Step 1b

The sample repo

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.

data/repo/README.md
# 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`.
A repo-aware assistant is only as honest as its citations. Keeping the corpus small here makes every path:start-end verifiable by eye - the same check you'd automate against a real repo.
Step 2

Index into passages that carry citations

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.

python/repo_assistant.py
}


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
node/repo_assistant.mjs
}

// 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("/");
}
dotnet/Program.cs

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)
{
Citations aren't bolted on after the fact - they're a property of the index. If a passage can't say where it lives, an answer built from it can't be trusted, so line ranges are captured at index time, not guessed later.
Step 3

Retrieve the candidate passages

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.

python/repo_assistant.py
        "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)
node/repo_assistant.mjs
  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;
}
dotnet/Program.cs
    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)
{
Swap in BM25, embeddings, or the hybrid from Lesson 3 and nothing downstream changes: the answer and plan steps only care that passages come back with citations attached, not how they were ranked.
Step 4

Answer only from the repo - or abstain

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.

python/repo_assistant.py
    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, chunks
node/repo_assistant.mjs
function 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 };
}
dotnet/Program.cs
    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);
}
The failure mode of a code assistant isn't a wrong line number - it's a confident answer about code that doesn't exist. Gating on a score and abstaining below it is what stops that, and it's why the off-repo question returns nothing.
Step 5

Plan before you edit - and edit nothing

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.

python/repo_assistant.py
    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
node/repo_assistant.mjs

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);
}
dotnet/Program.cs
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)
{
"Plan first, approve, then edit" is the safety rail for an assistant with write access. Separating propose from apply means a human reads the diff-to-be before anything touches disk - the plan is grounded in citations exactly so that review is quick.
Run it

Run the assistant

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.)

demo · python
$ ./run -l 6 demo
demo · node
$ ./run -l 6 --lang node demo
demo · csharp
$ ./run -l 6 --lang csharp demo
Run it

Read the output

Q1 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 answer
Try it

A question the repo can answer

Want 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 retriever
Try it

A question the repo cannot answer

Now 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 autoscaling
Try it

Confirm it with the test

An 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.

test · python
$ ./run -l 6 test
Experiment

Ask the repo - no code editing

This 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.

web · python
$ ./run -l 6
Experiment

Try - raise min_score

Push 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 implemented
Experiment

Try - plan mode

Turn 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 provider
Make it yours

Point it at your own repository

The 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.

A lesson you can only run on the bundled corpus is a toy. The value shows up the moment it answers about your code with citations you can click - so the same index/retrieve/answer/abstain contract is exactly what you carry into a real tool.
Going further

From demo to production

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.

Recap

What you learned

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.

Use arrow keys, the dots, or the buttons. Deep-link a step with #step-N.