Generate Codebase Documentation with an LLM, Kept Current

Generate a Markdown wiki page for every Python project in a folder with an LLM and CocoIndex v1. On each run, the LLM re-reads only the files that changed.

TutorialUpdated 8 min read
Documentation for every codebase, kept current. Every Python file goes through extract_file_info, 1 LLM call, memoized; an arrow points to 3 rows: aggregate_project_info, 1 summary per project; generate_markdown, Markdown and Mermaid; and output/<project>.md, written by cocoindex update.
Enlarge cover

Generate Codebase Documentation with an LLM, Kept Current

Zoom in to see details.

Documentation for every codebase, kept current. Every Python file goes through extract_file_info, 1 LLM call, memoized; an arrow points to 3 rows: aggregate_project_info, 1 summary per project; generate_markdown, Markdown and Mermaid; and output/<project>.md, written by cocoindex update.

A wiki that describes your code goes out of date as soon as someone refactors a module. Nobody updates it, and readers stop trusting it. You can ask an LLM to write the documentation from the source instead, but a script that sends every file to the model on every run is slow and costs money, and most files did not change since the last run.

This tutorial builds a CocoIndex v1 pipeline that writes one Markdown wiki page for each Python project in a folder. An LLM reads each file and returns structured data: public classes, public functions, a summary, and Mermaid call graphs. The pipeline combines the files into a project summary and writes the page. When you run it again, the LLM re-reads only the files that changed, plus one summary call for each multi-file project they belong to. This is incremental processing.

The full example is in the CocoIndex repo, with a walkthrough in the docs.

What you build

  • A CocoIndex app, MultiCodebaseSummarization, in main.py. It treats each subdirectory of a root folder as one project. By default the root is ../, so in the CocoIndex repo it documents the folders in examples/ (about 40 when this was updated).
  • Three Pydantic models in models.py (CodebaseInfo, ClassInfo, FunctionInfo) that define what the LLM returns for a file and for a project.
  • One LLM call per Python file through Instructor and LiteLLM, plus one more call per multi-file project to combine the file summaries.
  • One Markdown page per project in output/, with an Overview, a Components list, a Mermaid diagram of the CocoIndex pipeline when the project has one, and per-file summaries.

You need Python 3.11+, cocoindex>=1.0.7 (1.1 at the time of writing), and an API key for an LLM. The default model is gemini/gemini-2.5-flash, so the default key is GEMINI_API_KEY. Run it has the commands.

A few terms used below:

  • Structured output: the LLM returns JSON that matches a schema (here, a Pydantic model), and the code validates it, so you get typed objects instead of free text.
  • Memoization: CocoIndex caches the result of a function and reuses it while the inputs and the function’s code stay the same.
  • Processing component: a unit of work that CocoIndex runs, tracks and cleans up on its own. Here, each project is one component.

Why not write a script?

A script that loops through files, calls an LLM and writes Markdown works for a one-time run. Keeping it correct over time raises several problems:

  • File changes: after you edit one file, re-running the whole pipeline is slow and expensive.
  • Tracking state: to process only what changed, you have to track timestamps, checksums or diffs yourself.
  • LLM costs: re-analyzing unchanged files wastes API calls, and the cost grows with the size of the codebase.
  • Logic changes: when you change the extraction prompt, every result from the old prompt is stale and must be recomputed.

These are caching, invalidation and orchestration concerns, separate from the extraction logic you want to write. Codebases also change often, as people and coding agents add and modify files. A scheduled full rebuild is out of date until it runs again, and most of its work is on files that did not change.

CocoIndex separates the transformation logic from the change tracking. You write the functions that extract, combine and format, as plain async Python. The model behind this is one relationship:

target_state = transformation(source_state)

You declare the transformation, and CocoIndex decides which inputs need reprocessing. When a source file changes, or the code of a function changes (an edited prompt, for example), only the affected outputs are recomputed.

How the pipeline fits together

