|
| 1 | +# AGENTS.md |
| 2 | + |
| 3 | +Guidance for AI coding agents - and humans - working in the RECAP repository. This is the **single source of truth** for how to set up, run, test, and change this project; tool-specific files (`CLAUDE.md`, etc.) import it. The human-facing overview is in [`README.md`](README.md); the security policy is in [`SECURITY.md`](SECURITY.md). |
| 4 | + |
| 5 | +## What RECAP is |
| 6 | + |
| 7 | +RECAP is a **local-first** Chrome extension (Manifest V3) plus a local Python (FastAPI) RAG backend. It passively indexes the pages a user actually reads and lets them search their browsing history in natural language. Everything runs on the user's machine; page content only leaves the device when the user asks a question - and only the retrieved snippets, sent to the LLM provider the user configured. |
| 8 | + |
| 9 | +## Setup & run |
| 10 | + |
| 11 | +Requires **Docker** (recommended) or **Python 3.11+**, plus Chrome. Three paths, all of |
| 12 | +which install spaCy + the `en_core_web_sm` model (so the knowledge graph works out of the box): |
| 13 | + |
| 14 | +```bash |
| 15 | +# Docker - recommended: reproducible, one command. Serves http://127.0.0.1:8000 (loopback only). |
| 16 | +docker compose up --build |
| 17 | + |
| 18 | +# uv - fast, reproducible local dev. `uv sync` installs deps + the spaCy model. |
| 19 | +uv sync && uv run python main.py |
| 20 | + |
| 21 | +# pip - simple fallback. |
| 22 | +pip install -r requirements.txt && python main.py |
| 23 | +``` |
| 24 | + |
| 25 | +`pyproject.toml` (+ `uv.lock`) is the canonical dependency set for uv/Docker; `requirements.txt` |
| 26 | +mirrors it for the pip path - **keep the two in sync** when changing versions. torch is pinned to |
| 27 | +the CPU wheel (`tool.uv.sources`) so images stay small and it runs anywhere; GPU users override it. |
| 28 | + |
| 29 | +- Copy `.env.example` → `.env` and set at least one LLM key (or point at a local Ollama). See **LLM & embeddings** below. |
| 30 | +- **Docker + a host Ollama:** the container can't see the host's `localhost` - set `LLM_BASE_URL`/`EMBEDDING_BASE_URL` to `http://host.docker.internal:11434/v1` (compose already maps `host.docker.internal`). |
| 31 | +- Load the extension: `chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the `extension/` folder. |
| 32 | +- **Knowledge graph:** entities need spaCy (now installed by default). If you enabled it *after* indexing pages, backfill them without re-browsing via `POST /maintenance/rebuild_kg` (re-runs NER over stored text; SQLite is the source of truth). |
| 33 | + |
| 34 | +## Testing |
| 35 | + |
| 36 | +```bash |
| 37 | +python tests/test_integration.py |
| 38 | +``` |
| 39 | + |
| 40 | +Ten checks (config, models, SQLite/FTS5, LanceDB, knowledge graph, classifier, chunker, reranker, end-to-end pipeline, semantic gate). No pytest needed. On a Windows console, prefix `PYTHONUTF8=1` if you hit a Unicode error. **Run this before opening a PR and keep it green.** |
| 41 | + |
| 42 | +## Architecture - the one rule that matters |
| 43 | + |
| 44 | +**SQLite is the single source of truth; the keyword index and vector index are derived and reconstructible.** |
| 45 | + |
| 46 | +- `data/recap.db` (SQLite) holds the canonical chunk **text** + all page/chunk metadata - stored exactly once. |
| 47 | +- **FTS5** keyword search is an *external-content* table synced by triggers - it holds the inverted index only, no second copy of the text. |
| 48 | +- **LanceDB** (`data/vector_store/`) stores only `chunk_id + vector` - no text, no metadata. |
| 49 | +- Retrieval fuses BM25 + dense + KG with **weighted Reciprocal Rank Fusion** (k=60) → cross-encoder rerank → **time-decay recency** → then **hydrates** text/metadata from SQLite. Hydration is the single place chunk text is read and the single place the date filter is applied. |
| 50 | +- Because the indexes are derived, changing the embedding model **rebuilds vectors from SQLite** (`POST /maintenance/reindex`) without losing any pages. |
| 51 | + |
| 52 | +**Ingestion order:** `content_classifier` → semantic `chunker` (token-aware) → spaCy NER (optional; degrades gracefully) → SQLite → FTS (via triggers) → LanceDB → knowledge graph. Write SQLite (truth) **first**, derived indexes after, and commit the page's `content_hash` **last** - so a failure mid-pipeline re-indexes cleanly on the next visit instead of leaving a half-indexed page. |
| 53 | + |
| 54 | +## Directory map |
| 55 | + |
| 56 | +``` |
| 57 | +main.py entry point (uvicorn -> backend.app:app) |
| 58 | +backend/ |
| 59 | + app.py thin FastAPI layer - NO business logic here |
| 60 | + config.py pydantic-settings Settings (env / .env) |
| 61 | + models.py all Pydantic request/response models (bounded) |
| 62 | + bootstrap.py schema-version clean-rebuild of local data/ |
| 63 | + prompts.py ALL LLM prompt templates + untrusted-content sanitizer |
| 64 | + embeddings.py pluggable embedding fn (local OR OpenAI-compatible) |
| 65 | + ingestion/ processor, chunker, content_classifier, entity_extractor, semantic_gate |
| 66 | + retrieval/ hybrid_retriever (RRF + recency), reranker, answer_generator |
| 67 | + storage/ database (SQLite/FTS5), vector_store (LanceDB), knowledge_graph |
| 68 | +extension/ MV3 extension |
| 69 | + theme.css shared "Index Catalog" design system (every page imports it) |
| 70 | + background.js service worker: tab tracking, domain/path blocklist, gating |
| 71 | + content.js extraction, Shadow-DOM omnibar (Ctrl+Shift+K), highlight (Ctrl+Shift+S) |
| 72 | + newtab/popup/options/onboarding/digest/flashcards/graph |
| 73 | +tests/test_integration.py plain-python test runner |
| 74 | +``` |
| 75 | + |
| 76 | +## LLM & embeddings |
| 77 | + |
| 78 | +- **One OpenAI-compatible client** (the `openai` SDK) serves every LLM provider via a `(base_url, api_key, model)` triple. Presets: `openai`, `groq`, `openrouter`, `google`, `anthropic`, `ollama`; `custom` uses `llm_base_url`. **Do not add per-provider SDKs** (`groq`, `anthropic`, `google-generativeai` are intentionally not dependencies). |
| 79 | +- **Embeddings are pluggable the same way**: `local` sentence-transformers (default `BAAI/bge-base-en-v1.5`) or any OpenAI-compatible `/v1/embeddings`. The true vector **dimension is derived from the model at load - never hard-code it**. |
| 80 | +- Keys come from `.env` or the extension Options page (`POST /update_api_keys`, session-only). |
| 81 | +- LLM calls have a hard timeout; the system prompt includes an injection guard treating retrieved page text as untrusted data. |
| 82 | + |
| 83 | +## Conventions |
| 84 | + |
| 85 | +**Python** |
| 86 | +- `from __future__ import annotations`; full type hints; small, single-purpose functions; docstrings on public methods. |
| 87 | +- All request/response shapes live in `backend/models.py` as Pydantic models **with bounds** (`max_length`, numeric ranges, ISO-date validators). |
| 88 | +- Keep `app.py` a **thin API layer** - delegate to `IngestionProcessor` / `HybridRetriever` / `AnswerGenerator` / storage classes. |
| 89 | +- Blocking work (embedding, DB, vector, LLM) inside an `async` handler must run via `asyncio.to_thread` (or be a sync `def` handler) so it doesn't block the event loop. |
| 90 | + |
| 91 | +**Extension (vanilla JS, no build step)** |
| 92 | +- **IMPORTANT: MV3's CSP forbids inline `<script>` blocks and inline `on*=` handlers.** Put JS in an external `.js` file and wire events with `addEventListener`. |
| 93 | +- **NEVER load remote resources** (fonts, scripts, styles, analytics). The extension must work fully offline for privacy - use system-font stacks, not font CDNs. |
| 94 | +- **Escape all untrusted text** - page titles, URLs, and LLM output (which is derived from indexed pages) - before assigning to `innerHTML`; route link `href`s through a `safeUrl()` that allows only `http(s):`. This prevents XSS from a malicious indexed page. |
| 95 | +- Every page imports `extension/theme.css` first and uses its tokens/classes. Don't reintroduce a per-page palette or a dark theme. |
| 96 | + |
| 97 | +## Security & privacy - non-negotiable |
| 98 | + |
| 99 | +- **NEVER commit API keys or the `data/` directory.** Both are in `.gitignore`; secrets belong only in `.env`. See [`SECURITY.md`](SECURITY.md). |
| 100 | +- The backend **binds `127.0.0.1`** and restricts CORS to the extension origin. **Do not** change the default bind to `0.0.0.0`. |
| 101 | +- Local embeddings + a local LLM (Ollama) is a fully private setup. A **remote** embedding provider sends the text of *every indexed page* off-device - keep it opt-in and keep the warning. |
| 102 | +- Sensitive domains (banking, email, auth, password managers) are excluded from both tracking and content capture - keep that gating intact. |
| 103 | +- Sensitive-page detection is **layered**; keep every layer intact: (1) URL blocklists in the extension + backend (defense in depth for known-bad domains); (2) a **live-DOM structural gate in `content.js`** (`detectSensitivePage`: visible password/payment fields, `noindex`, form dominance, auth-phrase walls) that refuses extraction so sensitive text **never leaves the tab**; (3) a backend PII/auth-text backstop (`detect_sensitive_content`: Luhn-valid cards, SSN, IBAN, contact density); (4) an embedding-prototype `SemanticGate` catching login/account/checkout-like pages on unlisted domains (`semantic_gate_enabled`; fails open). Layers 3-4 run **before** any SQLite write. The DOM check must stay client-side - moving it to the backend would send the text over the wire first. Skip telemetry lands in the `skip_stats` table and surfaces as `top_skipped_domains` in `/stats` (ignore-domain candidates). |
| 104 | + |
| 105 | +## Adding things (quick pointers) |
| 106 | + |
| 107 | +- **New LLM/embedding provider** - add a preset base URL in `retrieval/answer_generator.py` / `embeddings.py`; no new dependency if it's OpenAI-compatible. |
| 108 | +- **New endpoint** - define models in `models.py`, add the route in `app.py`, delegate the logic to a service, wrap blocking work in `to_thread`. |
| 109 | +- **Storage/schema change** - bump `SCHEMA_VERSION` in `bootstrap.py` (triggers a clean local rebuild on next start); keep SQLite the source of truth. |
| 110 | + |
| 111 | +## Gotchas |
| 112 | + |
| 113 | +- Changing `embedding_model` / provider / dimension invalidates the vector space → the app **auto-reindexes from SQLite on next start**; expect a one-time rebuild. |
| 114 | +- `data/` is per-user runtime state; deleting it is safe (it re-indexes as you browse). It is intentionally wiped on a `SCHEMA_VERSION` bump. |
| 115 | +- The knowledge graph needs spaCy (`en_core_web_sm`); without it, entities/graph are empty but BM25 + dense retrieval still work. |
| 116 | +- With a **remote** embedding provider, the `SemanticGate` embeds the head of pages it may then reject - one more reason remote embeddings are opt-in. Fully-local setups are unaffected. |
| 117 | +- Commit conventions: run the test suite first; never bypass hooks; keep secrets and `data/` out of every commit. |
0 commit comments