# Plan B — Lokaler LLM-Router (Gamebox) neben Claude

**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
in <1 s; nur die schweren 20 % (Tiefe, Code, Tools, Pentest, langer Kontext)
gehen an Claude. Claude bleibt das Tiefen-Hirn.

## Warum das der einzige realistische Weg zu „live" ist

Gemessen (10.07.2026): CLI-Round-trip über den Claude-Max-Proxy hat einen
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.

## Modell & Serving (entschieden)

- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
  Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
  weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
  (kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~1–2 GB) + F5-TTS (~1–2 GB) →
  ~8–9 GB frei → passt. (FLUX ist auf 12 GB eh raus.)

## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)

Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:

- llama.cpp hört nur auf localhost der Gamebox.
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
  sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
  schickt `llm_response` (korreliert per requestId) zurück.
- `rvs/server.js` `ALLOWED_TYPES` um `llm_request`, `llm_response` und (Phase 2)
  `llm_partial` erweitern.
- Das Brain bekommt einen zweiten „Proxy" — nur über RVS statt direktem HTTP.

## Router-Logik im Brain

Reihenfolge pro Turn (früh raus = schnell):

- **Tier 0 — Fast-Path (existiert):** reine Steuerbefehle (Spotify, Licht) →
  Skill direkt, **kein LLM**. <1 s.
- **Tier 1 — Lokal (Qwen3):** einfache Konversation, kurze Fakten, Smalltalk,
  Bestätigungen. Ziel <1 s.
- **Tier 2 — Claude:** tief/technisch, Code, Tool-Use nötig, Pentest-Projekt,
  langer/komplexer Kontext.

**Routing-Signal (heuristisch zuerst, deterministisch & schnell):**
Nachrichtenlänge, Schlüsselwörter, ob ein Tool nötig scheint, Projekt-Kontext
(Pentest-Projekt → immer Claude), Konversationstiefe.

**Escalation statt perfekter Vorab-Klassifikation:** Das lokale Modell bekommt
die Anweisung, bei Unsicherheit oder Tool-Bedarf **NICHT zu raten**, sondern zu
eskalieren (z.B. Antwort `<<ESCALATE>>`). Das Brain routet den Turn dann an
Claude. So sind Fehlklassifikationen billig — lieber einmal lokal→Claude als
eine falsche lokale Antwort.

**Modus „Nur lokales LLM" (Diagnostic-Checkbox, Eval-Schalter):** Ein Flag
`localLlmOnly` (in Diagnostic setzbar, vom Brain beim Routen gelesen). Ist es an:
JEDER Turn geht ans lokale LLM, `<<ESCALATE>>` / „zu schwer" werden ignoriert
(kein Claude-Fallback) — damit Stefan die echte Staerke/Schwaeche des lokalen
Modells sieht, ohne dass Claude die schweren Turns rettet. Haken aus = normale
Heuristik + Escalation. Ehrlicher Hinweis: im Nur-lokal-Modus funktionieren
werkzeug-abhaengige Turns (Wetter, Timer, Memory, Bild) nicht — das lokale Tier
hat keine Tools; das ist ein Gespraechs-Eval-Modus, kein Voll-ARIA. Fast-Path
(Spotify etc.) laeuft davon unberuehrt weiter.

## Persona auf BEIDEN Modellen

Das lokale Modell braucht ARIAs Identität, sonst bricht es aus der Rolle
(gelernt aus dem `--system-prompt`-Debakel). Aber **schlanker**:
- IDENTITY_SEED + Kern-Persona: ja.
- Volles Memory / ALLE Skill-Schemas: **nein** — nur eine **kuratierte, kleine
  Tool-Auswahl** (siehe unten). Haelt den lokalen Prompt klein → schnell.
- Persona kommt lokal auch als echter System-Prompt (llama.cpp `system`-Rolle).

## Tool-Calling lokal (kuratierte Auswahl)