The pipeline in 4 stages. 1, the root folder, root_dir = ../ by default: each subfolder with Python files, such as code_embedding/, is a project, and hidden folders such as .venv/ and .git/ are skipped. 2, app_main walks the Python files of each project with localfs.walk_dir and mounts 1 process_project component per project with coco.mount, keyed by the project name. 3, process_project, memoized: coco.map runs extract_file_info on every file, 1 LLM call per file, then aggregate_project_info combines the results and generate_markdown renders the page. 4, output/: 1 Markdown file per project, such as code_embedding.md, declared with localfs.declare_file, and deleted with its project.
The pipelineEnlarge

The pipeline

Zoom in to see details.

The pipeline in 4 stages. 1, the root folder, root_dir = ../ by default: each subfolder with Python files, such as code_embedding/, is a project, and hidden folders such as .venv/ and .git/ are skipped. 2, app_main walks the Python files of each project with localfs.walk_dir and mounts 1 process_project component per project with coco.mount, keyed by the project name. 3, process_project, memoized: coco.map runs extract_file_info on every file, 1 LLM call per file, then aggregate_project_info combines the results and generate_markdown renders the page. 4, output/: 1 Markdown file per project, such as code_embedding.md, declared with localfs.declare_file, and deleted with its project.

app_main finds the projects and mounts one process_project component for each. Inside a component, every Python file goes to the LLM once, the results are combined into one project summary, and the summary becomes a Markdown file. The sections below follow the data: the models first, then each function, then the wiring.

Step 1: Define the data models

The LLM fills in Pydantic models. The field descriptions are part of the prompt: Instructor sends the schema, with its descriptions, to the model. CodebaseInfo serves twice, once per file and once per project:

The 3 Pydantic models in models.py. CodebaseInfo has name str, summary str, public_classes list of ClassInfo, public_functions list of FunctionInfo, and mermaid_graphs list of str. ClassInfo has name str and summary str. FunctionInfo has name str, signature str, is_coco_function bool, and summary str. Arrows link public_classes to ClassInfo and public_functions to FunctionInfo. The subtitle says the same CodebaseInfo is returned for 1 file and for 1 project. Notes say the field descriptions go to the model with the schema, and mermaid_graphs follows MERMAID_GRAPH_DESCRIPTION: graph TD, bold @coco.fn functions, ==> for coco.mount.
The data modelsEnlarge

The data models

Zoom in to see details.

The 3 Pydantic models in models.py. CodebaseInfo has name str, summary str, public_classes list of ClassInfo, public_functions list of FunctionInfo, and mermaid_graphs list of str. ClassInfo has name str and summary str. FunctionInfo has name str, signature str, is_coco_function bool, and summary str. Arrows link public_classes to ClassInfo and public_functions to FunctionInfo. The subtitle says the same CodebaseInfo is returned for 1 file and for 1 project. Notes say the field descriptions go to the model with the schema, and mermaid_graphs follows MERMAID_GRAPH_DESCRIPTION: graph TD, bold @coco.fn functions, ==> for coco.mount.
models.py
"""Pydantic models for multi-codebase summarization."""

from pydantic import BaseModel, Field


class FunctionInfo(BaseModel):
    """Information about a public function."""

    name: str = Field(description="Function name")
    signature: str = Field(
        description="Function signature, e.g. 'async def foo(x: int) -> str'"
    )
    is_coco_function: bool = Field(description="Whether decorated with @coco.fn")
    summary: str = Field(description="Brief summary of what the function does")


class ClassInfo(BaseModel):
    """Information about a public class."""

    name: str = Field(description="Class name")
    summary: str = Field(description="Brief summary of what the class represents/does")


MERMAID_GRAPH_DESCRIPTION = """
Mermaid graph showing CocoIndex function call relationships. Requirements:
- Use 'graph TD' (top-down) layout
- Include ONLY functions defined in this codebase (not external libraries)
- Use **bold** text for @coco.fn decorated functions
- Use thick arrows (==>) for coco.mount/coco.use_mount calls
- Use normal arrows (-->) for regular function calls
- Include a comment with the app name if this is for a coco.App

Example:
```mermaid
graph TD
    %% App: MyApp
    app_main[<b>app_main</b>] ==> process_file[<b>process_file</b>]
    process_file --> helper_func[helper_func]
```
"""


