# MentorCore Architecture

MentorCore turns a person's name into a conversational persona. Three components, decoupled by one contract — the **Persona Package**:

```
┌──────────────────┐   writes    ┌─────────────────┐    reads    ┌──────────────────┐
│ Persona Compiler  │ ──────────▶ │ Persona Package │ ◀────────── │ Persona Runtime  │
│ (offline CLI)     │             │ (versioned dir) │             │ (FastAPI server) │
└──────────────────┘             └─────────────────┘             └────────┬─────────┘
   name → sources →                personas/<slug>/                        │ REST + WebSocket
   corpus → index →                                                        ▼
   style → voice → eval                                          ┌──────────────────┐
                                                                 │ Web client (PWA) │
                                                                 │ text + voice     │
                                                                 └──────────────────┘
```

The compiler is slow, expensive, and runs offline. The runtime is fast, cheap, and only ever *reads* packages. This decoupling is what lets build phases proceed independently and is the most important invariant in the codebase: **the runtime never writes to a persona package; the compiler never serves traffic.**

## Repo layout

```
MentorCore/
├── CLAUDE.md               # agent operating instructions (read first)
├── docs/                   # PRD, this file, BUILDPLAN, TESTING, DECISIONS
├── compiler/               # Python package: pipeline stages, CLI entry
│   ├── sources/            # discovery + acquisition adapters (wikipedia, gutenberg, rss, yt)
│   ├── stages/             # normalize, chunk, embed, index, style, voice, eval
│   └── cli.py              # `mentor compile <name>` / `mentor eval <slug>`
├── runtime/                # Python package: FastAPI app
│   ├── api/                # REST routes + WebSocket audio endpoint
│   ├── conversation/       # retrieval, prompt assembly, guards, session state
│   ├── providers/          # LLM provider abstraction (anthropic, fake, ollama)
│   └── speech/             # STT (faster-whisper) and TTS (kokoro, f5) wrappers
├── shared/                 # persona package I/O (the contract), config, types
├── web/                    # Next.js PWA client
├── captures/               # frontier-model-authored persona distillations (checked in) — see docs/PERSONA-CAPTURE.md
├── personas/               # compiled persona packages (gitignored except examples/)
├── scripts/                # check.sh, eval-persona.sh, latency-bench.py
├── tests/                  # pytest suites (mirrors compiler/ runtime/ shared/)
└── observations.md
```

## The Persona Package (the contract)

A versioned directory under `personas/<slug>/`. Compiler writes it atomically (build into `personas/<slug>/.build-<ts>/`, then swap a `current` symlink; previous build retained for rollback).

```
personas/marcus-aurelius/
├── persona.yaml          # manifest — see schema below
├── corpus/               # normalized source docs, one .md per source + provenance frontmatter
├── index/                # LanceDB table (vectors + chunk metadata). Never hand-edit.
├── style/
│   ├── profile.md        # extracted philosophy, tone, vocabulary, beliefs, speech patterns
│   ├── exemplars.jsonl   # few-shot exemplar passages (verbatim quotes w/ source refs)
│   └── holdout.jsonl     # held-out passages reserved for eval — NEVER indexed or used in prompts
├── voice/
│   └── voice.yaml        # tts engine + voice id (stylized) or reference-audio path (cloned)
└── eval/
    └── history.jsonl     # one line per eval run: scores, model ids, timestamp
```

`persona.yaml` schema (validate with pydantic in `shared/package.py`):

```yaml
schema_version: 1
slug: marcus-aurelius
display_name: Marcus Aurelius
born: 121        # era info shapes "what X could know" guard
died: 180
voice_policy: generated   # generated | stylized | cloned-personal | cloned-licensed
sources:                  # provenance for every corpus doc
  - id: meditations-hays
    type: book            # book | interview | podcast | article | letters
    title: Meditations
    rights: public-domain # public-domain | fair-use-excerpt | licensed | personal-use
    origin_url: https://www.gutenberg.org/ebooks/2680
style_summary: >          # one-paragraph distillation injected into system prompt
  Stoic, austere, second-person self-address...
fidelity:
  groundedness: 0.0       # filled by eval stage
  style_match: 0.0
  last_eval: null
```

## Compiler pipeline

Each stage is a pure function `stage(package_dir, config) -> StageResult`, independently testable, resumable (stages skip if their output exists and inputs unchanged — hash-based).