Das lokale LLM DARF Werkzeuge nutzen (Qwen3 = natives OpenAI-Tool-Calling, von
llama.cpp `--jinja` unterstuetzt). Ablauf wie bei Claude: Brain schickt
messages + tools → Qwen antwortet mit `tool_calls` → Brain fuehrt via
`_dispatch_tool` aus → Ergebnis zurueck → finale Antwort. Tool-Loop im Brain,
Ziel = lokales LLM statt Claude-Proxy.

**Awareness ≠ Authority.** Das lokale Modell soll WISSEN, was ARIA alles kann
(damit es gezielt eskaliert statt zu halluzinieren), aber nicht alles ausfuehren.

**Harte Grenze = Kontext/VRAM, nicht Misstrauen.** Das volle Tool-Schema sind
~15-20 K Tokens. Qwens Kontext steht auf 8 K (`LLM_CTX=8192`) — es passt nicht
rein. Hochdrehen auf 32 K kostet mehrere GB KV-Cache extra → OOM auf der
geteilten 12-GB-3060 (Whisper + F5-TTS liegen mit drauf). Claude im RZ hat
200 K-1 M Kontext und ist zuverlaessig → kann sich das ganze Arsenal leisten;
das lokale 8B auf Heim-Hardware nicht. Andere Hardware-Klasse, anderes Budget.