class CodebaseInfo(BaseModel):
    """Extracted information from Python code (file or project level)."""

    name: str = Field(
        description="File path (for files) or project name (for projects)"
    )
    summary: str = Field(description="Brief summary of purpose and functionality")
    public_classes: list[ClassInfo] = Field(
        default_factory=list,
        description="Public classes (not starting with _)",
    )
    public_functions: list[FunctionInfo] = Field(
        default_factory=list,
        description="Public functions (not starting with _)",
    )
    mermaid_graphs: list[str] = Field(
        default_factory=list,
        description=MERMAID_GRAPH_DESCRIPTION,
    )

MERMAID_GRAPH_DESCRIPTION is the longest instruction in the example. It asks for a top-down graph of the codebase’s own functions, bold labels for @coco.fn functions, and thick ==> arrows for coco.mount calls, so every page draws its diagram the same way.

Step 2: Extract one file with an LLM

extract_file_info reads one file, puts its path and content into a prompt, and asks for a CodebaseInfo. instructor.from_litellm wraps LiteLLM’s async acompletion, so response_model=CodebaseInfo returns a validated object for any model LiteLLM supports. Mode.JSON asks the model for JSON in the schema:

main.py
LLM_MODEL = os.environ.get("LLM_MODEL", "gemini/gemini-2.5-flash")

_instructor_client = cast(
    instructor.AsyncInstructor,
    instructor.from_litellm(acompletion, mode=instructor.Mode.JSON),
)


@coco.fn(memo=True)
async def extract_file_info(file: FileLike) -> CodebaseInfo:
    """Extract structured information from a single Python file using LLM."""
    content = await file.read_text()
    file_path = str(file.file_path.path)

    prompt = f"""Analyze the following Python file and extract structured information.

File path: {file_path}

```python
{content}
```

Instructions:
1. Identify all PUBLIC classes (not starting with _) and summarize their purpose
2. Identify all PUBLIC functions (not starting with _) and summarize their purpose
3. If this file contains CocoIndex apps (coco.App), create Mermaid graphs showing the
   function call relationships (see the mermaid_graphs field description for format)
4. Provide a brief summary of the file's purpose
"""

    result = await _instructor_client.chat.completions.create(
        model=LLM_MODEL,
        response_model=CodebaseInfo,
        messages=[{"role": "user", "content": prompt}],
    )
    return CodebaseInfo.model_validate(result.model_dump())

memo=True caches the result for each file. For a file argument, CocoIndex checks the modified time first, and reads the content for a fingerprint only when the time changed. A file you save without changes still hits the cache. The prompt is in the function body, so editing it changes the function’s code, and every cached result for it misses once.

LLM_MODEL is a module-level value, read from the environment when main.py is imported, and the example does not list it in deps. If you change only LLM_MODEL, files that did not change keep their cached results from the old model.

What you see. Each call returns one CodebaseInfo whose name is the file path. Its lists hold the public classes and functions the model found. The prompt asks for mermaid_graphs only when the file contains a coco.App.

Quick check: you reword instruction 4 in the prompt. Which files go to the LLM on the next run?

All of them. The prompt is part of the source code of extract_file_info, and CocoIndex includes a function’s code in its memo key, so every cached result misses once. The change also invalidates the memo of process_project, which calls it, so every project runs again. On the run after that, unchanged files hit the cache again.

Step 3: Combine the files into one project summary

aggregate_project_info turns the per-file results into one CodebaseInfo for the project. It calls the LLM only when the project has more than one file:

main.py
@coco.fn
async def aggregate_project_info(
    project_name: str,
    file_infos: list[CodebaseInfo],
) -> CodebaseInfo:
    """Aggregate multiple file extractions into a project-level summary."""
    if not file_infos:
        return CodebaseInfo(
            name=project_name, summary="Empty project with no Python files."
        )

    # Single file - just update the name to be the project name
    if len(file_infos) == 1:
        info = file_infos[0]
        return CodebaseInfo(
            name=project_name,
            summary=info.summary,
            public_classes=info.public_classes,
            public_functions=info.public_functions,
            mermaid_graphs=info.mermaid_graphs,
        )

    # Multiple files - use LLM to create aggregated summary

    # Format file summaries for the prompt
    files_text = "\n\n".join(
        f"### {info.name}\n"
        f"Summary: {info.summary}\n"
        f"Classes: {', '.join(c.name for c in info.public_classes) or 'None'}\n"
        f"Functions: {', '.join(f.name for f in info.public_functions) or 'None'}"
        for info in file_infos
    )

    # Collect all mermaid graphs from files
    all_graphs = [g for info in file_infos for g in info.mermaid_graphs]

    prompt = f"""Aggregate the following Python files into a project-level summary.

Project name: {project_name}

Files:
{files_text}

Create a unified CodebaseInfo that:
1. Summarizes the overall project purpose (not individual files)
2. Lists the most important public classes across all files
3. Lists the most important public functions across all files
4. For mermaid_graphs: create a single unified graph showing how the CocoIndex
   components connect across the project (if applicable)
"""

    result = await _instructor_client.chat.completions.create(
        model=LLM_MODEL,
        response_model=CodebaseInfo,
        messages=[{"role": "user", "content": prompt}],
    )
    result = CodebaseInfo.model_validate(result.model_dump())

    # Keep original file-level graphs if LLM didn't generate a unified one
    if not result.mermaid_graphs and all_graphs:
        result.mermaid_graphs = all_graphs

    return result
Inside one process_project component. Each Python file goes through extract_file_info, 1 LLM call per file, memoized. coco.map runs these calls concurrently and returns 1 CodebaseInfo per file. aggregate_project_info then branches on the number of files: 1 file reuses that file's info under the project name with no call; 2 or more files make 1 LLM call that sees each file's name, summary, class names and function names, and the file-level Mermaid graphs are kept if the model returns none. generate_markdown renders the page with no LLM call, and localfs.declare_file declares output/<project>.md. A note says process_project is memoized, so a project with no changed files skips all of this and makes 0 calls.
Inside one projectEnlarge

Inside one project

Zoom in to see details.

Inside one process_project component. Each Python file goes through extract_file_info, 1 LLM call per file, memoized. coco.map runs these calls concurrently and returns 1 CodebaseInfo per file. aggregate_project_info then branches on the number of files: 1 file reuses that file's info under the project name with no call; 2 or more files make 1 LLM call that sees each file's name, summary, class names and function names, and the file-level Mermaid graphs are kept if the model returns none. generate_markdown renders the page with no LLM call, and localfs.declare_file declares output/<project>.md. A note says process_project is memoized, so a project with no changed files skips all of this and makes 0 calls.

The aggregation prompt sees each file’s name, summary and the names of its classes and functions, not the source code. If the model returns no unified graph, the file-level graphs are kept.

aggregate_project_info is not memoized. It runs each time its component runs, so a change to any file in a multi-file project costs one extraction call for that file plus one aggregation call.

Step 4: Render the Markdown page

generate_markdown is plain string formatting, with no LLM call:

main.py
@coco.fn
def generate_markdown(
    project_name: str, info: CodebaseInfo, file_infos: list[CodebaseInfo]
) -> str:
    """Generate markdown documentation from project info."""
    lines = [
        f"# {project_name}",
        "",
        "## Overview",
        "",
        info.summary,
        "",
    ]

    # Components
    if info.public_classes or info.public_functions:
        lines.extend(["## Components", ""])

        if info.public_classes:
            lines.append("**Classes:**")
            for cls in info.public_classes:
                lines.append(f"- `{cls.name}`: {cls.summary}")
            lines.append("")

        if info.public_functions:
            lines.append("**Functions:**")
            for fn in info.public_functions:
                marker = " ★" if fn.is_coco_function else ""
                lines.append(f"- `{fn.signature}`{marker}: {fn.summary}")
            lines.append("")

    # Mermaid graphs
    if info.mermaid_graphs:
        lines.extend(["## CocoIndex Pipeline", ""])
        for graph in info.mermaid_graphs:
            # Ensure proper code fence wrapping (LLM may or may not include them)
            graph_content = graph.strip()
            if graph_content.startswith("```"):
                # Already has fences, use as-is
                lines.append(graph_content)
            else:
                # Add mermaid code fences
                lines.append("```mermaid")
                lines.append(graph_content)
                lines.append("```")
            lines.append("")

    # File details (if multiple files)
    if len(file_infos) > 1:
        lines.extend(["## File Details", ""])
        for fi in file_infos:
            lines.extend([f"### {fi.name}", "", fi.summary, ""])

    # Legend
    lines.extend(["---", "", "*★ = CocoIndex function*"])

    return "\n".join(lines)
The layout of 1 generated page, output/<project>.md, with the field each section comes from. The heading is the project name. Overview is info.summary. Components lists Classes as name and summary, and Functions as signature and summary, with a star when is_coco_function is true. CocoIndex Pipeline holds each entry of info.mermaid_graphs in a mermaid code fence, and appears only when there is a graph. File Details has 1 subsection per file with that file's summary, and appears only for projects with 2 or more files. The page ends with the legend: star = CocoIndex function.
One generated pageEnlarge

One generated page

Zoom in to see details.

The layout of 1 generated page, output/<project>.md, with the field each section comes from. The heading is the project name. Overview is info.summary. Components lists Classes as name and summary, and Functions as signature and summary, with a star when is_coco_function is true. CocoIndex Pipeline holds each entry of info.mermaid_graphs in a mermaid code fence, and appears only when there is a graph. File Details has 1 subsection per file with that file's summary, and appears only for projects with 2 or more files. The page ends with the legend: star = CocoIndex function.

The page has 4 sections; only Overview is always present:

  • Overview: the project summary.
  • Components: classes, and functions by signature; ★ marks a function decorated with @coco.fn. Omitted when the model found no public classes or functions.
  • CocoIndex Pipeline: each Mermaid graph in a mermaid code fence. The function adds the fence only when the model did not already include one. Omitted when there is no graph.
  • File Details: one summary per file, for projects with 2 or more files.

Check the generated diagrams against the code. A response that validates against the schema can still describe a call relationship incorrectly.

Step 5: Mount one component per project

process_project runs the 3 steps for one project and declares the output file:

main.py
@coco.fn(memo=True)
async def process_project(
    project_name: str,
    files: Collection[localfs.File],
    output_dir: pathlib.Path,
) -> None:
    """Process a single project: extract info from all files, aggregate, and output markdown."""
    # Extract info from each file.
    file_infos = await coco.map(extract_file_info, files)

    # Aggregate into project-level summary
    project_info = await aggregate_project_info(project_name, file_infos)

    # Generate and output markdown
    markdown = generate_markdown(project_name, project_info, file_infos)
    localfs.declare_file(
        output_dir / f"{project_name}.md", markdown, create_parent_dirs=True
    )

coco.map runs extract_file_info on every file concurrently and returns the results in input order. It creates no new components: the calls run as async tasks inside the project’s component. localfs.declare_file declares a target state, a file that should exist with this content. CocoIndex writes it, rewrites it when the content changes, and deletes it when no component declares it any more.

app_main lists the projects and mounts the components:

main.py
@coco.fn
async def app_main(
    root_dir: pathlib.Path,
    output_dir: pathlib.Path,
) -> None:
    """
    Main application function.

    Scans subdirectories of root_dir, treating each as a Python project,
    and generates markdown documentation for each.
    """
    # List subdirectories (each is a project)
    for entry in root_dir.resolve().iterdir():
        # Skip non-directories and hidden directories
        if not entry.is_dir() or entry.name.startswith("."):
            continue
        project_name = entry.name

        # Walk Python files in this project, excluding .venv directories
        files = [
            f
            async for f in localfs.walk_dir(
                entry,
                recursive=True,
                path_matcher=PatternFilePathMatcher(
                    included_patterns=["**/*.py"],
                    excluded_patterns=["**/.*", "**/__pycache__"],
                ),
            )
        ]

        if files:
            # Mount a component to process this project
            await coco.mount(
                coco.component_subpath("project", project_name),
                process_project,
                project_name,
                files,
                output_dir,
            )