| # | Stage | What it does | Key tools |
|---|---|---|---|
| 1 | discover | name → candidate source list with rights metadata | `wikipedia` API, Wikiquote, gutendex.com (Project Gutenberg JSON API), Open Library API, `feedparser` for podcast RSS, web search. Phase 1a: skipped — sources hand-curated in a `sources.yaml` seed file |
| 2 | acquire | download + extract text/audio | `httpx`, `trafilatura` (web articles), `yt-dlp` (interview/podcast audio), Gutenberg plain-text |
| 3 | transcribe | audio → text with speaker turns | `faster-whisper` (CTranslate2; runs well on Apple Silicon CPU/MPS) |
| 4 | normalize | unify to markdown + provenance frontmatter; strip boilerplate | custom; lives in `compiler/stages/normalize.py` |
| 5 | split | hold out ~10% of passages for eval (stratified by source) | writes `style/holdout.jsonl` BEFORE indexing |
| 6 | chunk+embed+index | chunk (by-paragraph, ~512 tok, overlap 64), embed, write LanceDB table | `lancedb` (embedded, file-based, no server), `sentence-transformers` w/ `BAAI/bge-small-en-v1.5` locally |
| 7 | style | LLM pass over corpus → `profile.md` + exemplar selection | Claude **Haiku 4.5** (`claude-haiku-4-5`) for per-doc extraction via **Batches API** (50% cost); one **Opus 4.8** synthesis pass for the final profile |
| 8 | voice | build voice config per policy tier | see Voice section |
| 9 | eval | fidelity score vs holdout; write `eval/history.jsonl`; gate | judge = **Opus 4.8** (`claude-opus-4-8`); see TESTING.md |

CLI: `uv run mentor compile marcus-aurelius --sources sources/marcus-aurelius.yaml` and `uv run mentor eval marcus-aurelius`.

### Capture import (stage 7b, planned — Phase 1e)

If `captures/<slug>/` exists (a frontier-model-authored distillation: philosophy, style, provenance-tiered quotes, synthetic exemplars, voice references — full spec in `PERSONA-CAPTURE.md`), the `capture_import` stage merges it into the package after `style`: the capture takes precedence over machine extraction for `style/profile.md`, `provenance: verified` quotes join the index as a verbatim tier, synthetic Q→A exemplars feed `style/exemplars.jsonl`, and capture voice references feed the voice stage. Captures are inputs — the runtime still reads only the package, and the eval gate applies unchanged.

## Runtime pipeline

```
text path:   POST /api/chat/{slug}  → retrieve → assemble prompt → Claude (stream) → SSE to client
voice path:  WS /api/voice/{slug}   → audio frames in → VAD/endpoint → faster-whisper →
             retrieve → assemble → Claude (stream) → sentence-chunked TTS → audio frames out
```

### Prompt assembly (in `runtime/conversation/prompt.py`)

System prompt order (stable → volatile, for prompt caching — see caching note):
1. Persona identity + `style_summary` + era-knowledge guard ("You are X... You know nothing after {died}.")
2. Misattribution guard: *"Distinguish 'I wrote' (verbatim or near-verbatim from your works, cite the source doc) from 'I might say' (extrapolation in your voice). Never invent quotes."*
3. Style exemplars (few-shot, from `style/exemplars.jsonl`)
4. `cache_control: {type: "ephemeral"}` breakpoint here — everything above is byte-stable per persona
5. Retrieved chunks for this turn (volatile, after the breakpoint)

### Latency budget (voice turn, target < 2.0s perceived)

| Stage | Budget | Notes |
|---|---|---|
| endpoint detection (silence) | 300 ms | client-side VAD via WebAudio worklet |
| STT (faster-whisper small/distil) | 250 ms | server, runs on utterance, not streaming-incremental in v1 |
| retrieval (LanceDB) | 30 ms | embedded, local |
| LLM time-to-first-sentence | 700 ms | **Sonnet 4.6** (`claude-sonnet-4-6`), streaming, prompt-cached prefix |
| TTS first audio chunk (Kokoro) | 250 ms | synthesize per sentence as LLM streams; don't wait for full reply |
| network/buffer | 200 ms | local network in racing mode; Tailscale adds ~20-80ms mobile |

Perceived latency = time to *first audio out*, not full response. Sentence-streaming TTS is the load-bearing trick: split LLM stream on sentence boundaries, synthesize and ship each immediately.

### LLM provider abstraction