**Design (gibt „im Bilde" ohne VRAM zu sprengen):**
- **Ausfuehrbar lokal:** kleiner, risikoarmer Start-Satz — Wetter, Uhrzeit,
  `memory_search` (lesen), `trigger_timer`, Spotify-Steuerung, Licht/Smart-Home.
- **Awareness-Liste (billig, ~paar hundert Tokens im System-Prompt):** kurze
  Aufzaehlung des Rests — „ARIA kann ausserdem: Skills bauen, OAuth, Projekte,
  Bilder, ins Gedaechtnis schreiben — dafuer `<<ESCALATE>>`." Kein volles Schema.
- **Bleibt bei Claude (Authority):** `skill_create/update/delete`, `oauth_*`,
  `project_*`, `flux_generate`, `memory_save`.

Escalation-Netz bleibt: braucht ein Turn ein Tool, das lokal nicht ausfuehrbar
ist → `<<ESCALATE>>` → Claude mit vollem Arsenal. Der „Nur lokales LLM"-Haken
dient dazu, spaeter datengetrieben zu messen, ob der ausfuehrbare Satz erweitert
werden kann.

Implementierung (B1): Adapter reicht `tools` an llama.cpp + gibt `tool_calls`
zurueck; Bridge schleust beides durch (llm_request/llm_response); Brain-Tool-Loop
mit Ziel lokal.

## Phasen

- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox,
  `ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
  Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
  Loop, siehe oben) + „Nur lokales LLM"-Checkbox. Einfache Turns → lokal.
  Messen: Trefferquote, Tool-Zuverlaessigkeit & Latenz.
- **B2 — Streaming/Voice:** `llm_partial` → TTS beginnt beim ersten Satz →
  der „live"-Sprung. **Hier den Gong-/Ohr-Re-Arm-Bug mit-fixen** (Barge-In,
  sauberes Re-Listen).
- **B3 (optional):** lokalen Tool-Satz erweitern, sobald Qwen sich als
  zuverlaessig erweist (z.B. `memory_save`).

## Offene Entscheidungen (für Stefan)

1. **Modell:** Qwen3 8B (Tool-Calling) — oder doch Mistral Small 3 7B (Speed)?
2. **Routing v1:** rein heuristisch + Escalation (entschieden).
3. **Tools lokal:** kuratierte kleine Auswahl (entschieden — Start-Satz oben;
   Stefan bestaetigt/justiert die konkrete Liste vor dem B1-Bau).

## Folge-Baustein: Modell-Auswahl in ARIA Diagnostic (B0.5)

Ziel: In Diagnostic ein Modell auswählen; ist es nicht da, lädt der Container
es on-demand und aktiviert es. Spiegelt zwei bestehende Muster: den
`whisperModel`-Hotswap (RVS-Config-Broadcast → Bridge hot-swapped) und die
kuratierte Claude-Tier-Liste aus `models.json`.

**Kernproblem:** `llama.cpp`-Server serviert **ein** Modell pro Prozess —
„anderes aktivieren" = neu laden/swappen.

**Lösung: `llama-swap`** (Proxy vor llama.cpp): kennt eine Liste von Modellen,
lädt bei Anfrage das gewünschte on-demand (Download via `-hf` beim ersten Mal),
swappt bei VRAM-Knappheit das alte raus. OpenAI-kompatibel — der llm-adapter
zeigt statt auf `llama:8081` auf `llama-swap`.

**Bausteine:**
- `llama-swap`-Service in `xtts/docker-compose.yml` (ersetzt/ergänzt `llama`),
  Config mit den verfügbaren Modellen (Name → `-hf`-Command).
- Kuratierte Liste `local_models.json` (analog `models.json`) — Diagnostic-UI
  liest sie, zeigt Dropdown „Lokales Modell".
- Diagnostic → RVS-Config-Broadcast `localLlmModel` → llm-adapter setzt das
  `model`-Feld seiner llama-swap-Requests → swap/Download passiert automatisch.
- Status zurück an Diagnostic (lädt / bereit / VRAM-OOM), analog whisper-Status.

**Konkret gewünschte UI (Stefan):**
- Modell-Status sichtbar: **lädt (mit Fortschrittsbalken) → heruntergeladen →
  aktiviert**. Ist ein Modell schon im Cache: **nicht neu laden, nur
  aktivieren** (llama.cpp/llama-swap macht das nativ ueber den Cache).
- **Testchat-Zeile** in Diagnostic: kurze Nachricht direkt ans lokale LLM
  schicken, Antwort + Latenz anzeigen. Nutzt denselben RVS-Pfad
  (`llm_request`/`llm_response`) wie der Self-Test — kein neuer Kanal noetig.

Bis dahin: **ein** Modell via `-hf` Auto-Download (B0, erledigt). Erst end-to-end
grün, dann dieser Komfort-Layer.

## Skalierung: VRAM, Multi-GPU, „Cluster"

**Wichtige Klarstellung:** Roher VRAM/GPU ist NICHT ueber RVS teilbar. RVS ist ein
Nachrichten-Relay; GPUs werden lokal per CUDA/PCIe angesprochen. Ueber RVS teilt
man **Inferenz-Faehigkeit** (transkribiere/vervollstaendige), nicht VRAM. Es gibt
daher keinen „GPU-Broker-Container", der Karten uebers Netz verleiht.

Skalierungspfade (echt):
- **Mehr Karten in EINER Box → VRAM-Pool.** llama.cpp/vLLM splitten ein Modell
  ueber mehrere GPUs (`--tensor-split`). 2×3060 = 24 GB → groesseres Modell ODER
  Qwen8B mit grossem Kontext → **volles Tool-Schema passt rein**. Das ist der
  Weg zum „vollen Arsenal lokal".
- **Ein Modell ueber mehrere HOSTS splitten** (llama.cpp `--rpc`): moeglich, aber
  langsam (Layer-Grenzen ueber's Netz) — nur schnelles LAN, fuer „schnell"
  ungeeignet. Nicht empfohlen.
- **Mehrere eigenstaendige Modell-Server, je einer pro GPU/Host, Router waehlt:**
  einfach, = unser RVS-Muster. Zweiter GPU-Host = noch ein llm-adapter, meldet
  sich am RVS an, Router load-balanced. Das ist der sinnvolle „Cluster".
- **Innerhalb eines Hosts:** ein geteilter Inferenz-Server (`llama-swap`/vLLM)
  statt VRAM-Duplikat pro Container — kommt mit B0.5.

**Diagnostic ⓘ (Feature):** Checkbox „volleres Arsenal" + Info-Icon mit
VRAM-Bedarf: 12 GB (1×3060) = kuratierte Tools; 24 GB (2×3060, eine Box) = Qwen
mit grossem Kontext/volles Schema oder groesseres Modell; Cluster = weitere
GPU-Hosts als Modell-Server ueber RVS. (B0.5/B1-UI.)

### „Waechter" / Orchestrator (Ausbaustufe, gestaffelt)

Idee: ein Dienst, der auf den am RVS angemeldeten Hosts Container startet/stoppt.
Zerfaellt in zwei Teile:
- **Billig & bald nuetzlich — Registrierung + Heartbeat:** jeder GPU-Host meldet
  dem RVS „lebe, GPUs, VRAM frei, laufende Dienste" (kleine Erweiterung der
  Adapter; whisper broadcastet schon Status). Nutzen: Diagnostic zeigt die
  Flotte (Live-Daten fuers ⓘ), Router weiss ob lokal erreichbar (sonst Claude).
- **Teuer & aufschiebbar — Steuerung (Container start/stop):** Agent pro Host
  (Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
  Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.

**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen.

### ENTSCHIEDEN: manuelle Platzierung + read-only GPU-Dashboard (kein Auto)

Statt Auto-Controller (Semi-Auto verworfen — Host wechselt selten, Komplexitaet
lohnt nicht):
- **Pin = Docker Compose Profiles.** Services kriegen `profiles: [...]`, jeder
  Host setzt `COMPOSE_PROFILES=<seins>` in der `.env`; `docker compose up`
  startet nur die eigenen. „In Config gepinnt", nativ, kein Code.
- **Verschiebe-Regel:** `up` auf neuem Host + `docker compose rm -sf <svc>` auf
  altem (sonst holt `restart: unless-stopped` den Dienst beim Reboot zurueck →
  Broadcast-Kollision; Profile gelten nur beim `up`, nicht beim Daemon-Restart).
- **GPU-Dashboard in Diagnostic (read-only):** jeder GPU-Host sendet periodisch
  einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
  GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
  N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
  erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
  Ohne Orchestrator.

### Verschieben-Button (Semi-Auto) — reboot-sicher via Platzierungs-Config

Wenn ein „Verschieben"-Button in Diagnostic gewuenscht ist (Dropdown Ziel-Host +
Button = hier stoppen, dort starten), braucht das remote Container-Steuerung →
**kleiner Agent pro GPU-Host** (Docker-Zugriff, hoert RVS-Befehle). Das ist der
zuvor „teure" Teil, aber in der DUMMEN Variante:

- **Eine Platzierungs-Config ist Single Source of Truth:**
  `/shared/config/gpu_placement.json` = `{service: host}`.
- **Dummer Reconcile-Agent pro Host:** bei Start UND Config-Aenderung — starte
  die mir zugewiesenen Dienste, stoppe die anderen. Keine Policy, kein
  VRAM-Placement. Mensch = Scheduler (Button), Agent = befolgt nur Config.
- **Button aendert nur die Config** → Agenten reconcilen (alt stoppt, neu
  startet). **Reboot liest Config** → kein Divergieren, keine Kollision.
- **Reboot-Falle vermieden:** NIE Laufzeit-Move ohne Config-Update (sonst holt
  `restart: unless-stopped` den Dienst beim Reboot zurueck). Config = Wahrheit.

Deploy-Story: Code liegt via git auf allen Hosts (`pull`+`build`), aber `up -d`
startet nichts GPU-maessig von selbst — die Platzierungs-Config (bzw.
`COMPOSE_PROFILES`) entscheidet, was wo laeuft. Neuer Host = zuweisen, Agent
startet.

**Reihenfolge:** NACH B0/B1. Fallback ohne Button: reine `COMPOSE_PROFILES` pro
Host + Verschieben von Hand (null neue Infra).

## Nicht-Ziele

- Kein echter Gemini-Live-Duplex-Klon (Text-Modell als Hirn).
- FLUX bleibt optional/später (dickere GPU). Bild-Generierung separat als
  pluggbarer Provider (ChatGPT/DALL·E-Alternative) — eigenes Feature, nicht Teil B.