app = coco.App(
    coco.AppConfig(name="MultiCodebaseSummarization"),
    app_main,
    root_dir=pathlib.Path("../"),
    output_dir=pathlib.Path("./output"),
)

localfs.walk_dir is an async iterator, so the list is built with async for. The patterns match Python files at any depth and skip hidden paths (such as .venv) and __pycache__. A folder with no Python files gets no component.

You choose the granularity. A component could be one file, or a smaller unit such as a page or a section. This example uses one component per project directory because it writes one page per project. coco.mount takes a stable path, ("project", project_name), and each processing component owns the target states it declares, here its Markdown file. Components run independently: when one project finishes, its page is written without waiting for the other projects.

process_project is memoized too. When none of a project’s files changed and the code of the functions it calls is unchanged, CocoIndex skips the component’s body and keeps the page it declared last time, so that project makes no LLM call.

Quick check: you delete a project folder and run the pipeline again. What happens to its page?

It is deleted. app_main no longer finds the folder, so it does not mount a component at ("project", <name>). CocoIndex cleans up the target states of a component that is no longer mounted, and output/<name>.md was one of them. No LLM call runs for that project.

Run it

From examples/multi_codebase_summarization in the CocoIndex repo, copy the environment template and set your key, then install the example:

sh
cp .env.example .env     # set GEMINI_API_KEY (or LLM_MODEL=<provider/model> with its matching key)
pip install -e .

pip install -e . installs cocoindex>=1.0.7, instructor, litellm, pydantic and python-dotenv from pyproject.toml. The example’s .env sets COCOINDEX_DB=./cocoindex.db, the local database where CocoIndex keeps its tracking state and memo cache. The cocoindex CLI loads the nearest .env, searching up from the current directory.

Build the wiki:

sh
cocoindex update main.py

root_dir is ../, so this documents the folders in examples/, the example’s own folder included. To document your own code, point root_dir in main.py at a folder of project subdirectories:

my_projects/
├── my_project_1/
│   ├── main.py
│   └── utils.py
├── my_project_2/
│   └── app.py
└── ...

Read the output:

sh
ls output/
cat output/code_embedding.md

What you see. output/ holds one <folder name>.md per project folder that contains Python files, such as code_embedding.md. Each page starts with # <folder name> and ## Overview, and ends with *★ = CocoIndex function*. The summaries and diagrams are written by the model, so their wording differs between models and between runs that miss the cache.

What a re-run does

Every cocoindex update main.py lists the projects again and mounts a component for each. LLM calls happen only where a memo cache misses:

What a re-run sends to the LLM, in 6 cases. Nothing changed, outlined in dashed gray: process_project hits its cache, 0 LLM calls, the page is unchanged. A file saved with the same content, dashed gray: the modified time changed but the content fingerprint matches, 0 calls. A file edited, outlined in periwinkle: that file misses, 1 extraction call, plus 1 aggregation call if the project has 2 or more files, and the page is regenerated. A new project folder, outlined in green: a new component, 1 call per file plus 1 aggregation call for 2 or more files, and a new page. A project folder deleted, outlined in red: no component is mounted, 0 calls, and its page is deleted. The extraction prompt edited, outlined in periwinkle: the code of extract_file_info changed, so every file misses, 1 call per file plus 1 per multi-file project, and every page is regenerated. A legend maps dashed gray to unchanged, green to added, periwinkle to changed and red to deleted.
What a re-run doesEnlarge

What a re-run does

Zoom in to see details.