`runtime/providers/base.py` defines `class ChatProvider(Protocol): def stream_chat(system, messages, **kw) -> AsyncIterator[str]`. Implementations:
- `AnthropicProvider` — default. Uses official `anthropic` SDK, `client.messages.stream(...)`, adaptive-thinking defaults per model. Model tiers (config-driven, not hardcoded):
  - runtime conversation: `claude-sonnet-4-6` ($3/$15 per MTok — speed/quality balance for sub-second TTFT)
  - fidelity judge: `claude-opus-4-8` ($5/$25)
  - bulk extraction (compiler stage 7): `claude-haiku-4-5` ($1/$5) via Batches API
- `FakeProvider` — deterministic test double; replays canned responses. Used by all unit/integration tests so the suite runs offline and free.
- `OllamaProvider` — stub interface only in Phase 1 (raises NotImplementedError with a pointer); real implementation when the M4 Mac Studio local-model mode is built. The Protocol is the deliverable, not the adapter.

`ANTHROPIC_API_KEY` from env. Never log message contents at INFO.

## Voice (tiered policy)

| Tier | Engine | Notes |
|---|---|---|
| stylized (default, public-safe) | **Kokoro-82M** (`hexgrad/kokoro`) | Apache 2.0, 54 voicepacks, real-time on Apple Silicon, no cloning capability by design |
| cloned (personal/local only) | **F5-TTS** (`SWivid/F5-TTS`) | zero-shot clone from 5–15s reference audio. **CC-BY-NC 4.0 — non-commercial.** Acceptable for the personal tier; the public tier must not ship F5 output. If commercial cloning is ever needed: licensed API (e.g. ElevenLabs) with consent/rights, behind the same TTS interface |
| generated (fictional / long-dead) | Kokoro voicepack chosen by era/character description | same engine as stylized |

`runtime/speech/tts.py` exposes one interface: `synthesize_stream(text_iter, voice_config) -> AsyncIterator[bytes]` (16-bit PCM frames). STT: `runtime/speech/stt.py` wraps `faster-whisper` (model size configurable; `distil-small.en` default for latency).

## Web client (Next.js PWA)

- Next.js app in `web/`, served via the monorepo's standard launchd + `tailscale serve` pattern (`next start --port <PORT> --hostname 127.0.0.1`; check port conflicts first — see monorepo CLAUDE.md).
- Text chat: SSE from `POST /api/chat/{slug}`.
- Voice: hold-to-talk button (and spacebar) → MediaRecorder/AudioWorklet frames over WebSocket → plays returned PCM via WebAudio. PWA manifest so it installs to the phone home screen for the dog-walk use case (BT headset mic/speaker work through the normal browser audio stack).
- Persona picker reads `GET /api/personas` (runtime lists `personas/*/persona.yaml`).

## Deployment modes

| Mode | Topology | Phase |
|---|---|---|
| local dev | everything on one machine, `localhost` | 0+ |
| home server | runtime on Mac Studio (M4), launchd service, Tailscale serve for phone access | 1c |
| racing | Windows VR PC runs the sim + a thin push-to-talk client hitting the Mac Studio runtime over LAN; telemetry hook feeds a `/api/telemetry` endpoint | 2 |
| public | hosted compiler + runtime, accounts, shared persona library, voice-policy enforcement | 3 (requirements only) |

## Tech stack summary

| Concern | Choice | Why / fallback |
|---|---|---|
| Python env | `uv` | fast, lockfile, single tool |
| Backend | FastAPI + uvicorn + `websockets` | first-class WS + SSE, pydantic models shared with package schema |
| LLM | `anthropic` SDK (hybrid abstraction) | see provider section |
| Embeddings | `sentence-transformers` / bge-small | local, free, good enough for single-corpus retrieval; swap = re-index only |
| Vector store | LanceDB | embedded file-based (no server process), fits the "package is a directory" contract; fallback: `sqlite-vec` |
| STT | `faster-whisper` | CTranslate2, Apple Silicon friendly |
| TTS | Kokoro / F5-TTS | see voice table |
| Audio acquisition | `yt-dlp`, `feedparser` | interviews, podcasts |
| Text extraction | `trafilatura`, gutendex | articles, public-domain books |
| Frontend | Next.js PWA | matches existing launchd/Tailscale patterns; phone-installable |
| Tests | pytest, pytest-asyncio, Playwright | see TESTING.md |