← All Claude Builds
Claude Code Skill Pack · MCP · Web UI

Karpathy RAG

A personal knowledge base for Claude. SQLite + FTS5 + eleven skills + an MCP server + a local web interface — a librarian for your own content, with citations, a relationship graph, and progressive summarization that compounds across every Claude interface.

The 4-minute tour — what it is, how it’s built, and the web interface

What Is This?

A retrieval-augmented generation system that lives entirely on your machine. Index your session logs, notes, outlines, conversations, documents — whatever you want Claude to know about you — and Claude answers questions grounded in your content with [Doc #ID] citations. No cloud. No vector database. No embeddings server.

Named after Andrej Karpathy because the architecture is deliberately simple: text in a database, full-text search, a skill layer that orchestrates queries. The intelligence isn’t in fancy retrieval — it’s in how raw documents get compiled into entity pages, concept pages, and progressive insights that feed future queries.

And it has a face: a zero-dependency local web interface — live search catalog, wiki browser, interactive relationship graph, and system dashboard — served read-only over the same database.

What It Can Do

FTS5 Full-Text Search

Sub-second queries across every document with porter-stemmed ranking

Source-Grounded Answers

Every claim cites a document ID — never answers from training data alone

Progressive Summarization

Capture syntheses back as insight docs that compound over time

Relationship Graph

Documents link to other documents — derives_from, references, traversable

Cross-Interface Capture

MCP server lets Chat Desktop, Cowork, and Claude Code write to one shared DB

Wiki Compilation

Compile raw documents into entity, concept, area, and source pages

Local Web Interface

Catalog, Wiki, Graph, and Ledger tabs — Python stdlib only, read-only, one push to open

Interactive Graph Explorer

Force-directed map of every relationship — click to inspect, double-click to recenter

The Web Interface

The newest layer of the system: a local web app over rag.db. Zero dependencies — Python standard library only, no pip installs, no node build. The server opens the database read-only; all writes still go through the skills. Four tabs:

Catalog

Live FTS5 search across every document as you type — stemmed matching, doc-type chips, source and tag filters, three sort orders

Wiki

The compiled layer — entity pages, concept pages, area overviews, and source summaries, grouped and browsable

Graph

Force-directed map of the relationship layer — click a node for details, double-click to recenter on its neighborhood

Ledger

Live dashboard — stat tiles, documents by type, source table with sync dates, recent operations log

Every document opens as a full catalog card: rendered content, tags, relationships (links to / linked from), and compilation lineage. URLs are addressable — #/doc/<id>, #/graph?center=<id>, #/search?q=….

How It Works

1
Two folders, one purpose. Code lives at ~/.rag-db/ (with the web UI under ~/.rag-db/ui/); skills live at ~/.claude/skills/. The split exists because Claude Code requires skills under ~/.claude/skills/ to be discoverable.
2
Three layers in the schema. sources (where it came from) → documents (the content, with FTS5) → synthesis (cached overviews). Plus tags, relationships, compilation_sources (wiki lineage), and an append-only operations_log.
3
Skills wrap the database. Eleven slash commands handle init, ingest, query, status, capture, lint, sync, compile, refresh, relate, and update. Most call rag_helpers.py via Bash; a few use inline Python for richer queries.
4
MCP for cross-interface. The capture_mcp_server.py exposes capture_insight to any Claude interface that loads claude_desktop_config.json. An insight saved in Chat is queryable from Claude Code seconds later.
5
The web UI reads the same database. A stdlib-only local server (server.py) exposes a JSON API plus a single-page app — search, wiki, graph, dashboard — all read-only on port 6550.

Requirements

Quick Start

1
Create the folder: mkdir ~/.rag-db (or the Windows equivalent under your home directory)
2
Copy PROMPT.md below and save it as ~/.claude/skills/rag-init/SKILL.md
3
Start Claude Code and say: “Init the RAG”
4
Claude writes schema.sql, rag_helpers.py, capture.py, capture_mcp_server.py, four sibling skill files, and the web UI, then initializes the database
5
Verify with “RAG status”, then ingest your first source: “Add a note: my first RAG entry / this is content”
6
Query: “What do I know about [topic]?” — Claude answers with citations
7
Open the face: python ~/.rag-db/ui/server.py — the Catalog, Wiki, Graph, and Ledger appear at localhost:6550

Get the System

Two files. The README explains the system. The Prompt bootstraps every other file — database, skills, MCP server, and web UI — when you invoke it in Claude Code.

README.md
# Karpathy RAG — Personal Knowledge Base for Claude

## What Is This?

A personal RAG (retrieval-augmented generation) system that lives on your machine, indexes your text content, and lets Claude answer questions grounded in **your** material with citations. No cloud. No vector database. No embeddings server. Just SQLite + FTS5, a handful of Claude Code skills that turn Claude into a librarian for your own knowledge, an MCP server that lets every Claude interface write to the same database — and a local web interface that gives the whole archive a face.

Named after Andrej Karpathy because the architecture is deliberately simple. The intelligence isn't in fancy retrieval — it's in how you compile raw documents into entity pages, concept pages, and progressive insights that feed future queries.

## What It Can Do

- **FTS5 full-text search** with porter-stemmed ranking across every document
- **Source-grounded answers** — every fact cites a document ID, never falls back to training data
- **Progressive summarization** — capture syntheses back as `insight` docs so they compound over time
- **Relationship graph** — documents link to other documents (`derives_from`, `references`, etc.) and queries traverse the graph
- **Wiki compilation** — raw documents compile into entity pages, concept pages, area overviews, source summaries (with lineage tracked in `compilation_sources`)
- **Cross-interface capture** via MCP — Chat Desktop, Cowork, and Claude Code all write to the same database
- **Local web interface** — Catalog / Wiki / Graph / Ledger tabs over the same rag.db, read-only, Python stdlib only
- **Append-only operations log** — every read and write is auditable
- **Eleven slash commands** for init, status, query, capture, ingest, lint, sync, compile, refresh, relate, update

## File Structure

The system lives in two folders. The split isn't a mistake — Claude Code requires skills under `~/.claude/skills/` to be discoverable.

```
~/.rag-db/
  schema.sql                # SQLite schema with FTS5 + triggers
  rag_helpers.py            # CLI: init, status, search, related, log
  capture.py                # Save insights (used by skill + MCP)
  capture_mcp_server.py     # MCP server exposing capture across interfaces
  rag.db                    # SQLite database (created on init)
  ui/                       # OPTIONAL — the web interface
    server.py               #   stdlib-only HTTP server (JSON API + static + SPA)
    web/index.html          #   app shell
    web/style.css           #   design system
    web/app.js              #   single-page app: catalog, wiki, graph, ledger
    start-rag-ui.bat        #   console launcher (Windows)
    open-rag-ui.vbs         #   silent one-push launcher (Windows)

~/.claude/skills/
  rag-init/SKILL.md         # Bootstrap skill (the PROMPT below)
  rag-status/SKILL.md       # Quick dashboard
  rag-query/SKILL.md        # Search and synthesize with citations
  rag-capture/SKILL.md      # Save an insight (progressive summarization)
  rag-ingest/SKILL.md       # Add a source (manual notes; extend per source type)
```

The five skills above are the **core** that ships with the bootstrap. The full author's system has six more (`rag-lint`, `rag-sync`, `rag-compile`, `rag-refresh`, `rag-relate`, `rag-update`) which you can add as your library grows.

## Requirements

- **Claude Code** — Anthropic's CLI tool
- **Python 3.10+** with `sqlite3` compiled with FTS5 (default on Windows and macOS; on Linux, install `python3-sqlite` or build with the FTS5 flag)
- An empty `~/.rag-db/` folder (the bootstrap creates it if missing)
- **Optional:** `pip install mcp` if you want the cross-interface MCP server
- **Optional:** a Claude interface that loads `claude_desktop_config.json` (Chat Desktop, Cowork) to get the cross-interface capture benefit
- **The web UI needs nothing extra** — Python standard library plus any modern browser

## Quick Start

1. Save `PROMPT.md` as `~/.claude/skills/rag-init/SKILL.md`
2. Open the file and replace any `[PLACEHOLDER]` paths with your system paths (Python binary, home directory)
3. Start Claude Code and say: **"Init the RAG"**
4. Claude writes the core code files to `~/.rag-db/`, four sibling skill files to `~/.claude/skills/`, and (if you want it) the web UI to `~/.rag-db/ui/`, then runs `python ~/.rag-db/rag_helpers.py init` to create the database
5. Say: **"RAG status"** — you should see all tables present and FTS5 = OK
6. Add your first content: **"Add a note: my first RAG entry / and here is the content body"**
7. Query: **"What do I know about [topic]?"** — Claude searches the RAG and answers with `[Doc #N]` citations
8. Open the face: `python ~/.rag-db/ui/server.py` — Catalog, Wiki, Graph, and Ledger at http://localhost:6550

## The Web Interface

A zero-dependency local web app over the same database (Part 2 of the PROMPT):

| Tab | What it is |
|-----|-----------|
| **Catalog** | Live FTS5 search across all documents as you type — stemmed matching ("paint" finds "painting"), doc-type chips, source/tag filters, three sort orders |
| **Wiki** | The compiled layer — entity pages, concept pages, area overviews, source summaries |
| **Graph** | Interactive force-directed map of the relationship layer — click a node for details, double-click to recenter on its neighborhood |
| **Ledger** | Live dashboard — stat tiles, documents by type, source table with sync dates, recent operations log |

Every document opens as a full catalog card: rendered content, tags, relationships (links to / linked from), and compilation lineage. URLs are addressable: `#/doc/<id>`, `#/graph?center=<id>`, `#/search?q=...`.

Design guarantees: the server binds `127.0.0.1` only (not reachable from other machines) and opens the database with `mode=ro` — it is structurally incapable of writing. All writes go through the skills.

## Optional: Cross-Interface MCP

To make `capture_insight` callable from Chat Desktop and Cowork, add this to your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "rag-capture": {
      "command": "python",
      "args": ["C:/Users/YOUR_USERNAME/.rag-db/capture_mcp_server.py"]
    }
  }
}
```

Restart the interfaces. The `rag-capture` MCP appears with two tools: `capture_insight` and `list_recent_insights`. Anything captured in any interface is searchable from Claude Code via `/rag-query`.

## Optional: CLAUDE.md Integration

To make the RAG the first place Claude looks for personal knowledge, add this to `~/.claude/CLAUDE.md`:

```markdown
## Personal RAG Database

Database: ~/.rag-db/rag.db
Query CLI: python ~/.rag-db/rag_helpers.py search "query terms"

