"""
Conversation-Loop. Eine Anfrage von Stefan, eine Antwort von ARIA.

Pro Turn:
  1. user-Turn an die laufende Conversation appenden
  2. Hot Memory holen (alle pinned Punkte)
  3. Cold Memory holen (Top-K semantisch zur user-Nachricht)
  4. System-Prompt aus Hot+Cold bauen
  5. Messages = [system, *window, user]
  6. Claude via Proxy aufrufen
  7. Assistant-Reply in Conversation appenden + zurueckgeben

Memory-Destillat laeuft asynchron NACH dem Reply, gesteuert vom
/chat-Endpoint ueber BackgroundTasks.
"""

from __future__ import annotations

import json
import logging
import os
import re
import urllib.error
import urllib.request
from typing import Optional

from conversation import Conversation, Turn
from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
from local_llm import local_llm_chat
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod

BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
FLUX_HTTP_TIMEOUT_SEC = 1200
# Diagnostic-Settings fuer FLUX (Default-Modell + User-Keywords) liegen im
# selben File wie F5-TTS/Whisper Config — von der aria-bridge geschrieben.
VOICE_CONFIG_PATH = "/shared/config/voice_config.json"


def _load_flux_config() -> dict:
    """Liest fluxXxx-Felder aus der Voice-Config. Default-Werte wenn nichts
    persistiert ist — Stefan hat in Diagnostic vielleicht noch nichts gesetzt."""
    try:
        with open(VOICE_CONFIG_PATH, encoding="utf-8") as f:
            data = json.load(f) or {}
    except (FileNotFoundError, json.JSONDecodeError):
        data = {}
    except Exception as exc:
        logger.debug("Voice-Config lesen fehlgeschlagen: %s", exc)
        data = {}
    return {
        "fluxDefaultModel": data.get("fluxDefaultModel", "dev"),
        "fluxKeywordRaw": data.get("fluxKeywordRaw", "flux"),
        "fluxKeywordSwitch": data.get("fluxKeywordSwitch", "fix"),
    }

logger = logging.getLogger(__name__)


# Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "skill_create",
            "description": (
                "Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
                "Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
                "mit den pip_packages die er braucht.\n\n"
                "PFLICHT VORHER:\n"
                "  - `skill_list` aufrufen und pruefen ob ein passender Skill schon "
                "existiert. Wenn ja: `skill_update` statt neu anlegen.\n"
                "  - Name OHNE Versionssuffix waehlen (kein `-v2`, `_v3`, `-new`, "
                "`-fixed`, `-aria`, `-ctl`). Versionsverwaltung ist intern, Du brauchst "
                "nur einen klaren Namen.\n"
                "  - Bei OAuth-Services (Spotify, Google, GitHub etc.): NIEMALS "
                "client_id/client_secret/Tokens in den Code schreiben. Nutze "
                "`oauth_get_token('<service>')` — das macht Auto-Refresh. Sonst muss "
                "Stefan sich alle 60min manuell neu einloggen.\n"
                "  - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
                "ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
                "HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
                "pip-Library. Sonst muesste der Install bei jedem Container-Restart "
                "neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
                "Sonst NUR wenn ALLE Kriterien erfuellt sind:\n"
                "  1) wiederkehrend (Aufgabe kommt realistisch nochmal),\n"
                "  2) nicht-trivial (mehrere Schritte),\n"
                "  3) parametrisierbar (nimmt Eingaben, gibt Ergebnis),\n"
                "  4) wiederverwendbar als ganzes Paket.\n"
                "NICHT fuer einzelne Shell-Befehle (date, hostname, ls etc.) und "
                "nicht fuer Einmal-Faelle. Stefan kann Skill-Erstellung explizit "
                "triggern (\"bau daraus einen Skill\").\n\n"
                "Wenn etwas nur via apt-Paket geht — Stefan fragen ob es ins "
                "Brain-Dockerfile soll, NICHT als Skill bauen."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "kurz, snake_case (NUR a-z 0-9 _). KEINE Bindestriche — die brechen das Tool-Schema beim claude-max-api-proxy. Statt 'yt-dlp-download' → 'yt_dlp_download'."},
                    "description": {"type": "string", "description": "Was kann der Skill? 1 Satz."},
                    "entry_code": {
                        "type": "string",
                        "description": (
                            "Python-Code. Args lesen via os.environ['ARG_<UPPER_NAME>']. "
                            "WICHTIG: der Präfix `ARG_` ist Pflicht (Konvention vom "
                            "Skill-Runner). NIEMALS direkt PATH/METHOD/BODY etc. "
                            "abrufen — das sind reservierte System-ENV (PATH = "
                            "Executable-Suchpfad, nicht Dein arg!). Resultat per "
                            "print() (stdout) zurueck. Bei Fehler: non-zero exit "
                            "(sys.exit(1) o.ae.)."
                        ),
                    },
                    "readme": {"type": "string", "description": "Markdown — was macht der Skill, Beispiel-Aufrufe"},
                    "pip_packages": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "pip-Pakete die in der venv installiert werden (z.B. requests, yt-dlp, pypdf)",
                    },
                    "args": {
                        "type": "array",
                        "items": {"type": "object"},
                        "description": "Argumente-Schema [{name, type, required, description}]",
                    },
                    "fast_patterns": {
                        "type": "array",
                        "items": {"type": "object"},
                        "description": (
                            "OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
                            "pause/next, Rollade hoch/runter etc.) eine Liste von "
                            "[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
                            "match (anchored Regex, case-insensitive) matched, ruft das "
                            "Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
                            "ohne Claude (~5s Latenz gespart). Match wird gegen den "
                            "normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
                            "schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
                            "und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
                            "Skills mit kreativem Output / parametrisierter Logik — die "
                            "brauchen Claude. Beispiel: "
                            "[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
                            "\"reply\":\"Spotify: pausiert ⏸\"}]"
                        ),
                    },
                },
                "required": ["name", "description", "entry_code"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_list",
            "description": "Zeigt alle Skills (inkl. deaktivierte). Sollte selten noetig sein — die Liste steht eh im System-Prompt.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_update",
            "description": (
                "Aktualisiere einen EXISTIERENDEN Skill statt eine zweite Version "
                "mit `-v2`/`-new`/`-fixed` Suffix anzulegen. Stefan hasst Skill-"
                "Friedhoefe. Wenn Du `youtube2mp3` umbauen sollst → `skill_update` "
                "auf den bestehenden, NICHT `skill_create` mit neuem Namen.\n\n"
                "Skills sind GANZ NORMALER Python-Code. Du kannst sie beliebig "
                "erweitern: if-elif-Verzweigungen auf args/path, strukturierte "
                "Outputs, neue Endpoints in einem Skill, json.loads etc. "
                "'Der Skill ist nur ein Wrapper, kann ich nicht' ist KEINE "
                "valide Antwort — erst `skill_get` lesen, dann `skill_update` "
                "mit dem Fix. Stefan ist kein Python-Entwickler, er nennt das "
                "ZIEL, Du baust das WIE.\n\n"
                "Du kannst gleichzeitig `entry_code` (Python-Code austauschen), "
                "`readme`, `pip_packages` (bei Aenderung wird die venv automatisch "
                "neu aufgebaut), `args`, `description` und `active` setzen. Felder "
                "die Du weglaesst bleiben unberuehrt.\n\n"
                "WENN Du Dir bei einem grundlegenden API-Bruch unsicher bist ob "
                "der Skill noch zum Namen passt: lieber `skill_delete` + "
                "`skill_create` mit neuem semantischen Namen statt eines "
                "halbgaren Updates."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Bestehender Skill-Name"},
                    "entry_code": {"type": "string", "description": "Neuer Python-Code (optional)"},
                    "readme": {"type": "string", "description": "Neuer README-Inhalt (optional)"},
                    "pip_packages": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Neue pip-Pakete (ueberschreibt komplette Liste; triggert venv-Rebuild)",
                    },
                    "args": {
                        "type": "array",
                        "items": {"type": "object"},
                        "description": "Neues Args-Schema (optional)",
                    },
                    "description": {"type": "string", "description": "Neue Beschreibung (optional)"},
                    "active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
                    "config_schema": {
                        "type": "array",
                        "items": {"type": "object"},
                        "description": (
                            "Optional neues config_schema fuer den Skill. Liste von "
                            "Feldern [{name, type, label, secret?, description?, default?}]. "
                            "type: string|number|boolean|password (password impliziert secret=true). "
                            "Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
                        ),
                    },
                    "fast_patterns": {
                        "type": "array",
                        "items": {"type": "object"},
                        "description": (
                            "Optional komplette Fast-Path-Patterns-Liste UEBERSCHREIBEN — "
                            "[{match, args, reply}]. Siehe skill_create-Beschreibung fuer "
                            "Format. Leere Liste = alle Fast-Paths entfernen (alles geht "
                            "wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
                        ),
                    },
                },
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_scaffold",
            "description": (
                "ERSTE WAHL fuer Skill-Bau wenn das Muster zu einem Template passt — "
                "Brain expandiert das Skelett, Du sparst Dir das vollstaendige "
                "Python-Programm zu generieren. Wenn Stefan eine externe API "
                "mehrmals nutzt: SOFORT `skill_scaffold` statt jedes Mal "
                "ad-hoc Bash-curl.\n\n"
                "Verfuegbare Templates:\n"
                "  - **oauth-api**: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, …). "
                "Token kommt vom Brain mit Auto-Refresh. params: "
                "`{service:'spotify', base_url?:'https://...'}`\n"
                "  - **apikey-api**: API mit statischem Key (OpenWeather, OpenAI, Twilio). "
                "Key liegt im skill.json config_schema → CFG_<NAME> ENV. params: "
                "`{api_name:'OpenWeather', key_env:'OWM_API_KEY', auth_header?:'Authorization', auth_prefix?:'Bearer ', base_url:'https://...'}`\n"
                "  - **file-process**: Skelett fuer Datei-In/Datei-Out (PDF, Bild, JSON umformen). "
                "process()-Funktion ist Stub — danach `skill_update` mit echtem Code. params: "
                "`{output_ext:'txt'}`\n\n"
                "Nach Scaffold kannst Du das Skelett via `skill_update` weiter "
                "anpassen falls noetig (mehr pip_packages, andere args, …). "
                "Aber meistens reicht das Template direkt.\n\n"
                "Wenn kein Template passt: erst pruefen ob Du wirklich ein "
                "kustomes brauchst, sonst lieber Template + Update."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string",
                             "description": "Skill-Name (snake_case, NUR a-z 0-9 _, KEINE Bindestriche, ohne Versionssuffix)"},
                    "template": {"type": "string",
                                 "enum": ["oauth-api", "apikey-api", "file-process"],
                                 "description": "Eines der drei Templates"},
                    "params": {"type": "object",
                               "description": "Template-spezifische Parameter (siehe description)"},
                },
                "required": ["name", "template"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_set_config",
            "description": (
                "Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
                "User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
                "im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
                "Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
                "NICHT in den Skill-Code, sondern hierher.\n\n"
                "WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
                "aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
                "aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Skill-Name"},
                    "values": {
                        "type": "object",
                        "description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
                    },
                },
                "required": ["name", "values"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_list_versions",
            "description": (
                "Listet archivierte Versionen eines Skills (jeder skill_update "
                "legt automatisch eine an). Returns [{version_id, archived_at, "
                "summary}]. Brauchst Du fuer skill_rollback."
            ),
            "parameters": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_rollback",
            "description": (
                "Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
                "wird der aktuelle Stand automatisch archiviert — du verlierst "
                "nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
                "oder Stefan sagt 'mach den letzten Stand wieder her'. "
                "version_id bekommst Du aus skill_list_versions."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "version_id": {"type": "string", "description": "Format v_<timestamp>"},
                },
                "required": ["name", "version_id"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "skill_delete",
            "description": (
                "Loescht einen Skill samt venv und Logs. Nutze das wenn:\n"
                "1. Stefan explizit sagt der Skill soll weg\n"
                "2. Du eine alte Skill-Version losgeworden bist nachdem `skill_create` "
                "mit besserem Namen erfolgreich war (Aufraeumen statt Skill-Friedhof)\n"
                "3. Ein Skill grundlegend kaputt und ein Update sich nicht mehr lohnt — "
                "in dem Fall bestaetige vorher kurz bei Stefan.\n\n"
                "Nicht rueckholbar."
            ),
            "parameters": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "trigger_timer",
            "description": (
                "Lege einen Timer-Trigger an — feuert EINMALIG und ruft dich dann selbst auf "
                "(Push-Nachricht an Stefan). Use-Case: 'erinnere mich in 10min', "
                "'sag mir um 14:30 Bescheid'. Genau EINES von `in_seconds` ODER `fires_at` "
                "muss gesetzt sein."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"},
                    "in_seconds": {
                        "type": "integer",
                        "description": (
                            "Relativ ab jetzt in Sekunden. Bevorzugt bei Angaben wie "
                            "'in 2 Minuten' (=120), 'in 1 Stunde' (=3600). "
                            "Server berechnet daraus den absoluten Feuer-Zeitpunkt."
                        ),
                    },
                    "fires_at": {
                        "type": "string",
                        "description": (
                            "Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. "
                            "'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im "
                            "System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben "
                            "lieber `in_seconds` nutzen."
                        ),
                    },
                    "message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
                },
                "required": ["name", "message"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "trigger_watcher",
            "description": (
                "Lege einen Watcher-Trigger an — pollt eine Condition, "
                "feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
                "Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
                "Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
                "Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
                "- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
                "Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
                "- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
                "Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
                "wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
                "nur gegen GPS-Jitter).\n"
                "- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
                "Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "kurzer Name"},
                    "condition": {
                        "type": "string",
                        "description": (
                            "Boolescher Ausdruck mit den erlaubten Variablen, z.B. "
                            "'disk_free_gb < 5', 'hour_of_day == 8 and day_of_week == \"mon\"'. "
                            "Operatoren: < > <= >= == != and or not"
                        ),
                    },
                    "message": {"type": "string", "description": "Was soll bei Erfuellung gesagt werden"},
                    "check_interval_sec": {
                        "type": "integer",
                        "description": "Wie oft Condition pruefen (Default 300 = alle 5min, min 30)",
                    },
                    "throttle_sec": {
                        "type": "integer",
                        "description": "Mindestabstand zwischen 2 Feuerungen (Default 3600 = max 1x/h)",
                    },
                },
                "required": ["name", "condition", "message"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "trigger_cancel",
            "description": "Loescht einen Trigger (Timer abbrechen oder Watcher entfernen).",
            "parameters": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "trigger_list",
            "description": "Zeigt alle Trigger (active + inaktiv). Selten noetig — Stefan sieht sie im Diagnostic.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "request_location_tracking",
            "description": (
                "Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
                "deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen "
                "GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App "
                "veraltete Position und der Watcher feuert nie. Auch wieder ausschalten "
                "wenn der letzte GPS-Watcher geloescht wurde."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "on": {"type": "boolean", "description": "true = Tracking an, false = aus"},
                    "reason": {"type": "string", "description": "Kurzer Grund (wird in App-Notification angezeigt)"},
                },
                "required": ["on"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "oauth_register_provider",
            "description": (
                "Registriert einen NEUEN OAuth2-Provider in oauth_apps.json — "
                "nutze das wenn Stefan einen Service nutzen will, der noch nicht "
                "in der Default-Liste (spotify, google, github, strava, microsoft) "
                "ist. Du kennst typische OAuth-Endpunkte aus deinem Training "
                "(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, "
                "Zoom, Trello, ...). Trag NUR die URLs ein — client_id / "
                "client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder "
                "App > Settings > OAuth-Apps).\n\n"
                "**Workflow bei neuem Service:**\n"
                "1. `oauth_register_provider` mit auth_url + token_url + scopes\n"
                "2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in "
                "Diagnostic/App > OAuth-Apps deine client_id + client_secret aus "
                "dem {name}-Developer-Dashboard ein. Plus die Callback-URL "
                "{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n"
                "3. Warten bis Stefan fertig ist\n"
                "4. `oauth_authorize` rufen\n\n"
                "**`client_auth`-Wert:** Die meisten Provider wollen client_id+"
                "secret im Body (`body`, default). Spotify und manche andere "
                "wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, "
                "nimm `body` — schlaegt der Token-Request dann mit 401 fehl, "
                "switch auf `basic`.\n\n"
                "Bei Provider die du wirklich nicht kennst: frag Stefan oder "
                "such die Docs raus statt zu raten."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "service": {
                        "type": "string",
                        "description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')",
                    },
                    "auth_url": {
                        "type": "string",
                        "description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'",
                    },
                    "token_url": {
                        "type": "string",
                        "description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'",
                    },
                    "scopes": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Default-Scopes die der User beim Auth zustimmen muss",
                    },
                    "client_auth": {
                        "type": "string",
                        "enum": ["body", "basic"],
                        "description": "Wie der Provider client_id/secret erwartet (Default 'body')",
                    },
                },
                "required": ["service", "auth_url", "token_url"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "oauth_authorize",
            "description": (
                "Startet einen OAuth2-Authorize-Flow fuer einen externen "
                "Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
                "Returnt eine URL die Stefan im Browser oeffnen muss — er "
                "loggt sich beim Provider ein und stimmt den Scopes zu, der "
                "Provider redirected zu unserem RVS-Callback, RVS forwarded "
                "an Brain, Token wird automatisch gespeichert.\n\n"
                "**Nutze das wenn:** Stefan moechte einen Service nutzen "
                "(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
                "Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
                "**Workflow:**\n"
                "1. `oauth_authorize(service='spotify')` -> URL\n"
                "2. Gib Stefan die URL als anklickbaren Link\n"
                "3. Warte bis er sagt dass er autorisiert hat\n"
                "4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
                "Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
                "Service `client_id` + `client_secret` eingetragen. Falls nicht, "
                "wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
                "er soll das machen, NICHT versuchen die Credentials selbst zu "
                "raten oder zu generieren."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "service": {
                        "type": "string",
                        "description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
                    },
                    "scopes": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
                    },
                },
                "required": ["service"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "oauth_get_token",
            "description": (
                "Liefert das aktuelle access_token fuer einen Service. "
                "Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
                "und der Provider einen refresh_token mitgegeben hat.\n\n"
                "**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
                "der token kommt als Bearer-Header in Deinen HTTP-Request, "
                "z.B. `Authorization: Bearer <token>`.\n\n"
                "Wirft wenn Service noch nicht authentifiziert ist oder der "
                "Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "service": {"type": "string", "description": "z.B. spotify, google, ..."},
                },
                "required": ["service"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "oauth_revoke",
            "description": (
                "Loescht das gespeicherte Token fuer einen Service (lokal). "
                "Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
                "er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
                "\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
            ),
            "parameters": {
                "type": "object",
                "properties": {"service": {"type": "string"}},
                "required": ["service"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "flux_generate",
            "description": (
                "Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. "
                "Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
                "Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
                "kurz an, dann ist er nicht ueberrascht.\n\n"
                "**Schreibe deine Antwort wie immer auf Deutsch**, und referenziere das "
                "fertige Bild MIT dem `[FILE: ...]`-Marker, GENAU im Pfad-Format das das "
                "Tool zurueckgibt. Beispiel:\n"
                "  'Hier dein Aquarell:\\n[FILE: /shared/uploads/aria_generated_1234.png]'\n\n"
                "Der Marker wird beim App-Renderer ausgeblendet und das Bild stattdessen "
                "inline als Anhang gezeigt.\n\n"
                "**Prompt-Sprache: bevorzugt Englisch.** FLUX versteht zwar Deutsch, "
                "liefert aber mit englischen Prompts deutlich konsistentere Ergebnisse. "
                "Uebersetze Stefans deutsche Beschreibung selbststaendig — AUSSER `raw=true`.\n\n"
                "**Modus `raw=true` (Pipe-Modus):** Wenn Stefan das Raw-Keyword aus dem "
                "FLUX-Settings-Block im System-Prompt nutzt (typischerweise `flux`), "
                "leite seinen Text 1:1 als prompt durch — KEIN Uebersetzen, KEIN "
                "Beautify, KEINE Qualitaets-Keywords. Stefan formuliert dann selbst und "
                "der Prompt geht roh an FLUX. Brauchbar wenn er den vollen Output ohne "
                "ARIAs Filter haben will.\n\n"
                "**Modell-Wahl (`model`):** \n"
                "- `default` (oder weglassen): das in den Diagnostic-Settings eingestellte "
                "Default-Modell (steht im FLUX-Block im System-Prompt).\n"
                "- `dev`: hochqualitatives FLUX.1-dev, 20-90s, ~28 steps.\n"
                "- `schnell`: FLUX.1-schnell, 4-step distillation, ~5-15s.\n"
                "Wenn Stefan das Switch-Keyword (steht ebenfalls im FLUX-Block) im Prompt "
                "verwendet → setze `model` auf das ANDERE Modell als das Default. Bei "
                "'in hoher Qualitaet'/'detailliert' → `dev`. Bei 'schnell mal'/'fix' → `schnell`.\n\n"
                "Modell-Switch kostet einmalig 15-30s (Pipeline-Reload aus HF-Cache). "
                "Stefan sieht den Status im Diagnostic-Banner.\n\n"
                "Caps:\n"
                "- `width`/`height`: 256-1536, wird auf Vielfache von 64 gesnappt (Default 1024)\n"
                "- `steps`: 1-50 (Default 28 fuer dev, 4 fuer schnell)\n"
                "- `guidance_scale`: 0.0-20.0 (Default 3.5)\n"
                "- `seed`: optional, gleicher seed + gleicher prompt → gleiches Bild"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": (
                            "Bei raw=false (Default): englischer Bild-Prompt, von dir aus Stefans Worten gebaut, "
                            "mit Stil/Licht/Kamera-Stichworten. Bei raw=true: Stefans Text 1:1 ohne Aenderung."
                        ),
                    },
                    "raw": {
                        "type": "boolean",
                        "description": (
                            "true = Pipe-Modus, kein Rewriting. Setzen wenn Stefan das Raw-Keyword "
                            "(siehe FLUX-Block im System-Prompt) am Anfang seiner Nachricht verwendet."
                        ),
                    },
                    "model": {
                        "type": "string",
                        "enum": ["default", "dev", "schnell"],
                        "description": "Default-Modell oder explizit dev/schnell. Default = Diagnostic-Setting.",
                    },
                    "width": {"type": "integer", "description": "Breite in px (Default 1024, max 1536)"},
                    "height": {"type": "integer", "description": "Hoehe in px (Default 1024, max 1536)"},
                    "steps": {"type": "integer", "description": "Inference-Steps (Default 28, max 50). Mehr = besser+langsamer."},
                    "guidance_scale": {"type": "number", "description": "Wie strikt am Prompt kleben (Default 3.5)"},
                    "seed": {"type": "integer", "description": "Reproduzierbarkeits-Seed (optional)"},
                },
                "required": ["prompt"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "memory_search",
            "description": (
                "Durchsuche aktiv dein Gedaechtnis (Qdrant-DB). Nutze das wenn:\n"
                "- der User sagt 'schau in deinem Gedaechtnis' / 'ich hab das Memory aktualisiert'\n"
                "- du dir bei einer Info aus dem Konversations-Verlauf unsicher bist "
                "(z.B. ob das noch der aktuelle Stand ist)\n"
                "- du pruefen willst ob's schon einen Memory zu einem Thema gibt bevor "
                "du via memory_save einen neuen anlegst (vermeidet Fragmentierung)\n\n"
                "**WICHTIG: Memory ist Truth ueber dem Conversation-Window.** "
                "Wenn dort was anders steht als in deinem Gespraechs-Verlauf, gilt das "
                "was im Memory steht — der User koennte gerade was korrigiert haben.\n\n"
                "Mode 'text' = Substring (case-insensitive), gut fuer exakte Begriffe "
                "wie 'cessna'. Mode 'semantic' = Embedder-Search, gut fuer 'wann hatten "
                "wir ueber X gesprochen'-Fragen."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Such-Begriff"},
                    "mode": {
                        "type": "string",
                        "enum": ["text", "semantic"],
                        "description": "Default 'text' (Substring). 'semantic' fuer aehnlichkeits-Suche.",
                    },
                    "k": {"type": "integer", "description": "Wieviele Treffer (Default 5, max 20)"},
                },
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "memory_update",
            "description": (
                "Aktualisiere einen existierenden Memory-Eintrag — gibt die ID aus "
                "memory_search oder dem Cold-Memory an. Nur die uebergebenen Felder werden "
                "ueberschrieben, der Rest bleibt unangetastet. **Bevorzuge das ueber "
                "memory_save** wenn der User eine Korrektur macht oder du zusaetzliche "
                "Details zum gleichen Thema hast — vermeidet doppelte Eintraege."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "id": {"type": "string", "description": "Memory-ID (UUID, aus memory_search oder Cold-Memory)"},
                    "title": {"type": "string", "description": "Neuer Titel (optional)"},
                    "content": {"type": "string", "description": "Neuer Content — wird neu embedded fuer Search (optional)"},
                    "category": {"type": "string", "description": "Neue Kategorie (optional)"},
                    "tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
                    "pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
                },
                "required": ["id"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "memory_save",
            "description": (
                "Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
                "Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
                "festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
                "File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
                "Type-Wahl:\n"
                "- identity: ARIAs Selbstbild / Wesensart  (PINNED)\n"
                "- rule: harte Regel / Sicherheit / Werte   (PINNED)\n"
                "- preference: Stefans Vorlieben/Arbeitsweise (PINNED)\n"
                "- tool: Tool-Freigaben / Infrastruktur     (PINNED)\n"
                "- skill: Faehigkeit / Workflow-Anleitung   (PINNED)\n"
                "- fact: Wissen ueber Stefan/Welt/Sachen (Vorlieben, Besitz, Orte, "
                "Termine, Personen). Cold Memory, kommt nur via Semantic Search "
                "rein. **Default fuer 'merk-dir-das'-Anfragen.**\n"
                "- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n"
                "Wenn unsicher: type=fact, pinned=false.\n\n"
                "### Anhaenge\n"
                "`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` "
                "an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt "
                "ein Foto an, du siehst den Pfad in der User-Message).\n\n"
                "**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST "
                "an mit `Read <pfad>` (dein Read-Tool ist multi-modal — es liest Bilder "
                "wie Vision-API). Extrahiere alles Relevante in den content: sichtbare "
                "Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, "
                "auffaellige Details. Dann erst memory_save mit dem extrahierten "
                "content + attach_paths fuer das Bild. So weisst du beim spaeteren "
                "Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n"
                "Beispiel-Workflow:\n"
                "1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n"
                "2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n"
                "3. Du: `memory_save(type='fact', title='Stefans Cessna 172', "
                "content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, "
                "weiss/rot lackiert, vor Hangar fotografiert.', "
                "attach_paths=['/shared/uploads/aria_xy.jpg'])`"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"},
                    "content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"},
                    "type": {
                        "type": "string",
                        "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
                        "description": "Memory-Typ (siehe oben)",
                    },
                    "category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
                    "tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
                    "pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
                    "attach_paths": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments/<id>/ kopiert — Originale bleiben.",
                    },
                },
                "required": ["title", "content", "type"],
            },
        },
    },
    # ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
    {
        "type": "function",
        "function": {
            "name": "project_create",
            "description": (
                "Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
                "Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
                "oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
                "Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
                "spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
                    "description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
                },
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "project_enter",
            "description": (
                "Signalisiert der App/Diagnostic 'wechsel zu diesem Projekt'. Fuzzy-"
                "Match auf Namen — 'Spotify' findet das Projekt 'Spotify-Setup'. "
                "Der AKTUELLE Turn bleibt aber in seinem Chat-Kontext — wir haben "
                "Multi-Threading, kein globales 'aktives Projekt' mehr. Wenn Stefan "
                "im Hauptchat sagt 'lass uns in Spotify weiter machen': "
                "project_enter aufrufen (App wechselt Ansicht), aber Deine Antwort "
                "geht trotzdem im Hauptchat raus. Bei sehr alten Projekten vorher "
                "project_summary aufrufen damit Du Stefan abholst."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
                },
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "project_exit",
            "description": (
                "Signalisiert der App/Diagnostic 'wechsel zurueck zum Hauptchat'. "
                "Nutze wenn Stefan sagt 'Projekt Ende' oder 'zurueck zum Hauptchat' "
                "waehrend er visuell in einem Projekt ist. Der aktuelle Turn bleibt "
                "in seinem Chat-Kontext — Multi-Threading."
            ),
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "project_list",
            "description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "project_summary",
            "description": (
                "Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). "
                "Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit "
                "'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst "
                "halluzinierst Du Inhalte die nicht da sind."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
                },
                "required": ["name"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "project_end",
            "description": (
                "Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
                "(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
                "außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
                "abgeschlossen' o.ä."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Projekt-Name."},
                },
                "required": ["name"],
            },
        },
    },
]


# ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
#
# Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
# von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
# ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
# returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
# 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
#
# Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
# case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
# eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
#
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# skill_create / skill_update, das Brain orchestriert nur.

def _normalize_for_fast_match(text: str) -> str:
    norm = (text or "").strip().lower()
    norm = re.sub(r"[.!?]+$", "", norm)
    norm = re.sub(r"\s+", " ", norm)
    return norm


def _skill_to_tool(s: dict) -> dict:
    """Mappt einen Skill auf ein OpenAI-Function-Tool."""
    args = s.get("args") or []
    props = {}
    required = []
    for a in args:
        if not isinstance(a, dict):
            continue
        name = a.get("name") or ""
        if not name:
            continue
        props[name] = {
            "type": a.get("type", "string"),
            "description": a.get("description", ""),
        }
        if a.get("required"):
            required.append(name)
    # Tool-Namen duerfen in der Anthropic/Claude tool_use-API nur
    # [a-zA-Z0-9_-]{1,64} sein, aber der claude-max-api-proxy (OpenAI-
    # Format-Adapter) ist restriktiver und faellt bei Bindestrichen auf
    # die Nase — die GANZE Tool-Liste wird dann verworfen und ARIA
    # bekommt "No such tool available". Skill-Namen wie 'yt-dlp-download'
    # oder 'pdf-umfrage-generator' muessen daher zu run_yt_dlp_download
    # bzw. run_pdf_umfrage_generator gemappt werden.
    safe_name = "run_" + re.sub(r"[^a-zA-Z0-9_]", "_", s["name"])
    return {
        "type": "function",
        "function": {
            "name": safe_name,
            "description": s.get("description", "(ohne Beschreibung)"),
            "parameters": {
                "type": "object",
                "properties": props,
                "required": required,
            },
        },
    }


class Agent:
    # Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
    # System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
    # Rauschen — die MiniLM-multilingual Embeddings haben fuer "irgendwas
    # vs. irgendwas anderes" gerne mal 0.10-0.20 Score selbst bei voellig
    # unverwandten Inhalten. Mit 0.30 als Untergrenze vermeiden wir
    # Cross-Talk (z.B. 'hab ich ein flugzeug' triggert die Firmenadresse).
    COLD_SCORE_THRESHOLD = 0.30

    def __init__(self, store: VectorStore, embedder: Embedder,
                 conversation: Conversation, proxy: ProxyClient,
                 cold_k: int = 5):
        self.store = store
        self.embedder = embedder
        self.conversation = conversation
        self.proxy = proxy
        self.cold_k = cold_k
        # Side-Channel-Events die im Turn entstehen (z.B. skill_create).
        # Werden vom /chat-Endpoint in der Response mitgeschickt, damit
        # Stefan in der App und Diagnostic eine sichtbare Bubble bekommt.
        self._pending_events: list[dict] = []

    def pop_events(self) -> list[dict]:
        """Holt die Events des letzten chat()-Calls und leert die Liste."""
        events = self._pending_events
        self._pending_events = []
        return events

    def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
        """Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
        gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
        wird direkt aufgerufen, Reply geht ohne Claude zurueck.

        Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
        (ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
        zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
        Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
        durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
        norm = _normalize_for_fast_match(user_message)
        if not norm:
            return None

        active_skills = [s for s in skills_mod.list_skills(active_only=False)
                         if s.get("active", True)]
        for skill in active_skills:
            patterns = skill.get("fast_patterns") or []
            if not patterns:
                continue
            skill_name = skill.get("name") or ""
            for pat in patterns:
                rx = pat.get("match") or ""
                if not rx:
                    continue
                try:
                    if not re.match(rx, norm, re.IGNORECASE):
                        continue
                except re.error:
                    # Sollte durch _normalize_fast_patterns rausgefiltert sein.
                    continue
                args = pat.get("args") or {}
                reply = pat.get("reply") or f"{skill_name}: ok"
                logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
                            skill_name, rx, user_message[:60])
                try:
                    res = skills_mod.run_skill(skill_name, dict(args), timeout_sec=15)
                except Exception as exc:
                    logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
                                   skill_name, exc)
                    return None
                if not res.get("ok"):
                    tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
                    hint = (tail[-1] if tail else "")[:120]
                    return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
                return reply
        return None

    # ── Fast-Lane: lokales schnelles LLM (Plan B, B1a) ──
    #
    # Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns beantwortet
    # das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber
    # /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
    # Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.

    _LOCAL_WINDOW_TURNS = 8  # nur die letzten N Turns ans lokale Modell (Speed)

    def _try_local_fast_lane(self, user_message: str,
                             active_project_id: str) -> Optional[str]:
        cfg = router_mod.load_config()
        if not router_mod.should_try_local(user_message, cfg):
            return None

        sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR)
        # Nur die letzten paar Turns ans lokale Modell — es ist fuer kurze
        # Plauder-Turns da. Volles Fenster (bis 50) wuerde das Prefill aufblaehen
        # und den Speed-Vorteil auffressen (gemessen: 12 Turns → ~2,6s statt ~0,8s).
        window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
        messages = [{"role": "system", "content": sys_prompt}]
        messages += [{"role": t.role, "content": t.content} for t in window]

        res = local_llm_chat(messages, max_tokens=400, temperature=0.5)
        local_only = bool(cfg.get("localOnly"))

        if not res.get("ok"):
            logger.info("[router] lokal fehlgeschlagen (%s) — %s",
                        res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude")
            if local_only:
                # Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille.
                return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
            return None

        content = (res.get("content") or "").strip()

        if local_only:
            # Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen.
            content = content.replace(router_mod.ESCALATE_MARKER, "").strip()
            if not content:
                return "[Lokales LLM lieferte keine Antwort.]"
            logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs"))
            self.conversation.add("assistant", content, project_id=active_project_id)
            return content

        # Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
        # Identity-Break → Claude uebernehmen.
        if (not content or router_mod.ESCALATE_MARKER in content
                or looks_like_identity_break(content)):
            logger.info("[router] lokal eskaliert → Claude")
            return None

        logger.info("[router] lokal beantwortet in %sms (%d Zeichen)",
                    res.get("elapsedMs"), len(content))
        self.conversation.add("assistant", content, project_id=active_project_id)
        return content

    # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──

    MAX_TOOL_ITERATIONS = 8  # Schutz vor Endlos-Loops

    def chat(self, user_message: str, source: str = "",
             project_id: Optional[str] = None,
             pending_queue: Optional[list[str]] = None) -> str:
        """Verarbeitet eine User-Nachricht — pro Request project_id explizit
        angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
        so laufen parallele /chat-Requests fuer verschiedene Projekte echt
        parallel (Multi-Threading-Architektur seit 06/2026).

        pending_queue: Liste weiterer User-Nachrichten die in DIESEM Projekt
        NACH dem aktuellen Turn warten. ARIA sieht sie im System-Prompt und
        soll pruefen ob eine spaetere Nachricht den aktuellen Task
        korrigiert / annuliert (dann Skip-Antwort statt Ausfuehren).

        Wenn project_id=None (Backward-Compat fuer Aufrufer die den Param nicht
        setzen): wird als Hauptchat behandelt."""
        user_message = (user_message or "").strip()
        if not user_message:
            raise ValueError("Leere Nachricht")

        # Events vom letzten Turn weglassen
        self._pending_events = []

        # Projekt-Kontext pro Request statt aus globalem State
        active_project_id = (project_id or "").strip()
        active_project = projects_mod.get_project(active_project_id) if active_project_id else None

        # Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
        # Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
        # iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
        fast_reply = self._try_skill_fast_path(user_message)
        if fast_reply is not None:
            self.conversation.add("user", user_message, source=source,
                                  project_id=active_project_id)
            self.conversation.add("assistant", fast_reply, project_id=active_project_id)
            if active_project_id:
                projects_mod.touch_project(active_project_id)
            return fast_reply

        # 1. User-Turn an die Konversation
        self.conversation.add("user", user_message, source=source,
                              project_id=active_project_id)
        if active_project_id:
            projects_mod.touch_project(active_project_id)

        # Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
        # /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
        # ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
        # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
        local_reply = self._try_local_fast_lane(user_message, active_project_id)
        if local_reply is not None:
            return local_reply

        # 2. Hot Memory (alle pinned Punkte)
        hot = self.store.list_pinned()

        # 3. Cold Memory (Top-K semantic) — mit Score-Threshold gegen Rauschen
        try:
            qvec = self.embedder.embed(user_message)
            cold = self.store.search(
                qvec, k=self.cold_k, exclude_pinned=True,
                score_threshold=self.COLD_SCORE_THRESHOLD,
            )
        except Exception as exc:
            logger.warning("Cold-Search fehlgeschlagen: %s", exc)
            cold = []

        # 4. Aktive Skills holen + Tool-Liste bauen
        all_skills = skills_mod.list_skills(active_only=False)
        active_skills = [s for s in all_skills if s.get("active", True)]
        tools = list(META_TOOLS) + [_skill_to_tool(s) for s in active_skills]

        # Trigger-Liste + Variablen-Info fuer den System-Prompt
        all_triggers = triggers_mod.list_triggers(active_only=False)
        condition_vars = watcher_mod.describe_variables()
        condition_funcs = watcher_mod.describe_functions()

        # 5. System-Prompt + Window-Messages
        flux_config = _load_flux_config()
        # OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
        try:
            oauth_services = oauth_mod.list_services()
        except Exception as exc:
            logger.warning("oauth list_services fehlgeschlagen: %s", exc)
            oauth_services = None
        oauth_host = os.environ.get("RVS_HOST", "").strip()
        oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
        oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"

        system_prompt = build_system_prompt(hot, cold, skills=all_skills,
                                            triggers=all_triggers,
                                            condition_vars=condition_vars,
                                            condition_funcs=condition_funcs,
                                            flux_config=flux_config,
                                            oauth_services=oauth_services,
                                            oauth_callback_host=oauth_host,
                                            oauth_callback_port=oauth_port,
                                            oauth_callback_tls=oauth_tls)
        # Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
        # in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
        # aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
        if pending_queue:
            queue_lines = "\n".join(f"  - {m[:280]}" for m in pending_queue[:5])
            more_hint = ""
            if len(pending_queue) > 5:
                more_hint = f"\n  ... und {len(pending_queue) - 5} weitere"
            system_prompt += (
                f"\n\n## QUEUE — NACH DIESEM TASK WARTEN\n"
                f"{queue_lines}{more_hint}\n"
                f"\nBEVOR DU DEN AKTUELLEN TASK LOESST:\n"
                f"  1. Pruefe die Queue oben — widerspricht/annuliert eine der spaeteren "
                f"Nachrichten den aktuellen Task?\n"
                f"  2. Wenn ja: antworte ganz kurz 'Task ubersprungen — wird durch spaetere "
                f"Nachricht korrigiert' und mach KEINE Aktion. Der spaetere Task laeuft dann "
                f"ganz normal als naechste Anfrage durch.\n"
                f"  3. Wenn nein / unabhaengige Ergaenzung: Task normal loesen.\n"
                f"Beispiel: aktueller Task 'titelleiste rot', Queue enthaelt "
                f"'doch nicht, mach sie blau' → skip, blau kommt als naechste Anfrage."
            )

        # Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
        # weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
        # einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
        if active_project:
            system_prompt += (
                f"\n\n## AKTUELLES PROJEKT\n"
                f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
                f"(id={active_project['id']}). Beschreibung: "
                f"{active_project.get('description', '(keine)')}. "
                f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
                f"Wenn er rauswill, ruf project_exit auf."
            )
        else:
            project_count = len(projects_mod.list_projects())
            if project_count > 0:
                system_prompt += (
                    f"\n\n## PROJEKTE\n"
                    f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
                    f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
                    f"weiterfuehren': project_enter aufrufen."
                )
        messages = [ProxyMessage(role="system", content=system_prompt)]
        # Identitaets-Grounding IM Konversations-Strom (Defense-in-Depth neben
        # dem vollen System-Prompt-Replace via --system-prompt). Ein
        # synthetischer erster ARIA-Turn in ihrer eigenen Stimme haelt die Rolle
        # per Self-Grounding auch bei duennem Verlauf (Hauptchat / erster Turn
        # eines neuen Projekts). Kein <system>-Tag -> kein Injection-Trigger.
        # Rein ephemer — wird NIE persistiert. Siehe IDENTITY_SEED in prompts.py.
        messages.append(ProxyMessage(role="assistant", content=IDENTITY_SEED))
        # Conversation-Window auf das aktive Projekt filtern: in einem Projekt
        # sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
        # nur die nicht-getaggten Turns.
        window = self.conversation.window(project_id=active_project_id)
        for t in window:
            messages.append(ProxyMessage(role=t.role, content=t.content))

        logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
                    len(hot), len(cold), len(active_skills), len(all_skills),
                    len(window), active_project_id or "(main)", len(system_prompt))

        # 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
        # Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
        # ist bereits in der Conversation. Ohne Gegenpart wird die naechste
        # Anfrage im Window an Claude geschickt mit user → user als letzten
        # zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
        # zu 400-Errors fuehren kann.
        final_reply = ""
        try:
            for iteration in range(self.MAX_TOOL_ITERATIONS):
                result = self.proxy.chat_full(messages, tools=tools,
                                              project_id=active_project_id)
                if result.tool_calls:
                    # Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
                    messages.append(ProxyMessage(
                        role="assistant",
                        content=result.content or None,
                        tool_calls=[{
                            "id": tc["id"], "type": "function",
                            "function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
                        } for tc in result.tool_calls],
                    ))
                    # Tools ausfuehren + Ergebnis als role=tool zurueck
                    for tc in result.tool_calls:
                        tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
                        # Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
                        # Skill-Outputs (siehe agent.py weiter unten). 8 KB war
                        # viel zu wenig: Spotify _all=true mit 90 Playlists
                        # liefert ~34 KB compact, das wurde hier auf 8 KB
                        # zugeschnitten und ARIA glaubte die Liste sei
                        # abgeschnitten obwohl der Skill alles korrekt
                        # paginiert hatte. Claude-Context vertraegt locker
                        # 50 KB pro Tool-Result.
                        messages.append(ProxyMessage(
                            role="tool",
                            tool_call_id=tc["id"],
                            name=tc["name"],
                            content=tool_result[:50000],
                        ))
                    continue  # next iteration mit Tool-Results
                # Kein Tool-Call mehr → final reply
                final_reply = (result.content or "").strip()
                break
            else:
                # Loop-Limit erreicht
                final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
                logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)

            if not final_reply:
                raise RuntimeError("Leerer Reply vom Proxy")

        except Exception as exc:
            # Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
            # muss auch rein damit die Paarung stimmt. Wir schreiben einen
            # Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
            # mit der JSONL-Persistenz aufmachen).
            err_text = f"[Fehler: {exc}]"
            logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
            try:
                # Turn-Kontext bleibt gleich — es gibt keinen globalen Wechsel
                # mehr, jeder Request laeuft in seinem eigenen project_id-Kontext.
                self.conversation.add("assistant", err_text,
                                      project_id=active_project_id)
            except Exception as add_exc:
                logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
            raise

        # Gift-Waechter: faellt Claude trotz --system-prompt + Seed aus der Rolle
        # (Identity-Break), NICHT persistieren — sonst vergiftet dieser eine Turn
        # die History und loest bei schwachen Folgeturns eine Kaskade aus (das
        # Modell setzt seine eigene Ablehnung fort). Ein Retry holt per
        # Nondeterminismus meist die ARIA-Antwort; sonst sichere Fallback-Antwort.
        # So kann ein einzelner Ausrutscher nie snowballen.
        if looks_like_identity_break(final_reply):
            logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
            try:
                retry = self.proxy.chat_full(messages, tools=tools,
                                             project_id=active_project_id)
                retry_text = (retry.content or "").strip()
            except Exception as exc:
                logger.warning("[guard] Retry fehlgeschlagen: %s", exc)
                retry_text = ""
            if retry_text and not looks_like_identity_break(retry_text):
                logger.info("[guard] Retry lieferte saubere Antwort")
                final_reply = retry_text
            else:
                logger.warning("[guard] Retry weiter Break/leer — Fallback, Break NICHT persistiert")
                final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
                               "sag mir einfach, was du brauchst.")

        # 7. Assistant-Turn (final reply) in die Conversation
        self.conversation.add("assistant", final_reply,
                              project_id=active_project_id)
        return final_reply

    # ── Tool-Dispatcher ───────────────────────────────────────

    def _dispatch_tool(self, name: str, arguments: dict) -> str:
        """Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
        Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
        weitermachen kann."""
        try:
            if name == "skill_create":
                # ARIA-Skills sind immer Python — execution ist nicht mehr im Schema
                manifest = skills_mod.create_skill(
                    name=arguments["name"],
                    description=arguments["description"],
                    execution="local-venv",
                    entry_code=arguments["entry_code"],
                    readme=arguments.get("readme", ""),
                    args=arguments.get("args", []),
                    pip_packages=arguments.get("pip_packages", []),
                    config_schema=arguments.get("config_schema") or None,
                    fast_patterns=arguments.get("fast_patterns") or None,
                    author="aria",
                )
                # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
                self._pending_events.append({
                    "type": "skill_created",
                    "skill": {
                        "name": manifest["name"],
                        "description": manifest.get("description", ""),
                        "execution": manifest.get("execution", ""),
                        "active": manifest.get("active", True),
                        "setup_error": manifest.get("setup_error"),
                    },
                })
                return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
            if name == "skill_scaffold":
                skill_name = (arguments.get("name") or "").strip()
                template = (arguments.get("template") or "").strip()
                params = arguments.get("params") or {}
                if not skill_name or not template:
                    return "FEHLER: name + template erforderlich."
                try:
                    manifest = skills_mod.scaffold_skill(
                        name=skill_name, template=template, params=params, author="aria",
                    )
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                # Side-Channel-Event analog zu skill_create
                self._pending_events.append({
                    "type": "skill_created",
                    "skill": {
                        "name": manifest["name"],
                        "description": manifest.get("description", ""),
                        "execution": manifest.get("execution", ""),
                        "active": manifest.get("active", True),
                        "setup_error": manifest.get("setup_error"),
                        "scaffolded_from": template,
                    },
                })
                return (
                    f"OK — Skill '{manifest['name']}' aus Template '{template}' angelegt. "
                    f"active={manifest['active']}. "
                    f"Falls noetig: skill_update fuer custom Code, skill_set_config fuer secrets."
                )
            if name == "skill_list":
                items = skills_mod.list_skills(active_only=False)
                if not items:
                    return "(keine Skills vorhanden)"
                return "\n".join(
                    f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
                    for s in items
                )
            if name == "skill_update":
                skill_name = (arguments.get("name") or "").strip()
                if not skill_name:
                    return "FEHLER: name ist Pflicht."
                patch: dict = {}
                for k in ("entry_code", "readme", "description", "args", "active"):
                    if k in arguments and arguments[k] is not None:
                        patch[k] = arguments[k]
                if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
                    patch["pip_packages"] = arguments["pip_packages"]
                if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
                    patch["config_schema"] = arguments["config_schema"]
                if "fast_patterns" in arguments and isinstance(arguments["fast_patterns"], list):
                    patch["fast_patterns"] = arguments["fast_patterns"]
                if not patch:
                    return "FEHLER: keine Felder zum Update angegeben."
                try:
                    manifest = skills_mod.update_skill(skill_name, patch)
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                # Side-Channel-Event als skill_created getarnt — gleiche Bubble-Mechanik
                # in App/Diagnostic; das Update soll fuer Stefan ebenfalls sichtbar werden.
                self._pending_events.append({
                    "type": "skill_created",
                    "skill": {
                        "name": manifest["name"],
                        "description": manifest.get("description", ""),
                        "execution": manifest.get("execution", ""),
                        "active": manifest.get("active", True),
                        "setup_error": manifest.get("setup_error"),
                        "updated": True,
                    },
                })
                changed = ", ".join(sorted(patch.keys()))
                return f"OK — Skill '{skill_name}' aktualisiert ({changed}). active={manifest['active']}"
            if name == "skill_delete":
                skill_name = (arguments.get("name") or "").strip()
                if not skill_name:
                    return "FEHLER: name ist Pflicht."
                try:
                    skills_mod.delete_skill(skill_name)
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                return f"OK — Skill '{skill_name}' geloescht."
            if name == "skill_set_config":
                skill_name = (arguments.get("name") or "").strip()
                values = arguments.get("values")
                if not skill_name or not isinstance(values, dict):
                    return "FEHLER: name + values (dict) erforderlich."
                try:
                    skills_mod.set_skill_config(skill_name, values)
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                masked = skills_mod.get_skill_config_masked(skill_name)
                return (
                    f"OK — Config fuer Skill '{skill_name}' gesetzt. "
                    f"Aktuelle Werte (secrets gemasked): {masked}"
                )
            if name == "skill_list_versions":
                skill_name = (arguments.get("name") or "").strip()
                if not skill_name:
                    return "FEHLER: name ist Pflicht."
                versions = skills_mod.list_skill_versions(skill_name)
                if not versions:
                    return f"Skill '{skill_name}' hat keine archivierten Versionen."
                lines = [
                    f"- {v.get('version_id')}  ({v.get('archived_at','?')})  {v.get('summary','')}"
                    for v in versions
                ]
                return "Versionen (neueste zuerst):\n" + "\n".join(lines)
            if name == "skill_rollback":
                skill_name = (arguments.get("name") or "").strip()
                version_id = (arguments.get("version_id") or "").strip()
                if not skill_name or not version_id:
                    return "FEHLER: name + version_id erforderlich."
                try:
                    res = skills_mod.rollback_skill(skill_name, version_id)
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                # Side-Channel-Event als skill_created getarnt — App/Diagnostic
                # zeigen Rollback dann als sichtbare Aktion an
                self._pending_events.append({
                    "type": "skill_created",
                    "skill": {
                        "name": skill_name,
                        "description": "(rollback)",
                        "execution": "local-venv",
                        "active": True,
                        "updated": True,
                    },
                })
                return (
                    f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
                    f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
                )
            if name.startswith("run_"):
                # Tool-Namen sind 'safe' (nur _), Skill-Namen koennen aber
                # Bindestriche enthalten (z.B. yt-dlp-download). Wir suchen
                # zuerst exakt, dann ueber Underscore-zu-Bindestrich-Mapping.
                tool_suffix = name[len("run_"):]
                skill_name = tool_suffix
                if skills_mod.read_manifest(skill_name) is None:
                    # ggf. Bindestriche zurueckmappen
                    for cand in skills_mod.list_skills(active_only=False):
                        cand_name = cand.get("name") or ""
                        if re.sub(r"[^a-zA-Z0-9_]", "_", cand_name) == tool_suffix:
                            skill_name = cand_name
                            break
                res = skills_mod.run_skill(skill_name, args=arguments)
                # 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
                # da wurde der Track-Name regelmaessig abgeschnitten und ARIA
                # hat aus dem Album-Kontext halluziniert. Claude kann hunderte
                # KB Context, 50 KB pro Tool-Result sind locker drin.
                stdout = (res.get("stdout") or "")
                stderr = (res.get("stderr") or "")
                if len(stdout) > 50000:
                    stdout = stdout[:50000] + f"\n...(abgeschnitten, original {len(res.get('stdout',''))} bytes)"
                if len(stderr) > 4000:
                    stderr = stderr[:4000] + f"\n...(abgeschnitten)"
                snippet = stdout or "(kein stdout)"
                marker = "OK" if res["ok"] else f"FEHLER (exit={res['exit_code']})"
                out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}"
                if stderr:
                    out += f"\nstderr:\n{stderr}"
                return out
            if name == "trigger_timer":
                fires_at_iso = arguments.get("fires_at")
                in_seconds = arguments.get("in_seconds")
                if not fires_at_iso and in_seconds is not None:
                    from datetime import datetime as _dt, timezone as _tz, timedelta as _td
                    try:
                        secs = int(in_seconds)
                    except (TypeError, ValueError):
                        return "FEHLER: in_seconds muss eine ganze Zahl sein."
                    if secs < 1:
                        return "FEHLER: in_seconds muss >= 1 sein."
                    fires_at_iso = (_dt.now(_tz.utc) + _td(seconds=secs)).isoformat(timespec="seconds")
                if not fires_at_iso:
                    return "FEHLER: entweder `in_seconds` ODER `fires_at` muss gesetzt sein."
                t = triggers_mod.create_timer(
                    name=arguments["name"],
                    fires_at_iso=fires_at_iso,
                    message=arguments["message"],
                    author="aria",
                )
                self._pending_events.append({
                    "type": "trigger_created",
                    "trigger": {"name": t["name"], "type": "timer",
                                "fires_at": t["fires_at"], "message": t["message"]},
                })
                return f"OK — Timer '{t['name']}' angelegt, feuert um {t['fires_at']}."
            if name == "trigger_watcher":
                t = triggers_mod.create_watcher(
                    name=arguments["name"],
                    condition=arguments["condition"],
                    message=arguments["message"],
                    check_interval_sec=int(arguments.get("check_interval_sec", 300)),
                    throttle_sec=int(arguments.get("throttle_sec", 3600)),
                    author="aria",
                )
                self._pending_events.append({
                    "type": "trigger_created",
                    "trigger": {"name": t["name"], "type": "watcher",
                                "condition": t["condition"], "message": t["message"]},
                })
                return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
            if name == "trigger_cancel":
                try:
                    triggers_mod.delete(arguments["name"])
                    return f"OK — Trigger '{arguments['name']}' geloescht."
                except ValueError as e:
                    return f"FEHLER: {e}"
            if name == "request_location_tracking":
                on = bool(arguments.get("on", False))
                reason = (arguments.get("reason") or "").strip()
                self._pending_events.append({
                    "type": "location_tracking",
                    "on": on,
                    "reason": reason,
                })
                return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten."
            if name == "trigger_list":
                items = triggers_mod.list_triggers(active_only=False)
                if not items:
                    return "(keine Trigger vorhanden)"
                lines = []
                for t in items:
                    state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
                    if t["type"] == "timer":
                        lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
                    elif t["type"] == "watcher":
                        lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
                    else:
                        lines.append(f"- {t['name']} ({t['type']}, {state})")
                return "\n".join(lines)
            if name == "oauth_register_provider":
                svc = (arguments.get("service") or "").strip()
                auth_url = (arguments.get("auth_url") or "").strip()
                token_url = (arguments.get("token_url") or "").strip()
                scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
                client_auth = (arguments.get("client_auth") or "body").strip().lower()
                if not svc or not auth_url or not token_url:
                    return "FEHLER: service, auth_url, token_url sind Pflicht."
                try:
                    entry = oauth_mod.register_provider(
                        svc, auth_url, token_url, scopes=scopes, client_auth=client_auth,
                    )
                except ValueError as exc:
                    return f"FEHLER: {exc}"
                except Exception as exc:
                    logger.exception("oauth_register_provider fehlgeschlagen")
                    return f"FEHLER: {exc}"
                cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"<RVS_HOST nicht gesetzt>/oauth/callback/{svc}"
                scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)"
                return (
                    f"OK — Provider '{svc}' registriert.\n"
                    f"  auth_url:  {entry['auth_url']}\n"
                    f"  token_url: {entry['token_url']}\n"
                    f"  scopes:    {scopes_str}\n"
                    f"  client_auth: {entry['client_auth']}\n\n"
                    f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > "
                    f"Settings > OAuth-Apps) deine client_id + client_secret aus "
                    f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL "
                    f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n"
                    f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf."
                )
            if name == "oauth_authorize":
                svc = (arguments.get("service") or "").strip()
                if not svc:
                    return "FEHLER: service ist Pflicht (z.B. 'spotify')."
                scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
                try:
                    info = oauth_mod.build_authorize_url(svc, scopes=scopes)
                except RuntimeError as exc:
                    return f"FEHLER: {exc}"
                except Exception as exc:
                    logger.exception("oauth_authorize fehlgeschlagen")
                    return f"FEHLER: {exc}"
                return (
                    f"OK — Authorize-URL fuer {svc} bereit.\n"
                    f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
                    f"{info['url']}\n\n"
                    f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
                    f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
                    f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
                    f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
                    f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
                    f"Redirect-URI eintragen."
                )
            if name == "oauth_get_token":
                svc = (arguments.get("service") or "").strip()
                if not svc:
                    return "FEHLER: service ist Pflicht."
                try:
                    record = oauth_mod.get_token(svc)
                except RuntimeError as exc:
                    return f"FEHLER: {exc}"
                tok = record.get("access_token", "")
                ttype = record.get("token_type", "Bearer")
                exp = record.get("expires_at", 0)
                remain = max(0, int(exp) - int(__import__("time").time()))
                return (
                    f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
                    f"access_token: {tok}\n"
                    f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
                )
            if name == "oauth_revoke":
                svc = (arguments.get("service") or "").strip()
                if not svc:
                    return "FEHLER: service ist Pflicht."
                ok = oauth_mod.revoke(svc)
                return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
            if name == "flux_generate":
                prompt = (arguments.get("prompt") or "").strip()
                if not prompt:
                    return "FEHLER: prompt ist Pflicht."
                req: dict = {"prompt": prompt}
                for key in ("width", "height", "steps", "seed"):
                    if key in arguments and arguments[key] is not None:
                        try:
                            req[key] = int(arguments[key])
                        except (TypeError, ValueError):
                            pass
                if arguments.get("guidance_scale") is not None:
                    try:
                        req["guidance_scale"] = float(arguments["guidance_scale"])
                    except (TypeError, ValueError):
                        pass
                # Modell-Wahl: 'default' (oder weglassen) → flux-bridge nimmt Diagnostic-Default.
                # 'dev' / 'schnell' → expliziter Override.
                model_arg = (arguments.get("model") or "").strip().lower()
                if model_arg in ("dev", "schnell"):
                    req["model"] = model_arg
                # `raw` ist Brain-Domain (kein Rewriting des prompt) und wird hier
                # nicht durchgereicht — der prompt enthaelt bei raw=true bereits
                # Stefans Originaltext.
                try:
                    body = json.dumps(req).encode("utf-8")
                    http_req = urllib.request.Request(
                        f"{BRIDGE_URL}/internal/flux-generate", data=body, method="POST",
                        headers={"Content-Type": "application/json"},
                    )
                    with urllib.request.urlopen(http_req, timeout=FLUX_HTTP_TIMEOUT_SEC) as resp:
                        raw = resp.read()
                        result = json.loads(raw.decode("utf-8", "ignore"))
                except urllib.error.HTTPError as exc:
                    try:
                        err_body = exc.read().decode("utf-8", "ignore")
                        err_data = json.loads(err_body)
                        err = err_data.get("error") or err_body
                    except Exception:
                        err = str(exc)
                    return f"FEHLER (flux-bridge): {err}"
                except Exception as exc:
                    logger.exception("flux_generate HTTP-Call fehlgeschlagen")
                    return f"FEHLER: flux-bridge nicht erreichbar ({exc})"

                if not result.get("ok"):
                    return f"FEHLER (flux-bridge): {result.get('error', 'unbekannt')}"
                # Kompakte Rueckmeldung: Pfad + Render-Stats. Brain bettet den
                # Pfad in ihre Antwort als [FILE: ...]-Marker ein (siehe Tool-Beschreibung).
                return (
                    f"OK — Bild generiert.\n"
                    f"path: {result['path']}\n"
                    f"size: {result.get('width','?')}x{result.get('height','?')} "
                    f"({result.get('sizeBytes',0)//1024} KB)\n"
                    f"steps={result.get('steps','?')} guidance={result.get('guidance','?')} "
                    f"seed={result.get('seed','?')} model={result.get('model','?')}\n"
                    f"renderSeconds={result.get('renderSeconds','?')}\n\n"
                    f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
                    f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
                )
            if name == "memory_search":
                query = (arguments.get("query") or "").strip()
                if not query:
                    return "FEHLER: query ist Pflicht."
                mode = arguments.get("mode") or "text"
                try:
                    k = int(arguments.get("k", 5))
                except (TypeError, ValueError):
                    k = 5
                k = max(1, min(k, 20))
                try:
                    if mode == "semantic":
                        qvec = self.embedder.embed(query)
                        results = self.store.search(
                            qvec, k=k, exclude_pinned=False, score_threshold=0.30,
                        )
                    else:
                        results = self.store.search_text(query, k=k, exclude_pinned=False)
                    if not results:
                        return f"Keine Treffer fuer '{query}' (mode={mode})."
                    lines = [f"{len(results)} Treffer fuer '{query}' (mode={mode}):"]
                    for m in results:
                        score_part = f" [score={m.score:.2f}]" if m.score is not None else ""
                        pin = "📌 " if m.pinned else ""
                        atts = m.attachments or []
                        att_part = f" 📎{len(atts)}" if atts else ""
                        lines.append("")
                        lines.append(f"## {pin}{m.title} ({m.type}){score_part}{att_part}")
                        lines.append(f"id: {m.id}")
                        lines.append(m.content or "")
                        if atts:
                            for a in atts:
                                lines.append(f"  📎 {a.get('name', '?')} ({a.get('mime', '')}) — {a.get('path', '')}")
                    return "\n".join(lines)
                except Exception as e:
                    logger.exception("memory_search fehlgeschlagen")
                    return f"FEHLER: {e}"
            if name == "memory_update":
                pid = (arguments.get("id") or "").strip()
                if not pid:
                    return "FEHLER: id ist Pflicht."
                existing = self.store.get(pid)
                if not existing:
                    return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
                try:
                    from memory.vector_store import COLLECTION
                    import datetime as _dt
                    content_changed = False
                    if "title" in arguments and arguments["title"] is not None:
                        existing.title = str(arguments["title"]).strip()
                    if "content" in arguments and arguments["content"] is not None:
                        new_content = str(arguments["content"]).strip()
                        if new_content != existing.content:
                            content_changed = True
                            existing.content = new_content
                    if "category" in arguments and arguments["category"] is not None:
                        existing.category = str(arguments["category"]).strip()
                    if "tags" in arguments and arguments["tags"] is not None:
                        existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
                    if "pinned" in arguments and arguments["pinned"] is not None:
                        existing.pinned = bool(arguments["pinned"])
                    existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
                    if content_changed:
                        vec = self.embedder.embed(existing.content)
                        self.store.upsert(existing, vec)
                    else:
                        self.store.client.set_payload(
                            collection_name=COLLECTION,
                            payload=existing.to_payload() | {"updated_at": existing.updated_at},
                            points=[pid],
                        )
                    saved = self.store.get(pid)
                    self._pending_events.append({
                        "type": "memory_saved",
                        "action": "updated",
                        "memory": {
                            "id": saved.id, "type": saved.type, "title": saved.title,
                            "content_preview": (saved.content or "")[:140],
                            "category": saved.category, "pinned": saved.pinned,
                            "attachments": saved.attachments or [],
                        },
                    })
                    return f"OK — Memory '{saved.title}' aktualisiert (id={pid[:8]})."
                except Exception as e:
                    logger.exception("memory_update fehlgeschlagen")
                    return f"FEHLER: {e}"
            if name == "memory_save":
                title = (arguments.get("title") or "").strip()
                content = (arguments.get("content") or "").strip()
                mem_type = (arguments.get("type") or "fact").strip()
                if not title or not content:
                    return "FEHLER: title und content sind Pflicht."
                valid_types = {"identity", "rule", "preference", "tool",
                               "skill", "fact", "conversation", "reminder"}
                if mem_type not in valid_types:
                    return f"FEHLER: type muss einer von {sorted(valid_types)} sein."
                category = (arguments.get("category") or "").strip()
                tags_in = arguments.get("tags") or []
                tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
                pinned = bool(arguments.get("pinned", False))
                attach_paths_in = arguments.get("attach_paths") or []
                attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
                try:
                    from memory import MemoryPoint
                    vec = self.embedder.embed(content)
                    point = MemoryPoint(
                        id="", type=mem_type, title=title, content=content,
                        pinned=pinned, category=category, source="aria", tags=tags,
                    )
                    pid = self.store.upsert(point, vec)
                    # Anhaenge kopieren + Payload updaten
                    attach_errors: list[str] = []
                    if attach_paths:
                        import memory_attachments as mem_att
                        new_atts = []
                        for src in attach_paths:
                            try:
                                meta = mem_att.attach_from_path(pid, src)
                                new_atts.append(meta)
                            except ValueError as e:
                                attach_errors.append(f"{src}: {e}")
                        if new_atts:
                            from qdrant_client.http import models as qm
                            from memory.vector_store import COLLECTION
                            import datetime as _dt
                            now = _dt.datetime.now(_dt.timezone.utc).isoformat()
                            current = self.store.get(pid)
                            current.attachments = (current.attachments or []) + new_atts
                            current.updated_at = now
                            self.store.client.set_payload(
                                collection_name=COLLECTION,
                                payload=current.to_payload() | {"updated_at": now},
                                points=[pid],
                            )
                    saved = self.store.get(pid)
                    self._pending_events.append({
                        "type": "memory_saved",
                        "action": "created",
                        "memory": {
                            "id": saved.id, "type": saved.type, "title": saved.title,
                            "content_preview": (saved.content or "")[:140],
                            "category": saved.category, "pinned": saved.pinned,
                            "attachments": saved.attachments or [],
                        },
                    })
                    n_att = len(saved.attachments or [])
                    msg = (f"OK — Memory '{title}' gespeichert "
                           f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}"
                           + (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").")
                    if attach_errors:
                        msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n  - " + "\n  - ".join(attach_errors)
                    return msg
                except Exception as e:
                    logger.exception("memory_save fehlgeschlagen")
                    return f"FEHLER beim Speichern: {e}"
            # ── Projekte ────────────────────────────────────────
            if name == "project_create":
                pname = (arguments.get("name") or "").strip()
                desc = (arguments.get("description") or "").strip()
                if not pname:
                    return "FEHLER: name ist Pflicht."
                try:
                    p = projects_mod.create_project(pname, desc)
                except ValueError as e:
                    return f"FEHLER: {e}"
                self._pending_events.append({
                    "type": "project_changed",
                    "project": p,
                    "action": "created",
                })
                return (f"OK — Projekt '{p['name']}' angelegt (id={p['id']}). App/Diagnostic "
                        f"kriegen ein project_changed-Event und koennen dahin wechseln. "
                        f"Kommender Turn bleibt aber im aktuellen Chat-Kontext — "
                        f"Multi-Threading, jeder Chat ist eigenstaendig.")
            if name == "project_enter":
                pname = (arguments.get("name") or "").strip()
                if not pname:
                    return "FEHLER: name ist Pflicht."
                p = projects_mod.find_project(pname)
                if not p:
                    return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
                self._pending_events.append({
                    "type": "project_changed",
                    "project": p,
                    "action": "entered",
                })
                turn_count = p.get("turn_count", 0)
                hint = ""
                if turn_count > 0:
                    hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
                return (f"OK — App/Diagnostic wird zum Projekt '{p['name']}' "
                        f"(id={p['id']}, {turn_count} bisherige Turns) umschalten. "
                        f"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.{hint}")
            if name == "project_exit":
                self._pending_events.append({
                    "type": "project_changed",
                    "project": None,
                    "action": "exited",
                })
                return ("OK — App/Diagnostic bekommt Signal 'zurueck zum Hauptchat'. "
                        "Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.")
            if name == "project_list":
                items = projects_mod.list_projects()
                if not items:
                    return "(keine Projekte angelegt)"
                lines = []
                for p in items:
                    status_lbl = p.get("status", "active")
                    lines.append(
                        f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
                        f"status={status_lbl})"
                    )
                return "Projekte:\n" + "\n".join(lines)
            if name == "project_summary":
                pname = (arguments.get("name") or "").strip()
                if not pname:
                    return "FEHLER: name ist Pflicht."
                p = projects_mod.find_project(pname)
                if not p:
                    return f"Kein Projekt '{pname}' gefunden."
                # Letzte ~10 Turns des Projekts aus dem Conversation-Log
                turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
                if not turns:
                    return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im "
                            f"aktuellen Conversation-Window stehen noch keine Turns. "
                            f"Beschreibung: {p.get('description', '(keine)')}")
                tail = turns[-12:]
                summary_lines = []
                for t in tail:
                    prefix = "Stefan" if t.role == "user" else "Du"
                    summary_lines.append(f"{prefix}: {t.content[:280]}")
                preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n"
                            f"Letzte {len(tail)} Turns:\n")
                return preamble + "\n".join(summary_lines)
            if name == "project_end":
                pname = (arguments.get("name") or "").strip()
                if not pname:
                    return "FEHLER: name ist Pflicht."
                p = projects_mod.find_project(pname)
                if not p:
                    return f"Kein Projekt '{pname}' gefunden."
                projects_mod.end_project(p["id"])
                self._pending_events.append({
                    "type": "project_changed",
                    "project": projects_mod.get_project(p["id"]),
                    "action": "ended",
                })
                return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
            return f"Unbekanntes Tool: {name}"
        except Exception as exc:
            logger.exception("Tool '%s' fehlgeschlagen", name)
            return f"FEHLER: {exc}"

    # ── Memory-Destillat (laeuft im Hintergrund) ──────────────

    def distill_old_turns(self) -> dict:
        """Nimmt die N aeltesten Turns und destilliert sie zu fact-Memories.

        Pattern: separater Claude-Call, lieferte 3-7 JSON-Facts, die als
        type=fact, source=distilled gespeichert werden. Erfolgreiches
        Schreiben → Turns aus dem Window entfernen.
        """
        if not self.conversation.needs_distill():
            return {"distilled": 0, "reason": "kein Bedarf"}

        old_turns = self.conversation.take_oldest_for_distill()
        if not old_turns:
            return {"distilled": 0, "reason": "keine alten Turns"}

        # Konversation als Klartext bauen
        transcript = "\n".join(
            f"[{t.role.upper()}] {t.content}" for t in old_turns
        )[:30000]  # Cap auf 30k Zeichen damit der Prompt nicht explodiert

        system = (
            "Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
            "wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, "
            "keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON "
            "im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
            "\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
            "Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array."
        )
        user = (
            "Hier ist der Konversations-Abschnitt:\n\n"
            f"{transcript}\n\n"
            "Extrahiere die wichtigsten Fakten als JSON."
        )

        try:
            raw = self.proxy.chat([
                ProxyMessage(role="system", content=system),
                ProxyMessage(role="user", content=user),
            ])
        except Exception as exc:
            logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
            return {"distilled": 0, "error": str(exc)}

        facts = self._parse_facts(raw)
        if facts is None:
            logger.warning("Destillat lieferte unparsbares JSON: %r", raw[:200])
            return {"distilled": 0, "error": "JSON parse failed", "raw": raw[:200]}

        # Facts in die DB schreiben
        created = 0
        for f in facts:
            content = (f.get("content") or "").strip()
            if not content:
                continue
            title = (f.get("title") or "").strip()[:120] or "Fakt"
            point = MemoryPoint(
                id="",
                type="fact",
                title=title,
                content=content,
                pinned=False,
                category="konversation",
                source="distilled",
                tags=[],
            )
            try:
                vec = self.embedder.embed(content)
                self.store.upsert(point, vec)
                created += 1
            except Exception as exc:
                logger.warning("Fakt schreiben fehlgeschlagen: %s", exc)

        # Erst nach erfolgreichem Schreiben aus dem Window entfernen
        last_ts = old_turns[-1].ts
        self.conversation.commit_distill(last_ts)
        logger.info("Destillat: %d Facts geschrieben, %d Turns aus Window entfernt",
                    created, len(old_turns))
        return {"distilled": created, "removed_turns": len(old_turns)}

    @staticmethod
    def _parse_facts(raw: str) -> Optional[list]:
        if not raw:
            return None
        # JSON robust extrahieren — Claude kann Code-Fences setzen
        cleaned = raw.strip()
        if cleaned.startswith("```"):
            # ```json oder ``` rauswerfen
            cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
            if cleaned.endswith("```"):
                cleaned = cleaned[: -3]
            cleaned = cleaned.strip()
        # Erstes { bis letztes }
        start = cleaned.find("{")
        end = cleaned.rfind("}")
        if start == -1 or end == -1 or end < start:
            return None
        try:
            obj = json.loads(cleaned[start: end + 1])
        except Exception:
            return None
        facts = obj.get("facts") if isinstance(obj, dict) else None
        if not isinstance(facts, list):
            return None
        return facts