What a re-run sends to the LLM, in 6 cases. Nothing changed, outlined in dashed gray: process_project hits its cache, 0 LLM calls, the page is unchanged. A file saved with the same content, dashed gray: the modified time changed but the content fingerprint matches, 0 calls. A file edited, outlined in periwinkle: that file misses, 1 extraction call, plus 1 aggregation call if the project has 2 or more files, and the page is regenerated. A new project folder, outlined in green: a new component, 1 call per file plus 1 aggregation call for 2 or more files, and a new page. A project folder deleted, outlined in red: no component is mounted, 0 calls, and its page is deleted. The extraction prompt edited, outlined in periwinkle: the code of extract_file_info changed, so every file misses, 1 call per file plus 1 per multi-file project, and every page is regenerated. A legend maps dashed gray to unchanged, green to added, periwinkle to changed and red to deleted.
  • An edited file misses its extract_file_info cache. The other files in the project hit theirs. For a multi-file project, aggregate_project_info makes one more call.
  • A new project folder gets a new component. Only its files go to the LLM, plus one aggregation call if it has 2 or more files.
  • A deleted project folder loses its component, and CocoIndex deletes its page.
  • An edited prompt changes the code of extract_file_info. That invalidates its cached results and the memo of process_project, which calls it, so every file misses once.
Quick check: a project has 5 Python files and you edit one of them. How many LLM calls does the next run make for that project?

2: 1 extract_file_info call for the edited file, and 1 aggregate_project_info call, because the project has more than one file. The other 4 files hit the cache. For a single-file project, the answer is 1, because aggregation reuses the file’s info without a call.

Beyond documentation: keeping derived knowledge current

The same pattern applies wherever derived knowledge has to follow a changing source, including the context that coding agents read. Agents that work over long sessions act on the codebase as it is now. A summary that was accurate last week can lead an agent to act on assumptions that no longer hold.

Processing the change, instead of rebuilding the whole corpus, keeps this practical:

  • Cost: unchanged files do not cause repeated LLM calls.
  • Latency: a run after a small change spends LLM time only on the changed files.
  • Scale: after the first full run, a large codebase stays current without full reprocessing.

It is the same incremental pattern behind indexing a codebase for RAG, text embeddings and knowledge graphs: declare the transformation once, and only changed inputs are reprocessed.

What to try next

  • Document your own repos. Point root_dir at a folder of project subdirectories and run cocoindex update main.py.
  • Switch the model. Set LLM_MODEL in .env to another LiteLLM provider model and add its key. Check that it returns JSON that validates against CodebaseInfo. Cached results from the old model stay in use for unchanged files, because LLM_MODEL is not in deps; see deps to make it part of the memo key.
  • Change what the page says. Add a field to CodebaseInfo and a section to generate_markdown. A new field changes the schema the model sees, so check the output on one project first.
  • Refresh from CI. Run cocoindex update main.py from a commit hook or a CI job. The example scans the folders once per run; for a process that keeps running, read about live mode.
  • Give the pages to an agent. Each page is a short Markdown summary of a project, with its public API and call graph, that you can put into a coding agent’s context.

Further reading:

Thanks to the community

@prrao87

@prrao87

Thanks @prrao87 for reviewing the example and providing detailed feedback on terminology, style and conceptual clarity, which helped improve the developer experience.

If you have ideas or questions, or want to contribute, join us on Discord. The source is on GitHub.

Frequently asked questions

How do I keep my code documentation in sync with the code without re-running the LLM on every file?

Run the CocoIndex pipeline again after code changes with cocoindex update main.py. It lists the projects again and reuses memoized file extractions whose inputs and code are unchanged. An edited file triggers a new extraction and, for a multi-file project, a new project summary, while unchanged projects reuse their cached results. The example writes Markdown files; updates happen when you run the pipeline. See What a re-run does and the incremental processing deep dive.

How do I only re-analyze changed files when generating docs with an LLM (skip unchanged ones)?

Enable memoization on the per-file extraction with @coco.fn(memo=True), as the example does on extract_file_info. For a file argument, CocoIndex checks the modified time and then a content fingerprint, and reuses the extraction instead of calling the LLM when the file is unchanged. See Step 2: Extract one file with an LLM and Memoization keys & states.