When answering questions about my history, builds, skills, goals, or past
sessions, query the RAG first before answering from training data. Cite as
[Source: <name>, Doc #<id>].

When generating a substantive synthesis, offer to capture it via /rag-capture.
```

## Customization

The schema is generic. The skills are templates. Things to personalize:

- **Document types** — `doc_type` is a free-text field. Use whatever names match your content: `session_entry`, `meeting_note`, `paper`, `letter`, `recipe`, anything.
- **Source pipelines** — the bundled `rag-ingest` handles manual notes. Extend it for your own sources (a CLI log file, a Notion export, a folder of markdown files) by adding a pipeline branch.
- **Compilation strategy** — the schema supports `entity_page`, `concept_page`, `area_overview`, `source_summary`. Use what fits; ignore what doesn't. Compilation skills aren't in the core bootstrap; you can build them once you have content.
- **MCP path** — if your username or install location differs, update the absolute path in `capture.py` (`DB_PATH`) and `claude_desktop_config.json`.
- **Tags** — the schema has a `tags` table and `document_tags` join. Apply tags during ingest; query with `--tag`.
- **UI port** — the web UI defaults to 6550; run `python ~/.rag-db/ui/server.py --port 7000` to change it.

## How a Query Works

1. You say: "What do I know about X?"
2. The `rag-query` skill runs `python ~/.rag-db/rag_helpers.py search "X"` — FTS5 returns top matches ranked by relevance, with snippets
3. The skill checks for compiled wiki pages first (`entity_page`, `concept_page`) — pre-synthesized answers if they exist
4. If not, it reads the full content of top raw documents
5. It optionally follows one hop of relationships for context
6. It composes a conversational answer with `[Doc #N]` citations
7. The query is logged to `operations_log` for audit

## How a Capture Works

1. You (or Claude during a query) generates a synthesis
2. You say: "Capture this" — or Claude offers and you accept
3. The `rag-capture` skill calls `python ~/.rag-db/capture.py save --title ... --content ... --tags ...`
4. `capture.py` inserts a row into `documents` with `doc_type='insight'`, wires `derives_from` relationships to source docs, applies tags, logs the operation
5. FTS5 triggers index it instantly — the insight is searchable in the next query
6. From any other interface (Chat, Cowork) the same insight is reachable via the `rag-capture` MCP

## License

Open. Build it, modify it, ship your own version. If you make something cool, send it back.
PROMPT.md — Save as ~/.claude/skills/rag-init/SKILL.md
---
name: rag-init
description: >
  Bootstrap the personal Karpathy RAG database. Writes schema.sql, rag_helpers.py,
  capture.py, capture_mcp_server.py, four sibling skill files, and (optionally) the
  local web UI, then creates the SQLite database with FTS5. Trigger when the user says "init the RAG",
  "bootstrap RAG", "set up the knowledge base", "create the RAG database",
  or any variation of asking to install or initialize the RAG system.
allowed-tools: Bash Read Write
argument-hint: (no arguments needed)
---

# Karpathy RAG — Bootstrap

You are bootstrapping a personal RAG (retrieval-augmented generation) system. When invoked, you write all required files to disk, then initialize the SQLite database. After bootstrap, the user has five working slash commands: `/rag-init`, `/rag-status`, `/rag-query`, `/rag-capture`, `/rag-ingest`. Part 2 (optional) adds the local web interface — a zero-dependency, read-only browser UI over the same database.

## System Locations

- **Database folder:** `~/.rag-db/` (create if missing)
- **Database file:** `~/.rag-db/rag.db` (created by init)
- **Skills folder:** `~/.claude/skills/`
- **Web UI folder (optional, Part 2):** `~/.rag-db/ui/` with assets in `~/.rag-db/ui/web/`
- **Python:** use whichever Python is on PATH; if a specific path is needed, ask the user once and use it consistently

> If `~/.rag-db/rag.db` already exists, do **not** overwrite. Run status instead and tell the user the database is already initialized. Ask before any destructive action.

## Bootstrap Sequence

Execute these steps in order. After each Write, confirm the file is on disk before proceeding.

### Step 1 — Pre-flight check

Run:
```
python -c "import sqlite3; print(sqlite3.sqlite_version)"
```

Verify SQLite version is >= 3.9 (FTS5 requires this). If lower, stop and report — the user needs a newer Python.

Check whether `~/.rag-db/rag.db` already exists. If it does, run `python ~/.rag-db/rag_helpers.py status` (assuming helpers exist) and tell the user the system is already bootstrapped. Stop here unless they explicitly ask for a fresh re-init.

### Step 2 — Create folders

Create `~/.rag-db/` if missing. Create `~/.claude/skills/rag-init/`, `~/.claude/skills/rag-status/`, `~/.claude/skills/rag-query/`, `~/.claude/skills/rag-capture/`, `~/.claude/skills/rag-ingest/` if missing. If the user wants the web UI (Part 2), also create `~/.rag-db/ui/` and `~/.rag-db/ui/web/`.

### Step 3 — Write schema.sql

Write the following content **exactly** to `~/.rag-db/schema.sql`:

~~~sql
-- Personal RAG Database Schema
-- SQLite + FTS5

CREATE TABLE IF NOT EXISTS config (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

INSERT OR IGNORE INTO config (key, value) VALUES
    ('schema_version', '1'),
    ('description', 'Personal Karpathy RAG'),
    ('created_at', datetime('now'));

CREATE TABLE IF NOT EXISTS sources (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    source_type TEXT NOT NULL,
    uri TEXT,
    description TEXT,
    metadata TEXT,
    ingested_at TEXT NOT NULL DEFAULT (datetime('now')),
    last_synced_at TEXT,
    status TEXT NOT NULL DEFAULT 'active',
    checksum TEXT
);
CREATE INDEX IF NOT EXISTS idx_sources_type ON sources(source_type);
CREATE INDEX IF NOT EXISTS idx_sources_status ON sources(status);

CREATE TABLE IF NOT EXISTS documents (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_id INTEGER NOT NULL REFERENCES sources(id),
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    doc_type TEXT NOT NULL DEFAULT 'note',
    metadata TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
    status TEXT NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_documents_source ON documents(source_id);
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(doc_type);
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);

CREATE TABLE IF NOT EXISTS tags (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    category TEXT,
    description TEXT
);

CREATE TABLE IF NOT EXISTS document_tags (
    document_id INTEGER NOT NULL REFERENCES documents(id),
    tag_id INTEGER NOT NULL REFERENCES tags(id),
    PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag_id);

CREATE TABLE IF NOT EXISTS relationships (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_doc_id INTEGER NOT NULL REFERENCES documents(id),
    target_doc_id INTEGER NOT NULL REFERENCES documents(id),
    relationship_type TEXT NOT NULL,
    description TEXT,
    confidence REAL DEFAULT 1.0,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    created_by TEXT NOT NULL DEFAULT 'system'
);
CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_doc_id);
CREATE INDEX IF NOT EXISTS idx_rel_target ON relationships(target_doc_id);
CREATE INDEX IF NOT EXISTS idx_rel_type ON relationships(relationship_type);

CREATE TABLE IF NOT EXISTS synthesis (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    synthesis_type TEXT NOT NULL,
    scope TEXT,
    document_ids TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
    status TEXT NOT NULL DEFAULT 'current'
);
CREATE INDEX IF NOT EXISTS idx_synthesis_type ON synthesis(synthesis_type);
CREATE INDEX IF NOT EXISTS idx_synthesis_status ON synthesis(status);

CREATE TABLE IF NOT EXISTS operations_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT NOT NULL DEFAULT (datetime('now')),
    operation TEXT NOT NULL,
    target_table TEXT,
    target_id INTEGER,
    detail TEXT,
    actor TEXT NOT NULL DEFAULT 'claude'
);
CREATE INDEX IF NOT EXISTS idx_ops_log_timestamp ON operations_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_ops_log_operation ON operations_log(operation);

CREATE TABLE IF NOT EXISTS compilation_sources (
    compiled_doc_id INTEGER NOT NULL REFERENCES documents(id),
    source_doc_id INTEGER NOT NULL REFERENCES documents(id),
    contributed_at TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (compiled_doc_id, source_doc_id)
);
CREATE INDEX IF NOT EXISTS idx_comp_sources_src ON compilation_sources(source_doc_id);

CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
    title,
    content,
    content='documents',
    content_rowid='id',
    tokenize='porter unicode61'
);

CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
    INSERT INTO documents_fts(rowid, title, content)
    VALUES (new.id, new.title, new.content);
END;

CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
    INSERT INTO documents_fts(documents_fts, rowid, title, content)
    VALUES ('delete', old.id, old.title, old.content);
END;

CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
    INSERT INTO documents_fts(documents_fts, rowid, title, content)
    VALUES ('delete', old.id, old.title, old.content);
    INSERT INTO documents_fts(rowid, title, content)
    VALUES (new.id, new.title, new.content);
END;
~~~

### Step 4 — Write rag_helpers.py

Write the following content **exactly** to `~/.rag-db/rag_helpers.py`:

~~~python
"""
Personal RAG Database — Query Interface

Usage:
  python ~/.rag-db/rag_helpers.py init
  python ~/.rag-db/rag_helpers.py status
  python ~/.rag-db/rag_helpers.py search "query terms"
  python ~/.rag-db/rag_helpers.py search "query" --type insight --tag projects
  python ~/.rag-db/rag_helpers.py related <doc_id>
  python ~/.rag-db/rag_helpers.py log
"""
import sqlite3
import hashlib
import json
import sys
import os

if sys.stdout.encoding != 'utf-8':
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')

DB_PATH = os.path.expanduser("~/.rag-db/rag.db")
SCHEMA_PATH = os.path.expanduser("~/.rag-db/schema.sql")


def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def log_op(conn, operation, target_table=None, target_id=None, detail=None, actor="claude"):
    conn.execute(
        "INSERT INTO operations_log (operation, target_table, target_id, detail, actor) VALUES (?, ?, ?, ?, ?)",
        (operation, target_table, target_id,
         json.dumps(detail) if isinstance(detail, dict) else detail, actor)
    )


def checksum(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def init_db():
    if not os.path.exists(SCHEMA_PATH):
        print(f"ERROR: schema.sql not found at {SCHEMA_PATH}")
        sys.exit(1)
    conn = sqlite3.connect(DB_PATH)
    with open(SCHEMA_PATH, "r") as f:
        conn.executescript(f.read())
    conn.close()
    conn = connect()
    cur = conn.cursor()
    cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
    tables = [row["name"] for row in cur.fetchall()]
    fts_ok = "documents_fts" in tables
    if fts_ok:
        try:
            cur.execute("INSERT INTO sources (name, source_type) VALUES ('_fts_test', 'manual')")
            test_source_id = cur.lastrowid
            cur.execute(
                "INSERT INTO documents (source_id, title, content, doc_type) VALUES (?, 'fts_test', 'fts_test_content', 'test')",
                (test_source_id,))
            cur.execute("SELECT * FROM documents_fts WHERE documents_fts MATCH 'fts_test'")
            result = cur.fetchone()
            fts_ok = result is not None
            cur.execute("DELETE FROM documents WHERE title = 'fts_test'")
            cur.execute("DELETE FROM sources WHERE name = '_fts_test'")
            conn.commit()
        except Exception as e:
            fts_ok = False
            print(f"FTS5 test failed: {e}")
    log_op(conn, "init", detail={"tables": tables, "fts_ok": fts_ok}, actor="system")
    conn.commit()
    conn.close()
    print("RAG DATABASE INITIALIZED")
    print("=" * 50)
    print(f"Path: {DB_PATH}")
    print(f"Tables: {', '.join(tables)}")
    print(f"FTS5: {'OK' if fts_ok else 'FAILED'}")
    print("=" * 50)


def status():
    if not os.path.exists(DB_PATH):
        print("RAG database does not exist yet. Run: python ~/.rag-db/rag_helpers.py init")
        return
    conn = connect()
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM sources WHERE status = 'active'")
    source_count = cur.fetchone()[0]
    cur.execute("SELECT COUNT(*) FROM documents WHERE status = 'active'")
    doc_count = cur.fetchone()[0]
    cur.execute("SELECT COUNT(*) FROM tags")
    tag_count = cur.fetchone()[0]
    cur.execute("SELECT COUNT(*) FROM relationships")
    rel_count = cur.fetchone()[0]
    cur.execute("SELECT COUNT(*) FROM synthesis WHERE status = 'current'")
    syn_count = cur.fetchone()[0]
    cur.execute("SELECT COUNT(*) FROM operations_log")
    ops_count = cur.fetchone()[0]
    cur.execute("""
        SELECT doc_type, COUNT(*) as cnt FROM documents WHERE status = 'active'
        GROUP BY doc_type ORDER BY cnt DESC
    """)
    by_type = cur.fetchall()
    cur.execute("""
        SELECT source_type, COUNT(*) as cnt, MAX(last_synced_at) as last_sync
        FROM sources WHERE status = 'active'
        GROUP BY source_type ORDER BY cnt DESC
    """)
    by_source = cur.fetchall()
    cur.execute("""
        SELECT timestamp, operation, target_table, detail
        FROM operations_log ORDER BY id DESC LIMIT 5
    """)
    recent_ops = cur.fetchall()
    db_size = os.path.getsize(DB_PATH)
    if db_size < 1024:
        size_str = f"{db_size} B"
    elif db_size < 1024 * 1024:
        size_str = f"{db_size / 1024:.1f} KB"
    else:
        size_str = f"{db_size / (1024*1024):.1f} MB"
    conn.close()
    print("RAG DATABASE STATUS")
    print("=" * 50)
    print(f"Path: {DB_PATH} ({size_str})")
    print(f"Sources: {source_count} | Documents: {doc_count} | Tags: {tag_count}")
    print(f"Relationships: {rel_count} | Synthesis: {syn_count} | Ops: {ops_count}")
    if by_type:
        print("\nDOCUMENTS BY TYPE:")
        for row in by_type:
            print(f"  {row['doc_type']:25s} {row['cnt']:>5}")
    if by_source:
        print("\nSOURCES BY TYPE:")
        for row in by_source:
            sync = row['last_sync'] or 'never'
            print(f"  {row['source_type']:25s} {row['cnt']:>5}  (last sync: {sync})")
    if recent_ops:
        print("\nRECENT OPERATIONS:")
        for row in recent_ops:
            detail = row['detail'][:60] if row['detail'] else ''
            print(f"  {row['timestamp']}  {row['operation']:12s} {row['target_table'] or '':15s} {detail}")
    print("=" * 50)


def search(query, doc_type=None, tag=None, limit=20):
    conn = connect()
    cur = conn.cursor()
    params = [query]
    sql = """
        SELECT d.id, d.title, d.doc_type, d.source_id, d.metadata,
               snippet(documents_fts, 1, '>>>', '<<<', '...', 40) as snippet,
               s.name as source_name, s.source_type
        FROM documents_fts f
        JOIN documents d ON d.id = f.rowid
        JOIN sources s ON s.id = d.source_id
        WHERE documents_fts MATCH ?
          AND d.status = 'active'
    """
    if doc_type:
        sql += " AND d.doc_type = ?"
        params.append(doc_type)
    if tag:
        sql += " AND d.id IN (SELECT dt.document_id FROM document_tags dt JOIN tags t ON t.id = dt.tag_id WHERE t.name = ?)"
        params.append(tag)
    sql += f" ORDER BY rank LIMIT {limit}"
    cur.execute(sql, params)
    results = cur.fetchall()
    log_op(conn, "query", detail={"query": query, "doc_type": doc_type, "tag": tag, "results": len(results)})
    conn.commit()
    conn.close()
    print(f'SEARCH: "{query}"')
    if doc_type:
        print(f"Filter: doc_type={doc_type}")
    if tag:
        print(f"Filter: tag={tag}")
    print("=" * 50)
    if results:
        for r in results:
            print(f"\n  [{r['id']}] {r['title']}")
            print(f"       Type: {r['doc_type']} | Source: {r['source_name']} ({r['source_type']})")
            print(f"       {r['snippet']}")
    else:
        print("  No results found.")
    print(f"\n{len(results)} result(s)")
    print("=" * 50)


def related(doc_id):
    conn = connect()
    cur = conn.cursor()
    cur.execute("SELECT id, title, doc_type FROM documents WHERE id = ?", (doc_id,))
    doc = cur.fetchone()
    if not doc:
        print(f"Document #{doc_id} not found.")
        conn.close()
        return
    cur.execute("""
        SELECT r.relationship_type, r.confidence, r.description,
               d.id, d.title, d.doc_type
        FROM relationships r JOIN documents d ON d.id = r.target_doc_id
        WHERE r.source_doc_id = ? AND d.status = 'active'
        ORDER BY r.confidence DESC
    """, (doc_id,))
    outgoing = cur.fetchall()
    cur.execute("""
        SELECT r.relationship_type, r.confidence, r.description,
               d.id, d.title, d.doc_type
        FROM relationships r JOIN documents d ON d.id = r.source_doc_id
        WHERE r.target_doc_id = ? AND d.status = 'active'
        ORDER BY r.confidence DESC
    """, (doc_id,))
    incoming = cur.fetchall()
    cur.execute("""
        SELECT t.name, t.category FROM tags t
        JOIN document_tags dt ON dt.tag_id = t.id
        WHERE dt.document_id = ?
    """, (doc_id,))
    tags = cur.fetchall()
    conn.close()
    print(f"RELATED: [{doc['id']}] {doc['title']} ({doc['doc_type']})")
    print("=" * 50)
    if tags:
        print(f"\nTags: {', '.join(t['name'] for t in tags)}")
    if outgoing:
        print("\nLINKS TO:")
        for r in outgoing:
            conf = f" ({r['confidence']:.0%})" if r['confidence'] < 1.0 else ""
            print(f"  --[{r['relationship_type']}]--> [{r['id']}] {r['title']}{conf}")
    if incoming:
        print("\nLINKED FROM:")
        for r in incoming:
            conf = f" ({r['confidence']:.0%})" if r['confidence'] < 1.0 else ""
            print(f"  <--[{r['relationship_type']}]-- [{r['id']}] {r['title']}{conf}")
    if not outgoing and not incoming:
        print("\n  No relationships found.")
    print("=" * 50)


def show_log(limit=20):
    conn = connect()
    cur = conn.cursor()
    cur.execute("""
        SELECT timestamp, operation, target_table, target_id, detail, actor
        FROM operations_log ORDER BY id DESC LIMIT ?
    """, (limit,))
    rows = cur.fetchall()
    conn.close()
    print(f"OPERATIONS LOG (last {limit})")
    print("=" * 50)
    for r in rows:
        target = f"{r['target_table']}#{r['target_id']}" if r['target_table'] else ""
        detail = r['detail'][:80] if r['detail'] else ""
        print(f"  {r['timestamp']}  {r['operation']:12s} {target:20s} {detail}")
    if not rows:
        print("  No operations logged.")
    print("=" * 50)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage:")
        print("  python rag_helpers.py init")
        print("  python rag_helpers.py status")
        print('  python rag_helpers.py search "query" [--type TYPE] [--tag TAG]')
        print("  python rag_helpers.py related <doc_id>")
        print("  python rag_helpers.py log [limit]")
        sys.exit(1)
    cmd = sys.argv[1]
    if cmd == "init":
        init_db()
    elif cmd == "status":
        status()
    elif cmd == "search":
        if len(sys.argv) < 3:
            print('Usage: python rag_helpers.py search "query" [--type TYPE] [--tag TAG]')
            sys.exit(1)
        query = sys.argv[2]
        doc_type = None
        tag = None
        args = sys.argv[3:]
        i = 0
        while i < len(args):
            if args[i] == "--type" and i + 1 < len(args):
                doc_type = args[i + 1]; i += 2
            elif args[i] == "--tag" and i + 1 < len(args):
                tag = args[i + 1]; i += 2
            else:
                i += 1
        search(query, doc_type=doc_type, tag=tag)
    elif cmd == "related":
        if len(sys.argv) < 3:
            print("Usage: python rag_helpers.py related <doc_id>")
            sys.exit(1)
        related(int(sys.argv[2]))
    elif cmd == "log":
        limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
        show_log(limit)
    else:
        print(f"Unknown command: {cmd}")
        sys.exit(1)
~~~

### Step 5 — Write capture.py

Write the following content **exactly** to `~/.rag-db/capture.py`. Update `DB_PATH` if your home directory or username differs.

~~~python
"""
capture.py — Progressive summarization for the Karpathy RAG.

Persists syntheses/insights generated by any Claude interface back into rag.db
as doc_type='insight', wired into the relationship graph.

Used by:
  - /rag-capture skill (Claude Code, via Bash)
  - capture_mcp_server.py (Chat Desktop, Cowork, Claude Code MCP)
  - Direct Python calls for scripting
"""
import sqlite3
import json
import sys
import os
from datetime import datetime
from typing import Optional

DB_PATH = os.path.expanduser("~/.rag-db/rag.db")
INSIGHT_SOURCE_NAME = "Captured Insights"


def _connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def _ensure_insight_source(cur) -> int:
    cur.execute("SELECT id FROM sources WHERE name = ? LIMIT 1", (INSIGHT_SOURCE_NAME,))
    row = cur.fetchone()
    if row:
        return row["id"]
    now = datetime.now().isoformat()
    cur.execute(
        """INSERT INTO sources (name, source_type, uri, description,
                                ingested_at, last_synced_at, status)
           VALUES (?, 'insight', 'internal://captured', ?, ?, ?, 'active')""",
        (INSIGHT_SOURCE_NAME, "Syntheses captured from Claude sessions", now, now),
    )
    return cur.lastrowid


def save_insight(title, content, source_doc_ids=None, tags=None,
                 origin="claude", context_note=None):
    if not title or not title.strip():
        raise ValueError("title is required")
    if not content or not content.strip():
        raise ValueError("content is required")
    source_doc_ids = source_doc_ids or []
    tags = list(tags or [])
    if "insight" not in tags:
        tags.append("insight")
    conn = _connect()
    cur = conn.cursor()
    now = datetime.now().isoformat()
    source_id = _ensure_insight_source(cur)
    metadata = {"origin": origin, "captured_at": now, "source_doc_ids": source_doc_ids}
    if context_note:
        metadata["context"] = context_note
    cur.execute(
        """INSERT INTO documents (source_id, title, content, doc_type, metadata,
                                  created_at, updated_at, status)
           VALUES (?, ?, ?, 'insight', ?, ?, ?, 'active')""",
        (source_id, title.strip(), content.strip(),
         json.dumps(metadata), now, now),
    )
    doc_id = cur.lastrowid
    rel_count = 0
    for src_id in source_doc_ids:
        cur.execute("SELECT 1 FROM documents WHERE id = ? AND status = 'active'", (src_id,))
        if not cur.fetchone():
            continue
        cur.execute(
            """INSERT INTO relationships (source_doc_id, target_doc_id, relationship_type,
                  description, confidence, created_at, created_by)
               VALUES (?, ?, 'derives_from', ?, 0.9, ?, ?)""",
            (doc_id, src_id, "Insight synthesized from this source", now, origin),
        )
        rel_count += 1
    tag_count = 0
    for tag_name in tags:
        tag_name = tag_name.strip().lower()
        if not tag_name:
            continue
        cur.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
        cur.execute("SELECT id FROM tags WHERE name = ?", (tag_name,))
        tag_id = cur.fetchone()["id"]
        cur.execute("INSERT OR IGNORE INTO document_tags (document_id, tag_id) VALUES (?, ?)",
                    (doc_id, tag_id))
        tag_count += 1
    cur.execute(
        """INSERT INTO operations_log (timestamp, operation, target_table, target_id, detail, actor)
           VALUES (?, 'capture_insight', 'documents', ?, ?, ?)""",
        (now, doc_id,
         json.dumps({"title": title.strip(), "source_doc_ids": source_doc_ids,
                     "relationships_created": rel_count, "tags_applied": tag_count}),
         origin),
    )
    conn.commit()
    conn.close()
    return {"doc_id": doc_id, "source_id": source_id, "title": title.strip(),
            "relationships_created": rel_count, "tags_applied": tag_count,
            "timestamp": now}


def list_insights(limit=20):
    conn = _connect()
    cur = conn.cursor()
    cur.execute(
        """SELECT id, title, created_at, metadata FROM documents
           WHERE doc_type = 'insight' AND status = 'active'
           ORDER BY created_at DESC LIMIT ?""",
        (limit,),
    )
    rows = [dict(r) for r in cur.fetchall()]
    conn.close()
    return rows


if __name__ == "__main__":
    if sys.stdout.encoding != 'utf-8':
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    import argparse
    ap = argparse.ArgumentParser(description="Capture insight into RAG")
    sub = ap.add_subparsers(dest="cmd", required=True)
    p_save = sub.add_parser("save", help="Save an insight")
    p_save.add_argument("--title", required=True)
    p_save.add_argument("--content", required=True,
                        help="Content text, or @path/to/file to read from file")
    p_save.add_argument("--sources", default="", help="Comma-separated doc IDs")
    p_save.add_argument("--tags", default="", help="Comma-separated tag names")
    p_save.add_argument("--origin", default="cli")
    p_save.add_argument("--context", default=None)
    p_list = sub.add_parser("list", help="List recent insights")
    p_list.add_argument("--limit", type=int, default=20)
    args = ap.parse_args()
    if args.cmd == "save":
        content = args.content
        if content.startswith("@"):
            with open(content[1:], "r", encoding="utf-8") as f:
                content = f.read()
        src_ids = [int(x) for x in args.sources.split(",") if x.strip()]
        tag_list = [t.strip() for t in args.tags.split(",") if t.strip()]
        result = save_insight(title=args.title, content=content,
                              source_doc_ids=src_ids, tags=tag_list,
                              origin=args.origin, context_note=args.context)
        print(json.dumps(result, indent=2))
    elif args.cmd == "list":
        for r in list_insights(args.limit):
            print(f"[{r['id']}] {r['created_at']}  {r['title']}")
~~~

### Step 6 — Write capture_mcp_server.py

Write the following content **exactly** to `~/.rag-db/capture_mcp_server.py`. Requires `pip install mcp` if the user wants cross-interface capture.

~~~python
"""
capture_mcp_server.py — MCP server exposing the RAG capture tool.

Makes progressive summarization available to any Claude interface that loads
claude_desktop_config.json (Chat Desktop, Cowork, Claude Code).

Shares the same backing code (capture.py) used by the /rag-capture skill, so
all interfaces write to the same rag.db with identical semantics.

Register in claude_desktop_config.json as "rag-capture".
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from mcp.server.fastmcp import FastMCP
import capture

mcp = FastMCP("rag-capture")


@mcp.tool()
def capture_insight(title: str, content: str,
                    source_doc_ids: list[int] | None = None,
                    tags: list[str] | None = None,
                    origin: str = "claude_mcp",
                    context_note: str | None = None) -> dict:
    """Persist a synthesis/insight into the Karpathy RAG database.

    Use whenever you generate a substantive synthesis during a conversation —
    connecting multiple sources, distilling a concept, or compiling an answer
    that should compound into future queries. Becomes a permanent doc_type=
    'insight' row in rag.db, wired into the relationship graph, FTS5-searchable.

    Args:
        title: Short title, <=80 chars.
        content: Full synthesis text. Long-form is fine.
        source_doc_ids: Optional list of RAG document IDs this derives from.
        tags: Optional list of tag names. 'insight' is auto-added.
        origin: 'claude_chat', 'claude_cowork', 'claude_code', or 'claude_mcp'.
        context_note: Optional note about where/how this was generated.

    Returns:
        dict with doc_id, source_id, title, relationships_created,
        tags_applied, timestamp.
    """
    return capture.save_insight(
        title=title, content=content,
        source_doc_ids=source_doc_ids or [],
        tags=tags or [], origin=origin,
        context_note=context_note,
    )


@mcp.tool()
def list_recent_insights(limit: int = 20) -> list[dict]:
    """List the most recently captured insights in the RAG."""
    return capture.list_insights(limit=limit)


if __name__ == "__main__":
    mcp.run()
~~~

### Step 7 — Write rag-status/SKILL.md

Write the following to `~/.claude/skills/rag-status/SKILL.md`:

~~~markdown
---
name: rag-status
description: Quick dashboard for the personal Karpathy RAG database. Shows counts, document types, recent activity, source coverage. Trigger when the user says "RAG status", "how big is the knowledge base", "RAG dashboard", "what's in my RAG".
allowed-tools: Bash Read
---

# RAG Status

Show a quick dashboard of the personal RAG database.

## Steps

1. Run:
   ```
   python ~/.rag-db/rag_helpers.py status
   ```

2. Present the output conversationally. Highlight anything notable:
   - If the database is empty (0 documents), suggest running `/rag-ingest` to add content
   - If sources have not been synced in > 30 days, flag them as stale
   - If there are many documents but no synthesis entries, mention that compilation is available

## Presentation Rules

- Keep it concise — this is a quick check-in, not a deep dive
- If the user wants more detail, suggest `/rag-query` or `python ~/.rag-db/rag_helpers.py search "..."` for searching, or `python ~/.rag-db/rag_helpers.py related <id>` for following the graph
- If the database does not exist yet (helper prints that), tell the user to run `/rag-init` first
~~~

### Step 8 — Write rag-query/SKILL.md

Write the following to `~/.claude/skills/rag-query/SKILL.md`:

~~~markdown
---
name: rag-query
description: Search and synthesize from the personal Karpathy RAG database. Uses FTS5 full-text search, tag/type filtering, and relationship traversal. Never answers from training data — all responses grounded in database content with citations. Trigger when the user says "search the RAG", "what do I know about", "RAG query", "find in my knowledge base", "look up in RAG".
allowed-tools: Bash Read
argument-hint: <question-or-keywords> e.g. "project decisions", "#meetings"
---

# RAG Query

Search and synthesize from the personal Karpathy RAG database.

## Core Rules

1. **Never answer from memory.** Every claim must come from the database. If the database has no relevant content, say so — do not fill gaps with training data.
2. **Cite everything.** Every fact gets `[Source: <source_name>, Doc #<id>]`.
3. **Lead with compiled pages if they exist.** `entity_page` and `concept_page` documents are pre-synthesized answers — check first.
4. **Follow relationships.** After finding initial results, check one hop of relationships for additional context.
5. **Offer to capture.** If the synthesis is substantive, offer to save it via `/rag-capture`.

## Steps

1. **Parse the query.** Determine search terms, optional doc_type filter (if the query mentions "sessions", "insights", "notes", etc.), optional tag filter (if the query starts with `#`).

2. **Check for compiled pages first** with inline Python:
   ```python
   python -c "
   import sqlite3
   conn = sqlite3.connect('~/.rag-db/rag.db'.replace('~', __import__('os').path.expanduser('~')))
   conn.row_factory = sqlite3.Row
   cur = conn.cursor()
   cur.execute('''SELECT id, title, doc_type, content FROM documents
       WHERE doc_type IN ('entity_page', 'concept_page', 'area_overview', 'source_summary')
       AND status = 'active'
       AND id IN (SELECT rowid FROM documents_fts WHERE documents_fts MATCH ?)
       LIMIT 5''', ('QUERY_TERMS',))
   for r in cur.fetchall():
       print(f"[{r['id']}] {r['title']} ({r['doc_type']})")
       print(r['content'][:300])
       print('---')
   conn.close()
   "
   ```
   If compiled pages exist, lead with them.

3. **Run FTS search:**
   ```
   python ~/.rag-db/rag_helpers.py search "query terms"
   python ~/.rag-db/rag_helpers.py search "query" --type insight
   python ~/.rag-db/rag_helpers.py search "query" --tag projects
   ```

4. **Read top results in full** if snippets aren't enough — query `documents` by ID with inline Python.

5. **Follow relationships** for the top match:
   ```
   python ~/.rag-db/rag_helpers.py related <doc_id>
   ```

6. **Synthesize and present.** Lead with the direct answer. Follow with citations. If results are sparse, say what's missing.

## FTS5 Query Syntax

- Simple terms: `productivity systems`
- Phrase: `"exact phrase"`
- Boolean: `meeting AND decision NOT done`
- Prefix: `work*`
- Column filter: `title:meeting`

## Presentation

- Lead with the direct answer
- Follow with supporting evidence and `[Doc #N]` citations
- If rich, offer to capture: "Want me to save this synthesis to the RAG?" (calls `/rag-capture`)
~~~

### Step 9 — Write rag-capture/SKILL.md

Write the following to `~/.claude/skills/rag-capture/SKILL.md`:

~~~markdown
---
name: rag-capture
description: Persist a synthesis/insight into the personal Karpathy RAG database as doc_type='insight', wired into the relationship graph. Progressive summarization — every valuable synthesis gets saved so it compounds. Trigger when the user says "capture this", "save this insight", "remember this", "rag-capture", or when Claude generates a substantive synthesis that should be persisted.
allowed-tools: Bash Read Write
argument-hint: [optional title hint]
---

# RAG Capture — Progressive Summarization

Every substantive synthesis Claude produces while working with the RAG should be captured back into it. Raw documents → compiled wiki pages → syntheses → persisted insights that feed future queries.

## When to Use

**Use when:**
- The user says "capture this", "save this insight", "remember this"
- A `/rag-query` produced a meaningful synthesis that connected disparate docs
- A conversation produced a conceptual distillation worth keeping
- The user pastes in a synthesis from another Claude session that needs persisting

**Skip when:**
- The content is trivial, short, or just restates one existing doc
- The synthesis is really a new entity/concept page (use a compile skill instead, if you have one)

## Protocol

### Step 1: Gather inputs

Ask (or infer from context):
- **Title**: <=80 chars, punchy. Propose one and confirm if not given.
- **Content**: the full synthesis text. Long-form is fine.
- **Source docs**: IDs of any RAG documents this was built from. From a `/rag-query`, use those result IDs.
- **Tags**: 3-6 tag names, lowercase. `insight` is auto-added.

### Step 2: Show preview

```
CAPTURE PREVIEW
===============
Title:     [title]
Tags:      [tags]
Sources:   [doc IDs or "none"]
Content:   [first 200 chars...]

Content length: [N chars]
```

Ask: "Capture this?" Wait for confirmation.

### Step 3: Write via capture.py

For short content, pass inline:
```bash
python ~/.rag-db/capture.py save \
  --title "TITLE" \
  --content "CONTENT" \
  --sources "123,456" \
  --tags "tag1,tag2" \
  --origin "claude_code"
```

For long content (> 500 chars or contains quotes/newlines), write to a temp file first:
```bash
# Write content to a temp file using your editor or Write tool, e.g. /tmp/insight.md

python ~/.rag-db/capture.py save \
  --title "TITLE" \
  --content "@/tmp/insight.md" \
  --sources "123,456" \
  --tags "tag1,tag2" \
  --origin "claude_code"
```

### Step 4: Report

```
CAPTURED
========
Doc ID:          #[id]
Relationships:   [N] created
Tags applied:    [N]

Now searchable via /rag-query and reachable in the relationship graph.
```

## Cross-Interface

The same capability is exposed as MCP (`capture_insight` on the `rag-capture` server) so Chat Desktop and Cowork can persist insights to the same database. An insight saved in any interface is visible from every other one.

## Rules

1. **Confirm before writing.** This goes to the permanent knowledge base.
2. **Prefer rich linking.** If you know the source docs, pass them.
3. **Keep titles honest.** Match the content's actual scope, don't oversell.
4. **Origin matters.** Use `claude_code`, `claude_chat`, or `claude_cowork` to track provenance.
~~~

### Step 10 — Write rag-ingest/SKILL.md

Write the following to `~/.claude/skills/rag-ingest/SKILL.md`. This is a slim ingest skill covering manual notes only; the user can extend it with custom pipelines for their own sources.

~~~markdown
---
name: rag-ingest
description: Add content to the personal Karpathy RAG database. The bundled version handles manual notes (title + content + tags). Extend it with custom pipelines for your own sources (session logs, exports, file folders, etc). Trigger when the user says "add a note", "ingest", "add to RAG", "import into RAG".
allowed-tools: Bash Read Write
argument-hint: <source-type-or-content> e.g. "manual", or "add a note: title / content"
---

# RAG Ingest

Add content to the personal RAG database at `~/.rag-db/rag.db`.

## Core Rules

1. **Human-in-the-loop.** Always show what will be ingested (title, type, tags) and confirm before writing.
2. **Source provenance.** Every document traces back to a source row. For manual notes, use a single shared "Manual Notes" source.
3. **Log everything.** Every ingest gets a row in `operations_log`.
4. **Tag generously.** Apply at minimum the source-type tag and any topic tags from the content.

## Manual Note Pipeline

When the user says "add a note" or provides title + content directly:

1. Parse the title and content from the request. If only one is given, ask for the other.
2. Ask for tags if not given. Suggest 2-3 from the content.
3. Show the preview:
   ```
   INGEST PREVIEW
   ==============
   Title:    [title]
   Type:     note
   Tags:     [tags]
   Content:  [first 200 chars...]
   ```
4. Ask: "Ingest this?" Wait for confirmation.

5. Write via inline Python:
   ```python
   python -c "
   import sqlite3, json, os
   db = os.path.expanduser('~/.rag-db/rag.db')
   conn = sqlite3.connect(db)
   conn.execute('PRAGMA foreign_keys=ON')
   cur = conn.cursor()
   # Get or create the Manual Notes source
   cur.execute("SELECT id FROM sources WHERE name = 'Manual Notes' LIMIT 1")
   row = cur.fetchone()
   if row:
       source_id = row[0]
   else:
       cur.execute("INSERT INTO sources (name, source_type, uri, description) VALUES (?, ?, ?, ?)",
                   ('Manual Notes', 'manual', 'internal://manual', 'Notes added directly via /rag-ingest'))
       source_id = cur.lastrowid
   # Insert document
   cur.execute('INSERT INTO documents (source_id, title, content, doc_type) VALUES (?, ?, ?, ?)',
               (source_id, 'TITLE_HERE', 'CONTENT_HERE', 'note'))
   doc_id = cur.lastrowid
   # Apply tags
   for tag in ['tag1', 'tag2']:
       cur.execute('INSERT OR IGNORE INTO tags (name) VALUES (?)', (tag,))
       cur.execute('SELECT id FROM tags WHERE name = ?', (tag,))
       tag_id = cur.fetchone()[0]
       cur.execute('INSERT OR IGNORE INTO document_tags (document_id, tag_id) VALUES (?, ?)', (doc_id, tag_id))
   # Log
   cur.execute('INSERT INTO operations_log (operation, target_table, target_id, detail, actor) VALUES (?, ?, ?, ?, ?)',
               ('ingest', 'documents', doc_id, json.dumps({'source_type': 'manual', 'title': 'TITLE_HERE'}), 'claude'))
   conn.commit()
   print(f'Ingested document #{doc_id}')
   conn.close()
   "
   ```

   For multi-line content, use a temp file or the Write tool first, then read it in the Python script.

6. Report: "Ingested document #N. Searchable via /rag-query."

## Extending Ingestion

To add a custom pipeline (e.g., for a folder of markdown files, a Notion export, a session log):

1. Add a new section to this skill describing when it triggers
2. Write a small Python script (or extend `rag_helpers.py`) that:
   - Creates one source row for the data origin
   - Iterates the items, creating one document each with appropriate `doc_type`
   - Applies tags consistently
   - Logs each ingest
3. Add example trigger phrases to the skill description so Claude routes correctly

The schema accepts any `doc_type` and `source_type` — you don't need to modify the schema for new sources.
~~~

### Step 11 — Initialize the database

Run:
```
python ~/.rag-db/rag_helpers.py init
```

Verify the output shows all tables created and `FTS5: OK`.

### Step 12 — Report

Tell the user:

> **Karpathy RAG bootstrapped.**
>
> - Database: `~/.rag-db/rag.db`
> - Code: `schema.sql`, `rag_helpers.py`, `capture.py`, `capture_mcp_server.py`
> - Skills: `/rag-init`, `/rag-status`, `/rag-query`, `/rag-capture`, `/rag-ingest`
> - Web UI (if installed): run `python ~/.rag-db/ui/server.py` -> http://localhost:6550
>
> Next steps:
> 1. Add your first content: "Add a note: title / content"
> 2. Query it back: "What do I know about [topic]?"
> 3. Optional: register the MCP server (see README) for cross-interface capture.
> 4. Optional: install the web UI (Part 2 below) and open http://localhost:6550

## Part 2 — The Web Interface (Optional)

A local web app over the same database: live full-text search (**Catalog**), the compiled
layer (**Wiki**), an interactive force-directed relationship map (**Graph**), and a live
dashboard (**Ledger**). Zero dependencies — Python standard library only, no pip installs,
no node build. The server opens rag.db **read-only** (`mode=ro`); it cannot write.

Skip this part if the user only wants the skills — it can be added any time later by
re-invoking this skill and asking for "the web UI part".

### Step 13 — Write ui/server.py

Write the following content **exactly** to `~/.rag-db/ui/server.py`:

~~~python
"""
Karpathy RAG UI — local web interface over rag.db
==================================================
Zero-dependency (Python stdlib only). Read-only against the database.

Run:  python server.py            (starts on http://localhost:6550 and opens browser)
      python server.py --no-browser
      python server.py --port 7000
"""
import json
import os
import re
import sqlite3
import sys
import threading
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DB_PATH = os.path.expanduser("~/.rag-db/rag.db")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WEB_DIR = os.path.join(BASE_DIR, "web")
TUTORIAL_MP4 = os.path.join(BASE_DIR, "karpathy-rag-tutorial.mp4")
PORT = 6550

WIKI_TYPES = ("entity_page", "concept_page", "area_overview", "source_summary")

# Snippet markers that survive HTML-escaping on the client
MARK_OPEN, MARK_CLOSE = "‹‹m››", "‹‹/m››"

CONTENT_TYPES = {
    ".html": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "application/javascript; charset=utf-8",
    ".svg": "image/svg+xml",
    ".png": "image/png",
    ".ico": "image/x-icon",
}


def connect():
    uri = "file:" + DB_PATH.replace(os.sep, "/") + "?mode=ro"
    conn = sqlite3.connect(uri, uri=True)
    conn.row_factory = sqlite3.Row
    return conn


def rows_to_dicts(rows):
    return [dict(r) for r in rows]


def sanitize_fts(q):
    """Quote every token so FTS5 syntax characters can't break the query;
    last token gets a prefix star for type-ahead feel."""
    tokens = re.findall(r"\w+", q, re.UNICODE)
    if not tokens:
        return None
    quoted = [f'"{t}"' for t in tokens[:-1]]
    quoted.append(f'"{tokens[-1]}"*')
    return " ".join(quoted)


# ─────────────────────────────────────────────
# API handlers — each returns a JSON-able dict
# ─────────────────────────────────────────────

def api_stats(params):
    conn = connect()
    cur = conn.cursor()
    # Aggregates run unfiltered so they stay on covering indexes (index-only
    # scans, ~20ms). A status='active' predicate forces a row lookup into the
    # fat content rows (~4s across these queries). Non-active docs are counted
    # once via idx_documents_status and subtracted where it matters.
    counts = {}
    for key, sql in [
        ("documents_all", "SELECT COUNT(*) FROM documents"),
        ("documents_inactive", "SELECT COUNT(*) FROM documents WHERE status <> 'active'"),
        ("sources", "SELECT COUNT(*) FROM sources WHERE status='active'"),
        ("tags", "SELECT COUNT(*) FROM tags"),
        ("relationships", "SELECT COUNT(*) FROM relationships"),
        ("synthesis", "SELECT COUNT(*) FROM synthesis WHERE status='current'"),
        ("operations", "SELECT COUNT(*) FROM operations_log"),
    ]:
        cur.execute(sql)
        counts[key] = cur.fetchone()[0]
    counts["documents"] = counts.pop("documents_all") - counts.pop("documents_inactive")

    cur.execute("""SELECT doc_type, COUNT(*) AS n FROM documents
                   GROUP BY doc_type ORDER BY n DESC""")
    by_type = rows_to_dicts(cur.fetchall())

    cur.execute("""SELECT s.id, s.name, s.source_type, s.description, s.last_synced_at,
                          COALESCE(dc.n, 0) AS doc_count
                   FROM sources s LEFT JOIN
                        (SELECT source_id, COUNT(*) AS n FROM documents
                         GROUP BY source_id) dc ON dc.source_id = s.id
                   WHERE s.status='active'
                   ORDER BY doc_count DESC""")
    by_source = rows_to_dicts(cur.fetchall())

    cur.execute("""SELECT timestamp, operation, target_table, target_id, detail, actor
                   FROM operations_log ORDER BY id DESC LIMIT 20""")
    recent_ops = rows_to_dicts(cur.fetchall())
    conn.close()

    return {
        "counts": counts,
        "by_type": by_type,
        "by_source": by_source,
        "recent_ops": recent_ops,
        "db_path": DB_PATH,
        "db_size_mb": round(os.path.getsize(DB_PATH) / (1024 * 1024), 1),
    }


def api_meta(params):
    conn = connect()
    cur = conn.cursor()
    cur.execute("""SELECT doc_type, COUNT(*) AS n FROM documents
                   GROUP BY doc_type ORDER BY n DESC""")
    types = rows_to_dicts(cur.fetchall())
    cur.execute("""SELECT t.name, t.category, COUNT(dt.document_id) AS n
                   FROM tags t LEFT JOIN document_tags dt ON dt.tag_id = t.id
                   GROUP BY t.id ORDER BY n DESC""")
    tags = rows_to_dicts(cur.fetchall())
    cur.execute("""SELECT id, name, source_type FROM sources
                   WHERE status='active' ORDER BY name""")
    sources = rows_to_dicts(cur.fetchall())
    conn.close()
    return {"types": types, "tags": tags, "sources": sources}


def api_search(params):
    q = (params.get("q") or "").strip()
    doc_type = params.get("type") or None
    tag = params.get("tag") or None
    source = params.get("source") or None
    sort = params.get("sort") or "curated"
    limit = min(int(params.get("limit") or 30), 100)
    offset = int(params.get("offset") or 0)

    conn = connect()
    cur = conn.cursor()

    filters, fparams = "", []
    if doc_type:
        filters += " AND d.doc_type = ?"
        fparams.append(doc_type)
    if source:
        filters += " AND d.source_id = ?"
        fparams.append(int(source))
    if tag:
        filters += (" AND d.id IN (SELECT dt.document_id FROM document_tags dt"
                    " JOIN tags t ON t.id = dt.tag_id WHERE t.name = ?)")
        fparams.append(tag)

    if not q:
        # Browse mode: filters only. Count stays index-only (no status
        # predicate — it would force row lookups); order by id DESC rides the
        # index and approximates recency in this append-only store.
        cur.execute(f"SELECT COUNT(*) FROM documents d WHERE 1=1 {filters}", fparams)
        total = cur.fetchone()[0]
        cur.execute(f"""SELECT d.id, d.title, d.doc_type, d.updated_at,
                               substr(d.content, 1, 300) AS snip,
                               s.name AS source_name
                        FROM documents d JOIN sources s ON s.id = d.source_id
                        WHERE d.status='active' {filters}
                        ORDER BY d.id DESC
                        LIMIT ? OFFSET ?""", fparams + [limit, offset])
        results = rows_to_dicts(cur.fetchall())
        conn.close()
        return {"total": total, "results": results, "query": q, "mode": "browse"}

    # CROSS JOIN pins the join order: FTS index first, then documents.
    # A plain JOIN lets the planner flip it (one FTS probe per document row
    # = 28s per query on this 219k-doc table).
    base = f"""FROM documents_fts f
               CROSS JOIN documents d ON d.id = f.rowid
               CROSS JOIN sources s ON s.id = d.source_id
               WHERE documents_fts MATCH ? AND d.status='active'{filters}"""

    order = {
        "curated": """ORDER BY CASE
                        WHEN d.doc_type IN ('entity_page','concept_page') THEN 0
                        WHEN d.doc_type IN ('area_overview','source_summary','insight') THEN 1
                        WHEN d.doc_type = 'workflowy_node' THEN 3
                        ELSE 2 END, rank""",
        "rank": "ORDER BY rank",
        "newest": "ORDER BY d.updated_at DESC",
    }.get(sort, "ORDER BY rank")

    select = f"""SELECT d.id, d.title, d.doc_type, d.updated_at,
                        snippet(documents_fts, 1, '{MARK_OPEN}', '{MARK_CLOSE}', ' … ', 40) AS snip,
                        s.name AS source_name
                 {base} {order} LIMIT ? OFFSET ?"""

    for candidate in (q, sanitize_fts(q)):
        if candidate is None:
            continue
        try:
            # Capped count: an exact COUNT(*) over a common term walks every
            # match; 1001 is enough to say "1,000+" in the UI.
            cur.execute(f"SELECT COUNT(*) FROM (SELECT 1 {base} LIMIT 1001)",
                        [candidate] + fparams)
            total = cur.fetchone()[0]
            cur.execute(select, [candidate] + fparams + [limit, offset])
            results = rows_to_dicts(cur.fetchall())
            conn.close()
            return {"total": total, "capped": total > 1000,
                    "results": results, "query": q, "mode": "fts"}
        except sqlite3.OperationalError:
            continue
    conn.close()
    return {"total": 0, "results": [], "query": q, "mode": "fts"}


def api_doc(doc_id):
    conn = connect()
    cur = conn.cursor()
    cur.execute("""SELECT d.*, s.name AS source_name, s.source_type
                   FROM documents d JOIN sources s ON s.id = d.source_id
                   WHERE d.id = ?""", (doc_id,))
    row = cur.fetchone()
    if not row:
        conn.close()
        return None
    doc = dict(row)

    cur.execute("""SELECT t.name, t.category FROM tags t
                   JOIN document_tags dt ON dt.tag_id = t.id
                   WHERE dt.document_id = ?""", (doc_id,))
    doc["tags"] = rows_to_dicts(cur.fetchall())

    cur.execute("""SELECT r.relationship_type, r.description, r.confidence,
                          d.id, d.title, d.doc_type
                   FROM relationships r JOIN documents d ON d.id = r.target_doc_id
                   WHERE r.source_doc_id = ? AND d.status='active'
                   ORDER BY r.confidence DESC LIMIT 200""", (doc_id,))
    doc["links_to"] = rows_to_dicts(cur.fetchall())

    cur.execute("""SELECT r.relationship_type, r.description, r.confidence,
                          d.id, d.title, d.doc_type
                   FROM relationships r JOIN documents d ON d.id = r.source_doc_id
                   WHERE r.target_doc_id = ? AND d.status='active'
                   ORDER BY r.confidence DESC LIMIT 200""", (doc_id,))
    doc["linked_from"] = rows_to_dicts(cur.fetchall())

    try:
        cur.execute("""SELECT d.id, d.title, d.doc_type
                       FROM compilation_sources cs JOIN documents d ON d.id = cs.source_doc_id
                       WHERE cs.compiled_doc_id = ? LIMIT 100""", (doc_id,))
        doc["compiled_from"] = rows_to_dicts(cur.fetchall())

        cur.execute("""SELECT d.id, d.title, d.doc_type
                       FROM compilation_sources cs JOIN documents d ON d.id = cs.compiled_doc_id
                       WHERE cs.source_doc_id = ? LIMIT 100""", (doc_id,))
        doc["contributes_to"] = rows_to_dicts(cur.fetchall())
    except sqlite3.OperationalError:
        # databases created before the compilation_sources table existed
        doc["compiled_from"], doc["contributes_to"] = [], []
    conn.close()
    return doc


def api_wiki(params):
    conn = connect()
    cur = conn.cursor()
    marks = ",".join("?" * len(WIKI_TYPES))
    cur.execute(f"""SELECT id, title, doc_type, metadata FROM documents
                    WHERE doc_type IN ({marks}) AND status='active'
                    ORDER BY title""", WIKI_TYPES)
    pages = []
    for r in cur.fetchall():
        meta = {}
        try:
            meta = json.loads(r["metadata"] or "{}")
        except (json.JSONDecodeError, TypeError):
            pass
        subtype = (meta.get("entity_type") or meta.get("concept_type")
                   or meta.get("area_key") or r["doc_type"])
        pages.append({
            "id": r["id"], "title": r["title"], "doc_type": r["doc_type"],
            "subtype": subtype,
            "canonical_name": meta.get("canonical_name") or meta.get("area_label"),
            "aliases": meta.get("aliases") or [],
            "node_count": meta.get("node_count"),
        })
    conn.close()
    return {"pages": pages}


def api_graph(params):
    center = params.get("center")
    conn = connect()
    cur = conn.cursor()

    if center:
        depth = min(int(params.get("depth") or 2), 3)
        node_ids = {int(center)}
        frontier = {int(center)}
        edges = []
        seen_edges = set()
        for _ in range(depth):
            if not frontier or len(node_ids) > 250:
                break
            marks = ",".join("?" * len(frontier))
            cur.execute(f"""SELECT id, source_doc_id, target_doc_id, relationship_type
                            FROM relationships
                            WHERE source_doc_id IN ({marks}) OR target_doc_id IN ({marks})""",
                        list(frontier) * 2)
            next_frontier = set()
            for e in cur.fetchall():
                if e["id"] in seen_edges:
                    continue
                seen_edges.add(e["id"])
                edges.append({"source": e["source_doc_id"], "target": e["target_doc_id"],
                              "type": e["relationship_type"]})
                for nid in (e["source_doc_id"], e["target_doc_id"]):
                    if nid not in node_ids:
                        node_ids.add(nid)
                        next_frontier.add(nid)
            frontier = next_frontier
    else:
        cur.execute("""SELECT source_doc_id, target_doc_id, relationship_type
                       FROM relationships""")
        edges = [{"source": e["source_doc_id"], "target": e["target_doc_id"],
                  "type": e["relationship_type"]} for e in cur.fetchall()]
        node_ids = {e["source"] for e in edges} | {e["target"] for e in edges}

    nodes = []
    id_list = list(node_ids)
    for i in range(0, len(id_list), 500):
        chunk = id_list[i:i + 500]
        marks = ",".join("?" * len(chunk))
        cur.execute(f"""SELECT id, title, doc_type FROM documents
                        WHERE id IN ({marks}) AND status='active'""", chunk)
        nodes.extend(rows_to_dicts(cur.fetchall()))
    conn.close()

    live = {n["id"] for n in nodes}
    edges = [e for e in edges if e["source"] in live and e["target"] in live]
    return {"nodes": nodes, "edges": edges,
            "center": int(center) if center else None}


ROUTES = {
    "/api/stats": api_stats,
    "/api/meta": api_meta,
    "/api/search": api_search,
    "/api/wiki": api_wiki,
    "/api/graph": api_graph,
}


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass  # quiet console

    def send_json(self, obj, status=200):
        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def send_video(self, full):
        """Serve the tutorial MP4 with Range support so seeking works."""
        size = os.path.getsize(full)
        rng = self.headers.get("Range")
        start, end = 0, size - 1
        if rng:
            m = re.match(r"bytes=(\d*)-(\d*)", rng)
            if m:
                if m.group(1):
                    start = int(m.group(1))
                if m.group(2):
                    end = min(int(m.group(2)), size - 1)
        length = end - start + 1
        self.send_response(206 if rng else 200)
        self.send_header("Content-Type", "video/mp4")
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("Content-Length", str(length))
        if rng:
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
        self.end_headers()
        with open(full, "rb") as f:
            f.seek(start)
            remaining = length
            while remaining > 0:
                chunk = f.read(min(65536, remaining))
                if not chunk:
                    break
                self.wfile.write(chunk)
                remaining -= len(chunk)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path
        params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}

        try:
            if path == "/tutorial.mp4":
                if not os.path.isfile(TUTORIAL_MP4):
                    return self.send_json({"error": "tutorial video not found"}, 404)
                return self.send_video(TUTORIAL_MP4)

            if path in ROUTES:
                return self.send_json(ROUTES[path](params))

            m = re.fullmatch(r"/api/doc/(\d+)", path)
            if m:
                doc = api_doc(int(m.group(1)))
                if doc is None:
                    return self.send_json({"error": "not found"}, 404)
                return self.send_json(doc)

            if path.startswith("/api/"):
                return self.send_json({"error": "unknown endpoint"}, 404)

            # Static files
            rel = "index.html" if path == "/" else path.lstrip("/")
            full = os.path.normpath(os.path.join(WEB_DIR, rel))
            if not full.startswith(WEB_DIR) or not os.path.isfile(full):
                full = os.path.join(WEB_DIR, "index.html")  # SPA fallback
            ext = os.path.splitext(full)[1].lower()
            with open(full, "rb") as f:
                body = f.read()
            self.send_response(200)
            self.send_header("Content-Type", CONTENT_TYPES.get(ext, "application/octet-stream"))
            self.send_header("Content-Length", str(len(body)))
            # Local dev-style app: revalidate every load so UI edits show up
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self.wfile.write(body)
        except (ConnectionAbortedError, BrokenPipeError):
            pass
        except Exception as e:
            try:
                self.send_json({"error": str(e)}, 500)
            except Exception:
                pass


def main():
    port = PORT
    if "--port" in sys.argv:
        port = int(sys.argv[sys.argv.index("--port") + 1])

    if not os.path.exists(DB_PATH):
        print(f"ERROR: rag.db not found at {DB_PATH}")
        sys.exit(1)

    try:
        server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    except OSError:
        # Port already bound — another instance is serving. Nothing to do.
        print(f"Karpathy RAG UI is already running on port {port}.")
        sys.exit(0)
    url = f"http://localhost:{port}"
    print(f"Karpathy RAG UI — serving {DB_PATH} (read-only)")
    print(f"  {url}   (Ctrl+C to stop)")
    if "--no-browser" not in sys.argv:
        threading.Timer(0.6, lambda: webbrowser.open(url)).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")


if __name__ == "__main__":
    main()
~~~

### Step 14 — Write ui/web/index.html

Write the following content **exactly** to `~/.rag-db/ui/web/index.html`:

~~~html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Karpathy RAG — Personal Archive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='16' fill='%2314100c'/><text x='50' y='68' font-size='52' text-anchor='middle' fill='%23d9a05b' font-family='Georgia' font-style='italic'>K</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,560;0,600;1,400;1,560&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>

<header>
  <div class="header-inner">
    <div class="wordmark" id="wordmark">Karpathy <span class="amp">RAG</span></div>
    <div class="ledger-line" id="ledger-line">reading the archive…</div>
  </div>
  <nav id="nav">
    <button data-view="search" class="active">Catalog</button>
    <button data-view="wiki">Wiki</button>
    <button data-view="graph">Graph</button>
    <button data-view="ledger">Ledger</button>
    <a class="nav-tutorial" href="https://www.cafe655.com/karpathy-rag-tutorial.mp4" target="_blank" rel="noopener"
       title="Watch the 4-minute tour">Tutorial ▸</a>
  </nav>
</header>

<main id="main">
  <div class="loading">OPENING THE ARCHIVE…</div>
</main>

<script src="/app.js"></script>
</body>
</html>
~~~

### Step 15 — Write ui/web/style.css

Write the following content **exactly** to `~/.rag-db/ui/web/style.css`:

~~~css
/* ============================================================
   KARPATHY RAG — dark roast archive
   Espresso surfaces · steamed-milk ink · amber lamp accent
   Signature: every document is a spine-coded catalog card
   ============================================================ */

:root {
  --bg:        #14100c;
  --surface:   #1e1813;
  --raised:    #28211a;
  --sunken:    #0f0c09;
  --ink:       #efe6d6;
  --ink-2:     #b5a68f;
  --ink-3:     #857761;
  --line:      #3a3126;
  --line-soft: #2c251c;
  --accent:    #d9a05b;
  --accent-dim:#8a6534;

  /* doc-type spines — validated categorical set (dark) */
  --t-workflowy: #3987e5;
  --t-concept:   #199e70;
  --t-entity:    #c98500;
  --t-build:     #008300;
  --t-session:   #9085e9;
  --t-pain:      #e66767;
  --t-insight:   #d55181;
  --t-area:      #d95926;
  --t-neutral:   #9c8e7a;

  --font-display: "Fraunces", Georgia, serif;
  --font-body: "Source Sans 3", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: "IBM Plex Mono", Consolas, monospace;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html { height: 100%; }
body {
  min-height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 15.5px;
  line-height: 1.55;
}

::selection { background: var(--accent-dim); color: var(--ink); }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

button { font-family: inherit; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

/* ── Header ledger bar ─────────────────────────── */
header {
  border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg, #191410, var(--bg));
  position: sticky; top: 0; z-index: 50;
}
.header-inner {
  max-width: 1200px; margin: 0 auto; padding: 14px 24px 0;
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 28px;
}
.wordmark {
  font-family: var(--font-display);
  font-style: italic; font-weight: 560;
  font-size: 26px; letter-spacing: 0.01em;
  color: var(--ink); cursor: pointer; white-space: nowrap;
}
.wordmark .amp { color: var(--accent); }
.ledger-line {
  font-family: var(--font-mono); font-size: 11.5px;
  color: var(--ink-3); letter-spacing: 0.04em;
  flex: 1; min-width: 240px;
}
.ledger-line b { color: var(--ink-2); font-weight: 500; }

nav { display: flex; gap: 2px; margin-top: 8px; padding: 0 12px; max-width: 1200px; margin-inline: auto; }
nav button {
  background: none; border: none; cursor: pointer;
  font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--ink-3);
  padding: 9px 16px 11px;
  border-bottom: 2px solid transparent;
}
nav button:hover { color: var(--ink-2); }
nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
nav .nav-tutorial {
  margin-left: auto;
  font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--accent-dim);
  padding: 9px 16px 11px; border-bottom: 2px solid transparent;
}
nav .nav-tutorial:hover { color: var(--accent); text-decoration: none; }

main { max-width: 1200px; margin: 0 auto; padding: 28px 24px 80px; }

/* ── Catalog (search) ──────────────────────────── */
.search-box { position: relative; margin-bottom: 14px; }
.search-box input {
  width: 100%;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 19px;
  padding: 15px 52px 15px 18px;
}
.search-box input::placeholder { color: var(--ink-3); }
.search-box input:focus { outline: none; border-color: var(--accent-dim); }
.search-box .search-glyph {
  position: absolute; right: 18px; top: 50%; transform: translateY(-50%);
  font-family: var(--font-mono); color: var(--ink-3); font-size: 13px;
  pointer-events: none;
}

.filter-row {
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  margin-bottom: 22px;
}
.chip {
  border: 1px solid var(--line); background: none; color: var(--ink-2);
  border-radius: 99px; padding: 4px 13px; font-size: 13px; cursor: pointer;
  display: inline-flex; align-items: center; gap: 7px;
}
.chip:hover { border-color: var(--accent-dim); color: var(--ink); }
.chip.active { border-color: var(--accent); color: var(--ink); background: var(--raised); }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.chip .n { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); }
.filter-row select {
  background: var(--surface); color: var(--ink-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 5px 8px; font-size: 13px; font-family: var(--font-body);
  max-width: 190px;
}
.filter-row .spacer { flex: 1; }

.result-meta {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3);
  letter-spacing: 0.04em; margin-bottom: 14px;
}

/* the catalog card — the signature element */
.card {
  display: block; width: 100%; text-align: left; cursor: pointer;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-left-width: 4px;
  border-radius: 4px;
  padding: 12px 16px 12px 15px;
  margin-bottom: 9px;
  color: var(--ink);
  transition: background 0.12s, border-color 0.12s;
}
.card:hover { background: var(--raised); border-color: var(--line); }
.card .card-top {
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
}
.card .title { font-size: 16px; font-weight: 600; }
.card .ledger {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--ink-3);
}
.card .snip { color: var(--ink-2); font-size: 13.5px; margin-top: 4px; }
.card .snip mark {
  background: none; color: var(--accent); font-weight: 600;
}

.load-more {
  display: block; margin: 18px auto 0; background: none;
  border: 1px solid var(--line); color: var(--ink-2); border-radius: 6px;
  padding: 9px 26px; cursor: pointer; font-size: 13.5px;
}
.load-more:hover { border-color: var(--accent-dim); color: var(--ink); }

.empty-note {
  color: var(--ink-3); font-size: 14.5px; padding: 30px 4px;
  font-style: italic; font-family: var(--font-display);
}

/* ── Document view ─────────────────────────────── */
.doc-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 34px; }
@media (max-width: 900px) { .doc-layout { grid-template-columns: 1fr; } }

.back-link {
  background: none; border: none; color: var(--ink-3); cursor: pointer;
  font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em;
  padding: 0 0 14px; text-transform: uppercase;
}
.back-link:hover { color: var(--accent); }

.doc-head { border-left: 4px solid var(--t-neutral); padding-left: 16px; margin-bottom: 22px; }
.doc-head h1 {
  font-family: var(--font-display); font-weight: 560; font-size: 30px;
  line-height: 1.2; margin-bottom: 6px;
}
.doc-head .ledger {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--ink-3);
}

.doc-content { max-width: 72ch; }
.doc-content h2, .doc-content h3, .doc-content h4 {
  font-family: var(--font-display); font-weight: 560;
  margin: 26px 0 8px; line-height: 1.25;
}
.doc-content h2 { font-size: 22px; }
.doc-content h3 { font-size: 18px; }
.doc-content h4 { font-size: 16px; color: var(--ink-2); }
.doc-content p { margin: 10px 0; }
.doc-content ul, .doc-content ol { margin: 10px 0 10px 22px; }
.doc-content li { margin: 4px 0; }
.doc-content code {
  font-family: var(--font-mono); font-size: 0.88em;
  background: var(--sunken); border: 1px solid var(--line-soft);
  border-radius: 3px; padding: 1px 5px;
}
.doc-content pre {
  background: var(--sunken); border: 1px solid var(--line-soft);
  border-radius: 6px; padding: 12px 14px; overflow-x: auto; margin: 12px 0;
}
.doc-content pre code { background: none; border: none; padding: 0; }
.doc-content blockquote {
  border-left: 3px solid var(--accent-dim); padding-left: 14px;
  color: var(--ink-2); margin: 12px 0; font-style: italic;
}
.doc-content hr { border: none; border-top: 1px solid var(--line); margin: 20px 0; }

.side-rail { font-size: 13.5px; }
.rail-section { margin-bottom: 24px; }
.rail-section h3 {
  font-family: var(--font-mono); font-size: 10.5px; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3);
  border-bottom: 1px solid var(--line-soft); padding-bottom: 6px; margin-bottom: 9px;
}
.rail-item {
  display: block; width: 100%; text-align: left; background: none; border: none;
  cursor: pointer; color: var(--ink-2); padding: 4px 0 4px 12px;
  font-size: 13px; line-height: 1.35;
  border-left: 3px solid var(--t-neutral); margin-bottom: 5px;
}
.rail-item:hover { color: var(--ink); }
.rail-item .rel {
  font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3);
  display: block; letter-spacing: 0.06em; text-transform: uppercase;
}
.tag-chip {
  display: inline-block; font-size: 11.5px; color: var(--ink-2);
  border: 1px solid var(--line); border-radius: 99px;
  padding: 2px 10px; margin: 0 5px 6px 0;
}
.rail-actions button {
  width: 100%; background: var(--surface); border: 1px solid var(--line);
  color: var(--ink-2); border-radius: 6px; padding: 8px; cursor: pointer;
  font-size: 13px; margin-bottom: 8px;
}
.rail-actions button:hover { border-color: var(--accent-dim); color: var(--ink); }

/* ── Wiki ──────────────────────────────────────── */
.wiki-intro { color: var(--ink-2); margin-bottom: 26px; max-width: 68ch; }
.wiki-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 26px 30px; }
.wiki-group h2 {
  font-family: var(--font-display); font-weight: 560; font-size: 19px;
  margin-bottom: 3px;
}
.wiki-group .ledger {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--ink-3); display: block; margin-bottom: 10px;
}
.wiki-group ul { list-style: none; }
.wiki-group li button {
  background: none; border: none; cursor: pointer; text-align: left;
  color: var(--ink-2); font-size: 14px; padding: 3px 0 3px 11px; width: 100%;
  border-left: 3px solid transparent; font-family: var(--font-body);
}
.wiki-group li button:hover { color: var(--accent); border-left-color: var(--accent-dim); }

/* ── Graph ─────────────────────────────────────── */
.graph-toolbar {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
}
.graph-toolbar input {
  background: var(--surface); border: 1px solid var(--line); color: var(--ink);
  border-radius: 6px; padding: 7px 12px; font-size: 14px; width: 300px;
  font-family: var(--font-body);
}
.graph-toolbar .hint { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); }
.graph-wrap {
  position: relative; border: 1px solid var(--line); border-radius: 8px;
  background: radial-gradient(ellipse at 50% 40%, #1a1510 0%, var(--sunken) 100%);
  overflow: hidden;
}
#graph-canvas { display: block; width: 100%; height: 68vh; cursor: grab; }
#graph-canvas.dragging { cursor: grabbing; }
.graph-info {
  position: absolute; left: 14px; bottom: 14px; max-width: 330px;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border: 1px solid var(--line); border-left-width: 4px; border-radius: 5px;
  padding: 10px 14px; font-size: 13px; display: none;
}
.graph-info.visible { display: block; }
.graph-info .title { font-weight: 600; font-size: 14.5px; }
.graph-info .ledger {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--ink-3);
}
.graph-info button {
  margin-top: 8px; background: none; border: 1px solid var(--line);
  color: var(--ink-2); border-radius: 5px; padding: 4px 12px; cursor: pointer; font-size: 12px;
}
.graph-info button:hover { border-color: var(--accent-dim); color: var(--ink); }
.graph-legend {
  position: absolute; right: 14px; top: 12px;
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.05em;
  color: var(--ink-3); text-align: right; line-height: 1.9; pointer-events: none;
}
.graph-legend .dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  margin-left: 7px; vertical-align: -0.5px;
}

/* ── Ledger (dashboard) ────────────────────────── */
.tile-row {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px; margin-bottom: 34px;
}
.tile {
  background: var(--surface); border: 1px solid var(--line-soft);
  border-radius: 6px; padding: 16px 18px 13px;
}
.tile .num {
  font-family: var(--font-display); font-weight: 560; font-size: 30px;
  line-height: 1.1; font-variant-numeric: tabular-nums;
}
.tile .lbl {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--ink-3); margin-top: 4px;
}

.dash-section { margin-bottom: 38px; }
.dash-section > h2 {
  font-family: var(--font-display); font-weight: 560; font-size: 20px;
  margin-bottom: 4px;
}
.dash-section > .sub {
  font-family: var(--font-mono); font-size: 11px; color: var(--ink-3);
  letter-spacing: 0.06em; margin-bottom: 16px;
}

.bar-row {
  display: grid; grid-template-columns: 170px 1fr 76px;
  gap: 12px; align-items: center; margin-bottom: 7px;
  background: none; border: none; width: 100%; cursor: pointer;
  color: var(--ink); padding: 0; font-family: var(--font-body);
}
.bar-row:hover .bar-label { color: var(--accent); }
.bar-label { text-align: right; font-size: 13px; color: var(--ink-2); }
.bar-track { position: relative; height: 14px; }
.bar-fill {
  position: absolute; inset: 0 auto 0 0; min-width: 3px;
  border-radius: 0 4px 4px 0;
}
.bar-val {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-2);
  text-align: left; font-variant-numeric: tabular-nums;
}

table.ledger-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
table.ledger-table th {
  font-family: var(--font-mono); font-size: 10px; font-weight: 500;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-3);
  text-align: left; padding: 6px 12px 6px 0;
  border-bottom: 1px solid var(--line);
}
table.ledger-table td {
  padding: 7px 12px 7px 0; border-bottom: 1px solid var(--line-soft);
  color: var(--ink-2); vertical-align: top;
}
table.ledger-table td:first-child { color: var(--ink); }
table.ledger-table .mono { font-family: var(--font-mono); font-size: 11.5px; }

.loading {
  font-family: var(--font-mono); font-size: 12px; color: var(--ink-3);
  padding: 40px 0; text-align: center; letter-spacing: 0.1em;
}

/* entrance for result cards */
@media (prefers-reduced-motion: no-preference) {
  .card { animation: cardIn 0.22s ease both; }
  @keyframes cardIn {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: none; }
  }
}
~~~

### Step 16 — Write ui/web/app.js

Write the following content **exactly** to `~/.rag-db/ui/web/app.js`:

~~~javascript
/* Karpathy RAG UI — vanilla JS single-page app */
"use strict";

const $ = (sel, el = document) => el.querySelector(sel);
const main = $("#main");

/* ── doc-type spine system (fixed categorical assignment) ── */
const TYPE_COLOR = {
  workflowy_node: "var(--t-workflowy)",
  concept_page:   "var(--t-concept)",
  entity_page:    "var(--t-entity)",
  build_record:   "var(--t-build)",
  session_entry:  "var(--t-session)",
  pain_point:     "var(--t-pain)",
  insight:        "var(--t-insight)",
  area_overview:  "var(--t-area)",
};
const TYPE_COLOR_RAW = {
  workflowy_node: "#3987e5", concept_page: "#199e70", entity_page: "#c98500",
  build_record: "#008300", session_entry: "#9085e9", pain_point: "#e66767",
  insight: "#d55181", area_overview: "#d95926",
};
const NEUTRAL = "#9c8e7a";
const typeColor = t => TYPE_COLOR[t] || "var(--t-neutral)";
const typeColorRaw = t => TYPE_COLOR_RAW[t] || NEUTRAL;
const typeLabel = t => (t || "").replace(/_/g, " ");

const esc = s => String(s ?? "")
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
  .replace(/"/g, "&quot;");

const fmtNum = n => Number(n ?? 0).toLocaleString("en-US");

async function api(path) {
  const r = await fetch(path);
  if (!r.ok) throw new Error(`${path} → ${r.status}`);
  return r.json();
}

/* snippet markers from the server survive escaping, then become <mark> */
function snippetHtml(s) {
  return esc(s)
    .replaceAll("‹‹m››", "<mark>").replaceAll("‹‹/m››", "</mark>")
    .replace(/\s+/g, " ");
}

/* ── markdown-lite renderer for document content ── */
function renderMarkdown(text) {
  const lines = String(text ?? "").split("\n");
  const out = [];
  let list = null, para = [], pre = null;

  const inline = s => {
    let h = esc(s);
    h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
    h = h.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
    h = h.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, "$1<em>$2</em>");
    h = h.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
      '<a href="$2" target="_blank" rel="noopener">$1</a>');
    h = h.replace(/(^|[\s>])(https?:\/\/[^\s<]+[^\s<.,)])/g,
      '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
    return h;
  };
  const flushPara = () => {
    if (para.length) { out.push(`<p>${inline(para.join(" "))}</p>`); para = []; }
  };
  const flushList = () => {
    if (list) { out.push(`</${list}>`); list = null; }
  };

  for (const raw of lines) {
    const line = raw.replace(/\t/g, "    ");
    if (pre !== null) {
      if (/^```/.test(line.trim())) { out.push(`<pre><code>${esc(pre.join("\n"))}</code></pre>`); pre = null; }
      else pre.push(raw);
      continue;
    }
    if (/^```/.test(line.trim())) { flushPara(); flushList(); pre = []; continue; }

    const h = line.match(/^(#{1,4})\s+(.*)/);
    if (h) { flushPara(); flushList(); out.push(`<h${h[1].length + 1}>${inline(h[2])}</h${h[1].length + 1}>`); continue; }
    if (/^(---+|===+)\s*$/.test(line.trim())) { flushPara(); flushList(); out.push("<hr>"); continue; }

    const bq = line.match(/^>\s?(.*)/);
    if (bq) { flushPara(); flushList(); out.push(`<blockquote>${inline(bq[1])}</blockquote>`); continue; }

    const ul = line.match(/^\s*[-*•]\s+(.*)/);
    const ol = line.match(/^\s*\d+[.)]\s+(.*)/);
    if (ul || ol) {
      flushPara();
      const want = ul ? "ul" : "ol";
      if (list !== want) { flushList(); out.push(`<${want}>`); list = want; }
      out.push(`<li>${inline((ul || ol)[1])}</li>`);
      continue;
    }
    if (line.trim() === "") { flushPara(); flushList(); continue; }
    para.push(line.trim());
  }
  if (pre !== null) out.push(`<pre><code>${esc(pre.join("\n"))}</code></pre>`);
  flushPara(); flushList();
  return out.join("\n");
}

/* ── global state ── */
const state = {
  meta: null,
  stats: null,
  search: { q: "", type: "", tag: "", source: "", sort: "curated", offset: 0, results: [], total: 0 },
  graph: null, // sim state
};

/* ── header ledger ── */
async function loadHeader() {
  try {
    state.stats = await api("/api/stats");
    const c = state.stats.counts;
    $("#ledger-line").innerHTML =
      `<b>${fmtNum(c.documents)}</b> documents · <b>${fmtNum(c.sources)}</b> sources · ` +
      `<b>${fmtNum(c.tags)}</b> tags · <b>${fmtNum(c.relationships)}</b> links · ` +
      `${state.stats.db_size_mb} MB`;
  } catch {
    $("#ledger-line").textContent = "archive unreachable — is rag.db present?";
  }
}

/* ── routing ── */
function nav(hash) { location.hash = hash; }

function parseHash() {
  const h = location.hash.replace(/^#\/?/, "");
  const [pathPart, queryPart] = h.split("?");
  const params = new URLSearchParams(queryPart || "");
  const seg = pathPart.split("/").filter(Boolean);
  return { seg, params };
}

window.addEventListener("hashchange", route);

function setActiveTab(view) {
  document.querySelectorAll("nav button").forEach(b =>
    b.classList.toggle("active", b.dataset.view === view));
}

async function route() {
  const { seg, params } = parseHash();
  const view = seg[0] || "search";
  if (view === "doc" && seg[1]) { setActiveTab("search"); return showDoc(+seg[1]); }
  if (view === "wiki") { setActiveTab("wiki"); return showWiki(); }
  if (view === "graph") { setActiveTab("graph"); return showGraph(params.get("center")); }
  if (view === "ledger") { setActiveTab("ledger"); return showLedger(); }
  setActiveTab("search");
  return showSearch(params);
}

/* ══════════════════════════════════════════════
   CATALOG (search)
   ══════════════════════════════════════════════ */
async function showSearch(params) {
  if (!state.meta) state.meta = await api("/api/meta");
  const s = state.search;
  if (params) {
    if (params.has("q")) s.q = params.get("q");
    if (params.has("type")) s.type = params.get("type");
  }

  const topTypes = state.meta.types.slice(0, 9);
  const tagOpts = state.meta.tags.filter(t => t.n > 0).map(t =>
    `<option value="${esc(t.name)}" ${s.tag === t.name ? "selected" : ""}>${esc(t.name)} (${t.n})</option>`).join("");
  const srcOpts = state.meta.sources.map(x =>
    `<option value="${x.id}" ${s.source == x.id ? "selected" : ""}>${esc(x.name)}</option>`).join("");

  main.innerHTML = `
    <div class="search-box">
      <input id="q" type="search" placeholder="Search the archive…" value="${esc(s.q)}"
             autocomplete="off" spellcheck="false" aria-label="Search the archive">
      <span class="search-glyph">FTS5</span>
    </div>
    <div class="filter-row" id="type-chips">
      <button class="chip ${s.type === "" ? "active" : ""}" data-type="">all types</button>
      ${topTypes.map(t => `
        <button class="chip ${s.type === t.doc_type ? "active" : ""}" data-type="${t.doc_type}">
          <span class="dot" style="background:${typeColor(t.doc_type)}"></span>
          ${typeLabel(t.doc_type)} <span class="n">${fmtNum(t.n)}</span>
        </button>`).join("")}
      <span class="spacer"></span>
      <select id="f-source" aria-label="Filter by source">
        <option value="">any source</option>${srcOpts}
      </select>
      <select id="f-tag" aria-label="Filter by tag">
        <option value="">any tag</option>${tagOpts}
      </select>
      <select id="f-sort" aria-label="Sort order">
        <option value="curated" ${s.sort === "curated" ? "selected" : ""}>curated first</option>
        <option value="rank" ${s.sort === "rank" ? "selected" : ""}>best match</option>
        <option value="newest" ${s.sort === "newest" ? "selected" : ""}>newest</option>
      </select>
    </div>
    <div class="result-meta" id="result-meta"></div>
    <div id="results"></div>
  `;

  const input = $("#q");
  let timer = null;
  input.addEventListener("input", () => {
    clearTimeout(timer);
    timer = setTimeout(() => { s.q = input.value; runSearch(true); }, 260);
  });
  input.addEventListener("keydown", e => {
    if (e.key === "Enter") { clearTimeout(timer); s.q = input.value; runSearch(true); }
  });
  $("#type-chips").addEventListener("click", e => {
    const chip = e.target.closest(".chip");
    if (!chip) return;
    s.type = chip.dataset.type;
    document.querySelectorAll("#type-chips .chip").forEach(c =>
      c.classList.toggle("active", c.dataset.type === s.type));
    runSearch(true);
  });
  $("#f-source").addEventListener("change", e => { s.source = e.target.value; runSearch(true); });
  $("#f-tag").addEventListener("change", e => { s.tag = e.target.value; runSearch(true); });
  $("#f-sort").addEventListener("change", e => { s.sort = e.target.value; runSearch(true); });

  if (s.q || s.type || s.tag || s.source) runSearch(true);
  else {
    $("#result-meta").textContent = "";
    $("#results").innerHTML = `<div class="empty-note">
      Type to search ${fmtNum(state.stats?.counts.documents || 219900)} documents —
      or pick a type chip to browse the drawers.</div>`;
  }
  input.focus();
}

function cardHtml(r) {
  const snip = r.snip ? snippetHtml(r.snip) : "";
  return `
    <button class="card" style="border-left-color:${typeColor(r.doc_type)}" data-id="${r.id}">
      <span class="card-top">
        <span class="title">${esc(r.title)}</span>
        <span class="ledger">№ ${r.id} · ${typeLabel(r.doc_type)} · ${esc(r.source_name || "")}</span>
      </span>
      ${snip ? `<span class="snip">${snip}</span>` : ""}
    </button>`;
}

async function runSearch(reset) {
  const s = state.search;
  if (reset) { s.offset = 0; s.results = []; }
  const meta = $("#result-meta"), box = $("#results");
  if (!meta || !box) return;
  meta.textContent = "SEARCHING…";

  const qs = new URLSearchParams();
  if (s.q) qs.set("q", s.q);
  if (s.type) qs.set("type", s.type);
  if (s.tag) qs.set("tag", s.tag);
  if (s.source) qs.set("source", s.source);
  qs.set("sort", s.sort);
  qs.set("limit", "30");
  qs.set("offset", String(s.offset));

  let data;
  try { data = await api("/api/search?" + qs); }
  catch (e) { meta.textContent = "search failed — " + e.message; return; }

  // stale response guard: only render if the query still matches
  if ((s.q || "") !== ($("#q")?.value ?? s.q)) return;

  s.total = data.total;
  const totalStr = data.capped ? "1,000+" : fmtNum(data.total);
  s.results = s.results.concat(data.results);
  meta.textContent = s.q || s.type || s.tag || s.source
    ? `${totalStr} MATCH${data.total === 1 ? "" : "ES"}${s.q ? ` FOR “${s.q.toUpperCase()}”` : ""}`
    : "";

  box.innerHTML = s.results.map(cardHtml).join("") +
    (s.results.length < s.total
      ? `<button class="load-more" id="load-more">Show more${data.capped ? "" : ` (${fmtNum(s.total - s.results.length)} remaining)`}</button>`
      : "") +
    (s.total === 0 ? `<div class="empty-note">Nothing in the drawers for that. Try fewer words — the index stems, so “paint” finds “painting”.</div>` : "");

  box.querySelectorAll(".card").forEach(c =>
    c.addEventListener("click", () => nav(`#/doc/${c.dataset.id}`)));
  $("#load-more")?.addEventListener("click", () => {
    state.search.offset += 30;
    runSearch(false);
  });
}

/* ══════════════════════════════════════════════
   DOCUMENT VIEW
   ══════════════════════════════════════════════ */
function railItems(list, heading) {
  if (!list?.length) return "";
  return `<div class="rail-section"><h3>${heading} · ${list.length}</h3>
    ${list.map(x => `
      <button class="rail-item" style="border-left-color:${typeColor(x.doc_type)}" data-id="${x.id}">
        ${x.relationship_type ? `<span class="rel">${esc(x.relationship_type)}</span>` : ""}
        ${esc(x.title)}
      </button>`).join("")}
  </div>`;
}

async function showDoc(id) {
  main.innerHTML = `<div class="loading">PULLING CARD № ${id}…</div>`;
  let doc;
  try { doc = await api("/api/doc/" + id); }
  catch { main.innerHTML = `<div class="empty-note">Card № ${id} isn't in the catalog.</div>`; return; }

  const dates = (doc.created_at || "").slice(0, 10) +
    (doc.updated_at && doc.updated_at !== doc.created_at ? ` · updated ${doc.updated_at.slice(0, 10)}` : "");

  main.innerHTML = `
    <button class="back-link" id="back">← back</button>
    <div class="doc-layout">
      <article>
        <div class="doc-head" style="border-left-color:${typeColor(doc.doc_type)}">
          <h1>${esc(doc.title)}</h1>
          <div class="ledger">№ ${doc.id} · ${typeLabel(doc.doc_type)} · ${esc(doc.source_name)} · ${dates}</div>
        </div>
        <div class="doc-content">${renderMarkdown(doc.content)}</div>
      </article>
      <aside class="side-rail">
        <div class="rail-actions">
          <button id="open-graph">View in graph</button>
        </div>
        ${doc.tags?.length ? `<div class="rail-section"><h3>Tags</h3>
          ${doc.tags.map(t => `<span class="tag-chip">${esc(t.name)}</span>`).join("")}</div>` : ""}
        ${railItems(doc.links_to, "Links to")}
        ${railItems(doc.linked_from, "Linked from")}
        ${railItems(doc.compiled_from, "Compiled from")}
        ${railItems(doc.contributes_to, "Feeds into")}
      </aside>
    </div>`;

  $("#back").addEventListener("click", () => history.length > 1 ? history.back() : nav("#/search"));
  $("#open-graph").addEventListener("click", () => nav(`#/graph?center=${doc.id}`));
  main.querySelectorAll(".rail-item").forEach(b =>
    b.addEventListener("click", () => nav(`#/doc/${b.dataset.id}`)));
  window.scrollTo(0, 0);
}

/* ══════════════════════════════════════════════
   WIKI
   ══════════════════════════════════════════════ */
const WIKI_GROUPS = [
  ["person", "People", "entity_page"],
  ["tool", "Tools", "entity_page"],
  ["project", "Projects", "entity_page"],
  ["organization", "Organizations", "entity_page"],
  ["place", "Places", "entity_page"],
  ["philosophy", "Philosophies", "concept_page"],
  ["framework", "Frameworks", "concept_page"],
  ["methodology", "Methodologies", "concept_page"],
  ["principle", "Principles", "concept_page"],
  ["system", "Systems", "concept_page"],
];

async function showWiki() {
  main.innerHTML = `<div class="loading">OPENING THE WIKI…</div>`;
  const data = await api("/api/wiki");
  const pages = data.pages;

  const groups = WIKI_GROUPS.map(([key, label, dt]) => {
    const items = pages.filter(p => p.doc_type === dt && p.subtype === key);
    return { key, label, dt, items };
  }).filter(g => g.items.length);

  const areas = pages.filter(p => p.doc_type === "area_overview");
  const sources = pages.filter(p => p.doc_type === "source_summary");

  const groupHtml = (label, dt, items, titleFn) => `
    <section class="wiki-group">
      <h2>${label}</h2>
      <span class="ledger" style="color:${typeColor(dt)}">${items.length} pages</span>
      <ul>${items.map(p => `<li><button data-id="${p.id}">${esc(titleFn ? titleFn(p) : (p.canonical_name || p.title))}</button></li>`).join("")}</ul>
    </section>`;

  main.innerHTML = `
    <p class="wiki-intro">The compiled layer of the archive — entity pages, concept pages,
      area overviews, and source summaries distilled from the raw documents.</p>
    <div class="wiki-groups">
      ${groups.map(g => groupHtml(g.label, g.dt, g.items)).join("")}
      ${areas.length ? groupHtml("Areas", "area_overview", areas,
        p => (p.canonical_name || p.title).replace("Area Overview: ", "")) : ""}
      ${sources.length ? groupHtml("Sources", "source_summary", sources,
        p => p.title.replace("Source Summary: ", "")) : ""}
    </div>`;

  main.querySelectorAll("button[data-id]").forEach(b =>
    b.addEventListener("click", () => nav(`#/doc/${b.dataset.id}`)));
}

/* ══════════════════════════════════════════════
   GRAPH — canvas force layout, no dependencies
   ══════════════════════════════════════════════ */
async function showGraph(centerId) {
  main.innerHTML = `
    <div class="graph-toolbar">
      <input id="g-search" type="search" placeholder="Center on a document… (title search)"
             autocomplete="off" aria-label="Center graph on a document">
      <button class="chip" id="g-full">full map</button>
      <span class="hint" id="g-hint">drag to pan · wheel to zoom · click a node · double-click to recenter</span>
    </div>
    <div class="graph-wrap">
      <canvas id="graph-canvas"></canvas>
      <div class="graph-legend" id="g-legend"></div>
      <div class="graph-info" id="g-info"></div>
    </div>`;

  const canvas = $("#graph-canvas");
  const url = centerId ? `/api/graph?center=${centerId}&depth=2` : "/api/graph";
  const data = await api(url);
  startGraph(canvas, data, centerId ? +centerId : null);

  // legend: types present, in fixed order
  const present = [...new Set(data.nodes.map(n => n.doc_type))];
  $("#g-legend").innerHTML = present.slice(0, 9).map(t =>
    `<div>${typeLabel(t)}<span class="dot" style="background:${typeColorRaw(t)}"></span></div>`).join("");

  const gs = $("#g-search");
  let t = null;
  gs.addEventListener("input", () => {
    clearTimeout(t);
    t = setTimeout(async () => {
      const q = gs.value.trim();
      if (!q) return;
      const res = await api("/api/search?" + new URLSearchParams({ q, sort: "curated", limit: "1" }));
      if (res.results.length) $("#g-hint").textContent = `↵ to center on: ${res.results[0].title}`;
    }, 300);
  });
  gs.addEventListener("keydown", async e => {
    if (e.key !== "Enter") return;
    const q = gs.value.trim();
    if (!q) return;
    const res = await api("/api/search?" + new URLSearchParams({ q, sort: "curated", limit: "1" }));
    if (res.results.length) nav(`#/graph?center=${res.results[0].id}`);
  });
  $("#g-full").addEventListener("click", () => nav("#/graph"));
}

function startGraph(canvas, data, centerId) {
  if (state.graph?.stop) state.graph.stop();

  const ctx = canvas.getContext("2d");
  const wrap = canvas.parentElement;
  const dpr = window.devicePixelRatio || 1;

  function resize() {
    canvas.width = wrap.clientWidth * dpr;
    canvas.height = canvas.clientHeight * dpr;
  }
  resize();

  const nodes = data.nodes.map(n => ({
    ...n,
    x: (Math.sin(n.id * 12.9898) * 43758.5453 % 1) * 800 - 400,
    y: (Math.sin(n.id * 78.233) * 12543.123 % 1) * 800 - 400,
    vx: 0, vy: 0,
    deg: 0,
  }));
  const byId = new Map(nodes.map(n => [n.id, n]));
  const edges = data.edges
    .map(e => ({ s: byId.get(e.source), t: byId.get(e.target), type: e.type }))
    .filter(e => e.s && e.t);
  edges.forEach(e => { e.s.deg++; e.t.deg++; });

  const center = centerId ? byId.get(centerId) : null;
  if (center) { center.x = 0; center.y = 0; }

  let scale = Math.min(1.1, 26 / Math.sqrt(nodes.length || 1) + 0.35);
  let panX = 0, panY = 0;
  let hoverNode = null, selNode = null;
  let alpha = 1;
  let running = true;

  const K_REP = nodes.length > 400 ? 1600 : 2600;
  const K_SPRING = 0.015, SPRING_LEN = 70;

  function tick() {
    // repulsion — grid-bucketed to keep big graphs cheap
    const cell = 120, grid = new Map();
    for (const n of nodes) {
      const key = ((n.x / cell) | 0) + ":" + ((n.y / cell) | 0);
      (grid.get(key) || grid.set(key, []).get(key)).push(n);
    }
    for (const n of nodes) {
      const cx = (n.x / cell) | 0, cy = (n.y / cell) | 0;
      for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) {
        const bucket = grid.get(gx + ":" + gy);
        if (!bucket) continue;
        for (const m of bucket) {
          if (m === n) continue;
          let dx = n.x - m.x, dy = n.y - m.y;
          let d2 = dx * dx + dy * dy;
          if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
          if (d2 > cell * cell * 4) continue;
          const f = K_REP / d2;
          const d = Math.sqrt(d2);
          n.vx += (dx / d) * f; n.vy += (dy / d) * f;
        }
      }
    }
    // springs
    for (const e of edges) {
      const dx = e.t.x - e.s.x, dy = e.t.y - e.s.y;
      const d = Math.sqrt(dx * dx + dy * dy) || 1;
      const f = K_SPRING * (d - SPRING_LEN);
      const fx = (dx / d) * f, fy = (dy / d) * f;
      e.s.vx += fx; e.s.vy += fy;
      e.t.vx -= fx; e.t.vy -= fy;
    }
    // gravity + integrate
    for (const n of nodes) {
      n.vx -= n.x * 0.004; n.vy -= n.y * 0.004;
      if (center && n === center) { n.x = 0; n.y = 0; n.vx = n.vy = 0; continue; }
      n.x += n.vx * alpha * 0.9; n.y += n.vy * alpha * 0.9;
      n.vx *= 0.55; n.vy *= 0.55;
    }
    alpha = Math.max(0.06, alpha * 0.995);
  }

  function nodeRadius(n) {
    return Math.min(14, 3.2 + Math.sqrt(n.deg) * 1.6) * (n === center ? 1.5 : 1);
  }

  function draw() {
    const w = canvas.width, h = canvas.height;
    ctx.clearRect(0, 0, w, h);
    ctx.save();
    ctx.translate(w / 2 + panX * dpr, h / 2 + panY * dpr);
    ctx.scale(scale * dpr, scale * dpr);

    const neighbors = new Set();
    const focus = hoverNode || selNode;
    if (focus) {
      neighbors.add(focus);
      for (const e of edges) {
        if (e.s === focus) neighbors.add(e.t);
        if (e.t === focus) neighbors.add(e.s);
      }
    }

    for (const e of edges) {
      const lit = focus && (e.s === focus || e.t === focus);
      ctx.strokeStyle = lit ? "rgba(217,160,91,0.55)" : "rgba(133,119,97,0.16)";
      ctx.lineWidth = (lit ? 1.4 : 0.7) / scale;
      ctx.beginPath();
      ctx.moveTo(e.s.x, e.s.y);
      ctx.lineTo(e.t.x, e.t.y);
      ctx.stroke();
    }
    for (const n of nodes) {
      const r = nodeRadius(n);
      const dimmed = focus && !neighbors.has(n);
      ctx.globalAlpha = dimmed ? 0.22 : 1;
      ctx.fillStyle = typeColorRaw(n.doc_type);
      ctx.beginPath();
      ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
      ctx.fill();
      // 2px surface ring so overlapping marks stay separable
      ctx.strokeStyle = "#14100c";
      ctx.lineWidth = 2 / scale;
      ctx.stroke();
      if (n === center || n === selNode) {
        ctx.strokeStyle = "#d9a05b";
        ctx.lineWidth = 2 / scale;
        ctx.beginPath();
        ctx.arc(n.x, n.y, r + 3 / scale, 0, Math.PI * 2);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    }
    // labels: high-degree nodes + focus neighborhood
    ctx.font = `${11 / scale}px "IBM Plex Mono", monospace`;
    ctx.fillStyle = "rgba(239,230,214,0.85)";
    const labelSet = focus ? neighbors : new Set(nodes.filter(n => n.deg >= 8 || n === center));
    for (const n of labelSet) {
      if (!n) continue;
      const t = n.title.length > 34 ? n.title.slice(0, 32) + "…" : n.title;
      ctx.fillText(t, n.x + nodeRadius(n) + 4 / scale, n.y + 4 / scale);
    }
    ctx.restore();
  }

  function loop() {
    if (!running) return;
    tick();
    draw();
    requestAnimationFrame(loop);
  }
  loop();

  function toWorld(mx, my) {
    const rect = canvas.getBoundingClientRect();
    return {
      x: (mx - rect.left - rect.width / 2 - panX) / scale,
      y: (my - rect.top - rect.height / 2 - panY) / scale,
    };
  }
  function pick(mx, my) {
    const p = toWorld(mx, my);
    let best = null, bestD = 14 / scale + 6;
    for (const n of nodes) {
      const d = Math.hypot(n.x - p.x, n.y - p.y);
      if (d < Math.max(nodeRadius(n) + 4, bestD) && d < bestD + nodeRadius(n)) {
        if (d - nodeRadius(n) < bestD) { best = n; bestD = d - nodeRadius(n); }
      }
    }
    return best;
  }

  let dragging = false, dragNode = null, lastX = 0, lastY = 0, moved = false;

  canvas.addEventListener("mousedown", e => {
    dragging = true; moved = false;
    lastX = e.clientX; lastY = e.clientY;
    dragNode = pick(e.clientX, e.clientY);
    canvas.classList.add("dragging");
  });
  window.addEventListener("mousemove", e => {
    if (dragging) {
      const dx = e.clientX - lastX, dy = e.clientY - lastY;
      if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
      if (dragNode) {
        const p = toWorld(e.clientX, e.clientY);
        dragNode.x = p.x; dragNode.y = p.y;
        dragNode.vx = dragNode.vy = 0;
        alpha = Math.max(alpha, 0.3);
      } else { panX += dx; panY += dy; }
      lastX = e.clientX; lastY = e.clientY;
    } else {
      hoverNode = pick(e.clientX, e.clientY);
      canvas.style.cursor = hoverNode ? "pointer" : "grab";
    }
  });
  window.addEventListener("mouseup", e => {
    if (dragging && !moved) {
      const n = pick(e.clientX, e.clientY);
      selNode = n || null;
      const info = $("#g-info");
      if (n && info) {
        info.className = "graph-info visible";
        info.style.borderLeftColor = typeColorRaw(n.doc_type);
        info.innerHTML = `
          <div class="ledger">№ ${n.id} · ${typeLabel(n.doc_type)} · ${n.deg} links</div>
          <div class="title">${esc(n.title)}</div>
          <button data-act="open">Open document</button>
          <button data-act="center">Recenter here</button>`;
        info.querySelector('[data-act="open"]').onclick = () => nav(`#/doc/${n.id}`);
        info.querySelector('[data-act="center"]').onclick = () => nav(`#/graph?center=${n.id}`);
      } else if (info) info.className = "graph-info";
    }
    dragging = false; dragNode = null;
    canvas.classList.remove("dragging");
  });
  canvas.addEventListener("dblclick", e => {
    const n = pick(e.clientX, e.clientY);
    if (n) nav(`#/graph?center=${n.id}`);
  });
  canvas.addEventListener("wheel", e => {
    e.preventDefault();
    const factor = e.deltaY < 0 ? 1.12 : 0.89;
    scale = Math.min(4, Math.max(0.08, scale * factor));
  }, { passive: false });

  const onResize = () => resize();
  window.addEventListener("resize", onResize);

  state.graph = {
    stop() {
      running = false;
      window.removeEventListener("resize", onResize);
    },
  };
}

/* ══════════════════════════════════════════════
   LEDGER (dashboard)
   ══════════════════════════════════════════════ */
async function showLedger() {
  main.innerHTML = `<div class="loading">BALANCING THE LEDGER…</div>`;
  const stats = await api("/api/stats");
  state.stats = stats;
  const c = stats.counts;
  const maxN = Math.max(...stats.by_type.map(t => t.n));

  const tiles = [
    [c.documents, "documents"], [c.sources, "sources"], [c.tags, "tags"],
    [c.relationships, "relationships"], [c.synthesis, "syntheses"], [c.operations, "operations"],
  ];

  main.innerHTML = `
    <div class="tile-row">
      ${tiles.map(([n, l]) => `<div class="tile"><div class="num">${fmtNum(n)}</div><div class="lbl">${l}</div></div>`).join("")}
    </div>

    <section class="dash-section">
      <h2>Documents by type</h2>
      <div class="sub">log scale — the biggest sources dwarf everything · click a bar to browse</div>
      <div>
        ${stats.by_type.map(t => {
          const pct = Math.max(1.5, (Math.log10(t.n + 1) / Math.log10(maxN + 1)) * 100);
          return `<button class="bar-row" data-type="${t.doc_type}">
            <span class="bar-label">${typeLabel(t.doc_type)}</span>
            <span class="bar-track"><span class="bar-fill" style="width:${pct}%;background:${typeColor(t.doc_type)}"></span></span>
            <span class="bar-val">${fmtNum(t.n)}</span>
          </button>`;
        }).join("")}
      </div>
    </section>

    <section class="dash-section">
      <h2>Sources</h2>
      <div class="sub">${stats.db_path} · ${stats.db_size_mb} MB · read-only</div>
      <table class="ledger-table">
        <thead><tr><th>Source</th><th>Type</th><th style="text-align:right">Docs</th><th>Last sync</th></tr></thead>
        <tbody>
          ${stats.by_source.map(x => `<tr>
            <td>${esc(x.name)}</td>
            <td class="mono">${esc(x.source_type)}</td>
            <td class="mono" style="text-align:right">${fmtNum(x.doc_count)}</td>
            <td class="mono">${esc((x.last_synced_at || "never").slice(0, 16).replace("T", " "))}</td>
          </tr>`).join("")}
        </tbody>
      </table>
    </section>

    <section class="dash-section">
      <h2>Recent operations</h2>
      <div class="sub">append-only audit trail</div>
      <table class="ledger-table">
        <thead><tr><th>When</th><th>Operation</th><th>Target</th><th>Detail</th></tr></thead>
        <tbody>
          ${stats.recent_ops.map(o => `<tr>
            <td class="mono">${esc((o.timestamp || "").slice(0, 16))}</td>
            <td class="mono">${esc(o.operation)}</td>
            <td class="mono">${esc(o.target_table ? o.target_table + (o.target_id ? " #" + o.target_id : "") : "")}</td>
            <td>${esc((o.detail || "").slice(0, 90))}</td>
          </tr>`).join("")}
        </tbody>
      </table>
    </section>`;

  main.querySelectorAll(".bar-row").forEach(b =>
    b.addEventListener("click", () => {
      state.search = { ...state.search, q: "", type: b.dataset.type, offset: 0, results: [] };
      nav("#/search?type=" + b.dataset.type);
    }));
}

/* ── boot ── */
$("#wordmark").addEventListener("click", () => nav("#/search"));
$("#nav").addEventListener("click", e => {
  const b = e.target.closest("button");
  if (b) nav("#/" + b.dataset.view);
});

loadHeader();
route();
~~~

### Step 17 — Launchers (optional, Windows)

Two convenience launchers. Write to `~/.rag-db/ui/start-rag-ui.bat`:

~~~bat
@echo off
title Karpathy RAG UI
python "%USERPROFILE%\.rag-db\ui\server.py"
~~~

And a silent one-push launcher, `~/.rag-db/ui/open-rag-ui.vbs` (double-click: server starts
hidden, browser opens; safe to run when the server is already up):

~~~vb
' Open Karpathy RAG UI — silent one-push launcher (Windows)
' Starts the server hidden (no console) if it isn't running, then opens the UI.
' If the server is already up, the extra instance exits instantly (port guard).
Dim sh, home
Set sh = CreateObject("WScript.Shell")
home = sh.ExpandEnvironmentStrings("%USERPROFILE%")
sh.Run "pythonw.exe """ & home & "\.rag-db\ui\server.py"" --no-browser", 0, False
WScript.Sleep 1500
sh.Run "http://localhost:6550/", 1, False
~~~

On macOS/Linux, skip the launchers and just run `python ~/.rag-db/ui/server.py`.

### Step 18 — Launch and verify

Run:
```
python ~/.rag-db/ui/server.py --no-browser
```

Then open http://localhost:6550 and verify:
- The header shows document/source counts from your database
- **Catalog** searches as you type (empty until you ingest content)
- **Wiki**, **Graph**, and **Ledger** tabs all render
- The console shows `serving ... (read-only)`

If port 6550 is taken, use `--port 7000`. Stop the server with Ctrl+C (or, if launched
hidden via the VBS, kill the `pythonw.exe` process holding the port).

## Critical Rules

1. **Never overwrite an existing rag.db without explicit confirmation.** If the user runs `/rag-init` again on a populated database, stop and ask.
2. **Write files exactly as embedded.** Do not paraphrase the schema or the helper code.
3. **The Python paths above use `~`** — if the platform doesn't expand tilde reliably (some Windows shells), substitute the user's actual home directory.
4. **The MCP server requires `pip install mcp`.** If the user doesn't have it, the file still gets written but won't run until they install. Mention this in the final report.
5. **Don't pre-seed any documents.** A fresh RAG starts empty. The user populates it through `/rag-ingest`.
6. **The web UI is read-only.** `server.py` opens rag.db with `mode=ro` and can never write — all writes go through the skills. If port 6550 is taken, run `python ~/.rag-db/ui/server.py --port 7000`.