How do I cut LLM costs when regenerating codebase docs on every commit?

Cache the expensive per-file extractions with @coco.fn(memo=True). Later runs reuse cached results for unchanged files, and a project with no changed files makes no LLM call. A changed file still costs one extraction call, plus one aggregation call when its project has more than one file. The article does not measure a percentage reduction; savings depend on the changed files, project sizes, and model. See What a re-run does and the memoization documentation.

How do I give my AI coding agent always-fresh, structured context about my own codebase?

Run the pipeline after code changes and give the generated Markdown pages to your agent. Each page contains a project summary, public classes and functions, and any Mermaid diagrams produced by the model. A commit hook or CI job can trigger updates; the example does not write JSON artifacts or install a watcher. See why current context matters and What you build.

For a self-hosted service that keeps every repository on your code hosts indexed and answers agents’ questions with citations to file and line, see CocoIndex Code Plus.

How do I build a self-updating wiki for a monorepo or many repos?

Point the pipeline at a parent folder and treat each subdirectory as a project. app_main walks each project's Python files, and await coco.mount(coco.component_subpath("project", project_name), ...) gives each project its own processing component. On the next update, a new project gets processed, unchanged projects reuse cached results, and a deleted project's page is removed. See Step 5: Mount one component per project and the full example.

How do I build a self-hosted DeepWiki-style codebase wiki?

The pipeline and generated wiki files run in your own environment, and you can edit the prompt, Pydantic models, aggregation, and output format. The default Gemini configuration sends source text to a hosted model. If source must stay in your environment, configure a compatible locally hosted model and validate its structured output. Start from the example and read Why not write a script?

How do I extract structured data (classes, functions, relationships) from source code with Pydantic and an LLM?

Define Pydantic models for exactly what you want (for example FunctionInfo, ClassInfo, and a top-level CodebaseInfo), then call the LLM through Instructor with response_model=CodebaseInfo. You get validated, typed objects back instead of free-form text. See Step 1: Define the data models and Step 2, with full code in models.py and main.py.

How do I generate a Mermaid architecture diagram from code automatically?

Ask the LLM for Mermaid graphs in a Pydantic field such as mermaid_graphs: list[str], with the drawing rules in the field description, then render them in Markdown with mermaid code fences. The example regenerates the affected project's page on an update after code changes. Check the generated diagrams against the code, since a structured response can still describe relationships incorrectly. See Step 4: Render the Markdown page and the example source.

How do I aggregate per-file LLM summaries into a single project-level summary?

Run a second LLM step that takes the list of per-file extractions and synthesizes one project summary, skipping the extra call for single-file projects. The example runs the per-file extractions concurrently with coco.map(), then aggregates the results. See Step 3: Combine the files into one project summary.

How do I scan subdirectories where each folder is a separate project and process them as a pipeline?

In app_main, iterate root_dir.resolve().iterdir(), collect each project's Python files with [f async for f in localfs.walk_dir(...)] and a PatternFilePathMatcher (**/*.py), then await coco.mount() a processing component per project. Each project runs independently, and its page is written as soon as it finishes. See Step 5 and the main.py source.

How do I keep the codebase wiki updating automatically as code changes (continuous live mode)?

The example scans directories once per update. Run cocoindex update main.py from a commit hook or CI job to refresh the wiki after changes. For a continuously running version, add a live-aware source or a periodic refresh component and enable live mode. The CLI live flag alone does not make root_dir.iterdir() watch for new projects. See What to try next.

Can I use a different LLM (OpenAI GPT, Claude, or a local model) instead of Gemini for code summarization?

Yes. Set LLM_MODEL to a model supported by your LiteLLM and Instructor configuration, and provide that provider's credentials or local endpoint. The example defaults to gemini/gemini-2.5-flash. Check that the replacement validates against the Pydantic schema. The example does not list LLM_MODEL in deps, so unchanged files keep their cached results from the old model. See Run it and the Instructor/LiteLLM integration documentation.

Build fresh context for your agents.

CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.