Files
ARIA-AGENT/aria-brain/agent.py
T
duffyduckandClaude Opus 4.8 c3bb7985a2 fix(skills): skill_get-Tool nachgereicht — kein Blind-Rewrite mehr
Skandal-Ursache: die skill_update-Anleitung sagt "erst skill_get lesen, dann
updaten" — aber skill_get existierte GAR NICHT als Tool. ARIA/Claude konnte
einen Skill also nie LESEN vor dem Aendern → Blind-Rewrite aus Beschreibung +
Gedaechtnis. (Claude sagte korrekt "skill_get steht mir nicht zur Verfuegung"
und hat den Spotify-Skill trotzdem komplett neu geschrieben.)

Neu: skill_get(name) liefert Manifest (args/fast_patterns/speak/converse) +
kompletten entry_code (echter Python) + README. skills.read_skill_source().
So sieht ARIA den Ist-Code und aendert gezielt statt blind zu ueberschreiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:04:37 +02:00

2594 lines
135 KiB
Python

"""
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.parse
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
import metrics
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")
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# 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__)
def _web_search(query: str, max_results: int = 5) -> str:
"""Fragt die self-hosted SearXNG-Instanz (JSON-API) und gibt die Top-Treffer
als kompakten Text zurueck (Titel + Snippet + URL). Nie werfen — Fehler als
Text-Resultat, damit der Tool-Loop weitermachen kann."""
try:
params = urllib.parse.urlencode({
"q": query, "format": "json", "language": "de", "safesearch": "0",
})
req = urllib.request.Request(
f"{SEARXNG_URL}/search?{params}",
headers={"User-Agent": "ARIA/1.0", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.warning("web_search (SearXNG) fehlgeschlagen: %s", exc)
return f"FEHLER: Websuche nicht verfuegbar ({exc})."
results = (data.get("results") or [])[:max_results]
if not results:
answers = data.get("answers") or []
if answers:
return "Direkte Antwort: " + " | ".join(str(a) for a in answers[:3])
return f"Keine Web-Treffer fuer '{query}'."
lines = [f"{len(results)} Web-Treffer fuer '{query}':"]
for i, r in enumerate(results, 1):
title = (r.get("title") or "").strip()
url = (r.get("url") or "").strip()
snippet = (r.get("content") or "").strip()
lines.append(f"\n{i}. {title}\n {snippet[:300]}\n Quelle: {url}")
return "\n".join(lines)
# web_search ist bewusst LOCAL-ONLY (nicht in META_TOOLS): Claude hat seine
# eigenen, staerkeren Web-Tools (WebSearch + WebFetch/Voll-Seiten-lesen + Bash).
# SearXNG ist fuer das lokale Tier, das sonst gar keinen Netz-Zugriff haette.
# _dispatch_tool behandelt "web_search" trotzdem generisch (Name-Match).
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": (
"Durchsuche das Web (via SearXNG) nach AKTUELLEN, nachschlagbaren "
"Infos: Wetter, News, Fakten, Oeffnungszeiten, Preise, Definitionen. "
"Nutze das, wenn die Antwort aktuelles Wissen braucht, das nicht im "
"Gedaechtnis steht. Praezise Suchanfrage (Suchmaschinen-Stil). "
"Ergebnis = Titel + Snippet + URL; fasse daraus knapp zusammen."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchanfrage (praezise)"},
"max_results": {"type": "integer", "description": "Anzahl Treffer (Default 5, max 10)"},
},
"required": ["query"],
},
},
}
# 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"
"SEMANTISCH BAUEN (wichtig fuer Zuverlaessigkeit!): Ein Skill soll "
"KLARE Operationen als args anbieten, NICHT die rohe API "
"durchreichen. Schlecht: args = {path, method, body} — dann muss das "
"aufrufende LLM die ganze fremde API selbst kennen und baut bei "
"komplexen Faellen (z.B. Geraete-Transfer) falsche Calls. Gut: args "
"= {action: 'play'|'pause'|'next'|'play_on_device', device_name?: str} "
"— der Skill-Code loest intern auf (Geraet-Name → ID, richtiger "
"Endpoint) und kapselt die API. Faustregel: Was das LLM sonst RATEN "
"muesste (Endpunkte, IDs, Payload-Struktur), gehoert INS Skill. Das "
"`args`-Schema ist die Bedienungsanleitung, die das LLM sieht — mach "
"sie semantisch und selbsterklaerend.\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}]. Bei match (anchored Regex, "
"case-insensitive, gegen den normalisierten Text: lowercase, "
"Endsatzzeichen weg) ruft das Brain run_skill(name, args) DIREKT "
"auf und gibt reply zurueck — ohne LLM (instant).\n\n"
"ROBUST SCHREIBEN (wichtig!):\n"
"- Immer ^...$ (nur ganze Befehle, keine Teilstrings).\n"
"- **Wortstellung + Synonyme + Fuellwoerter abdecken** in EINEM "
"Pattern via Alternativen und optionalen Gruppen. Nicht nur die "
"eine Formulierung! Beispiel Pause: "
"`^(spotify |musik )?(pause|pausier(e|en)?|stop|stopp|halt|"
"anhalten)( mal| bitte| spotify| die musik)?$` faengt 'pause', "
"'pause spotify', 'stopp mal', 'musik anhalten' … alle ab.\n"
"- Optionale Fuellwoerter mit `( bitte| mal| doch)?` zulassen, "
"Artikel/Objekte mit `( das lied| den song| die musik)?`.\n\n"
"Aber KEIN Zwang zur Vollstaendigkeit: Fast-Paths sind nur fuer "
"die HAEUFIGSTEN exakten Befehle (instant). Seltene/ungewoehnliche "
"Formulierungen faengt das lokale LLM ohnehin schnell ab (es hat "
"das Tool) — also lieber ein paar solide, breite Patterns als 30 "
"enge. NICHT fuer Skills mit kreativem/parametrisiertem Output. "
"Beispiel: [{\"match\":\"^(spotify |musik )?(pause|stop|stopp)"
"( mal| bitte)?$\",\"args\":{\"path\":\"/v1/me/player/pause\","
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
),
},
"speak": {
"type": "boolean",
"description": (
"STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
"Default false.\n\n"
"WARUM es das gibt: ARIA ist voice-first. Ein reiner "
"STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
"vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
"Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
"eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
"'was laeuft gerade'). Also: Kommando-Skill=false, "
"Antwort-Skill=true.\n\n"
"PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
"beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
"vorlesen), kann dein run.py im JSON-Output pro Aufruf "
"`speak` (und `converse`, s.u.) setzen — das ueberschreibt "
"diesen statischen Default. Beispiel-Output:\n"
" next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n"
" was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false,"
"\"reply\":\"Laeuft: …\"}"
),
},
"converse": {
"type": "boolean",
"description": (
"Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN "
"(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n"
"WARUM getrennt von speak: 'vorlesen' und 'weiterreden "
"koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' "
"soll vorgelesen werden (speak=true), aber es ist eine "
"abgeschlossene Einzel-Info — Stefan will danach NICHT ins "
"30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur "
"wenn eine echte Rueckfrage/ein Dialog sinnvoll ist "
"(converse=true). Am besten pro Aufruf im run.py-Output "
"setzen (dynamisch), nicht statisch."
),
},
},
"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_get",
"description": (
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
"args, fast_patterns, speak/converse) + kompletter entry_code "
"(der echte Python-Code) + README. IMMER vor `skill_update` "
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
"leicht funktionierende Teile!)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name."},
},
"required": ["name"],
},
},
},
{
"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."
),
},
"speak": {
"type": "boolean",
"description": (
"Statischer Vorlese-Default (siehe skill_create). false = "
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro "
"Aufruf via run.py-JSON-Output ueberschreibbar."
),
},
"converse": {
"type": "boolean",
"description": (
"Statischer Default: nach der Antwort 30s weiterlauschen "
"(Dialog=true) oder direkt zurueck aufs Wake-Word (false, "
"Default). Siehe skill_create. Pro Aufruf via Output "
"ueberschreibbar."
),
},
},
"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 _strip_leading_hint_blocks(text: str) -> str:
"""Entfernt fuehrende Hint-Bloecke `[ ... ]`, die die Bridge an
Voice-Nachrichten haengt (GPS-Position, Barge-In-Hinweis). Ohne das matcht
KEIN Voice-Befehl je den Fast-Path (Regex ist ^...$-verankert) — selbst
'nächstes lied' landete unnoetig bei Claude statt beim 0-Token-Fast-Path."""
s = (text or "").strip()
prev = None
while prev != s:
prev = s
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
return s
def _fold_umlauts(s: str) -> str:
"""ä→ae, ö→oe, ü→ue, ß→ss (lowercase erwartet). Whisper liefert echte
Umlaute ('Nächstes Lied'), viele Skill-fast_patterns sind aber in ae/oe/ue
geschrieben — ohne Faltung matcht das nie."""
return (s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("ß", "ss"))
def _looks_like_skill_action(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Steuerbefehl-Quittung (v.a. Spotify) im Reply.
Genutzt fuer den Anti-Halluzinations-Guard: sagt local sowas, hat aber KEIN
Werkzeug gerufen, ist real nichts passiert → eskalieren. Bewusst eng, um
normale Konversation ueber Musik nicht zu treffen."""
t = (text or "").strip().lower()
if not t:
return False
if t.startswith("spotify:") or t.startswith("spotify -") or t.startswith("musik:"):
return True
if "playlist" in t and ("abspiel" in t or "spiele" in t or "starte" in t):
return True
if ("ueberspring" in t or "überspring" in t) and ("titel" in t or "lied" in t or "song" in t):
return True
return False
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
# Interne Satzzeichen raus, die Whisper einstreut ('Nächstes Lied, bitte.').
# Kommas/Semikola/Doppelpunkte → Space, Punkt/!/? ganz weg. Sonst matcht das
# ^...$-verankerte fast_pattern nie, weil das Komma dazwischenfunkt.
norm = re.sub(r"[,;:]", " ", norm)
norm = re.sub(r"[.!?]+", "", norm)
norm = re.sub(r"\s+", " ", norm).strip()
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
# Pattern GLEICH falten wie den Text — egal ob der Skill-Autor
# 'naechstes' oder 'nächstes' geschrieben hat.
rx = _fold_umlauts(rx)
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"
# Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
# Skill darf ein Dialog-Skill sein.
self._fast_path_speak = bool(skill.get("speak", False))
self._fast_path_converse = bool(skill.get("converse", False))
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'}"
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
try:
_j = json.loads((res.get("stdout") or "").strip())
if isinstance(_j, dict):
if isinstance(_j.get("speak"), bool):
self._fast_path_speak = _j["speak"]
if isinstance(_j.get("converse"), bool):
self._fast_path_converse = _j["converse"]
except Exception:
pass
return reply
return None
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
#
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
# lokale Qwen in <1 s. Seit B1b mit kuratierten Tools (web_search,
# memory_search, trigger_timer, Spotify). 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)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
# menschenlesbaren Fehler auf stdout aus und beenden sich TROTZDEM mit Exit 0
# (z.B. Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Der
# harte FEHLER-Prefix (Exit != 0) faengt das nicht. Fuer run_*-Skills werten
# wir daher auch solche Marker als Fehlschlag → eskalieren an Claude, der
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
"exception", "traceback",
)
def _local_tool_failed(self, tool_name: str, tresult: str) -> bool:
"""True wenn ein lokaler Tool-Call als gescheitert gilt (→ Claude)."""
s = (tresult or "").strip()
if not s:
return False
if s.startswith("FEHLER"): # harter Exit-Code-Fehler
return True
if tool_name.startswith("run_"): # Action-Skill: auch weiche Fehler
low = s.lower()
return any(mk in low for mk in self._SOFT_FAIL_MARKERS)
return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
def _build_local_tools(self) -> list:
tools = [WEB_SEARCH_TOOL]
tools += [t for t in META_TOOLS
if t.get("function", {}).get("name") in self._LOCAL_TOOL_NAMES]
for s in skills_mod.list_skills(active_only=False):
if s.get("name") == "spotify" and s.get("active", True):
tools.append(_skill_to_tool(s))
break
return tools
def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
"""run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
if not tname.startswith("run_"):
return None
suffix = tname[len("run_"):]
m = skills_mod.read_manifest(suffix)
if m is None:
for cand in skills_mod.list_skills(active_only=False):
cn = cand.get("name") or ""
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
m = cand
break
return m
def _skill_speak_flag(self, tname: str) -> bool:
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False)."""
if not tname.startswith("run_"):
return True
return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False))
def _skill_response_flags(self, tname: str) -> tuple:
"""(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill.
Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) >
Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich
sein — 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop."""
flags = getattr(self, "_last_skill_flags", None) or {}
m = self._resolve_skill_manifest(tname) or {}
speak = bool(m.get("speak", False))
converse = bool(m.get("converse", False))
if isinstance(flags.get("speak"), bool):
speak = flags["speak"]
if isinstance(flags.get("converse"), bool):
converse = flags["converse"]
return speak, converse
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
# Vorlesen ja/nein fuer diesen lokalen Turn. Default True (Info-Antwort).
# Fuehrt local einen Skill (run_*) aus, uebernimmt dessen speak-Flag aus
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
self._local_turn_speak = True
# Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via
# web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default
# False = Einzelaktion).
self._local_turn_converse = True
# Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
# Bestaetigung durchzulassen.
self._local_ran_skill = False
local_only = bool(cfg.get("localOnly"))
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# wuerde das Prefill aufblaehen).
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]
# Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
# Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet.
final = ""
for _ in range(self._LOCAL_TOOL_ITERATIONS):
res = local_llm_chat(messages, max_tokens=500, temperature=0.5,
tools=tools, model=local_model)
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:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None
# Metric: dieser lokale Call (echte usage-Tokens wenn der Adapter sie
# liefert). Erfasst pro Tool-Runde — mehrere Runden = mehrere Calls.
try:
metrics.log_local_call(res.get("model") or local_model, messages,
res.get("content") or "", res.get("usage"))
except Exception:
pass
tcs = res.get("tool_calls")
if tcs:
messages.append({"role": "assistant",
"content": res.get("content") or "",
"tool_calls": tcs})
had_error = False
for tc in tcs:
fn = tc.get("function") or {}
tname = fn.get("name") or ""
try:
targs = json.loads(fn.get("arguments") or "{}")
except Exception:
targs = {}
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs)
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
self._local_turn_speak, self._local_turn_converse = \
self._skill_response_flags(tname)
self._local_ran_skill = True
if self._local_tool_failed(tname, tresult):
had_error = True
messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "",
"name": tname,
"content": (tresult or "")[:6000]})
# GENERAL (kein per-Skill-Code): scheitert ein Tool-Call, macht
# das grosse Modell weiter — es baut komplexe/rohe API-Calls
# zuverlaessiger und behandelt Fehler besser. So muss der Router
# NICHT wissen, welche Skill-Aufrufe "schwer" sind; das lokale
# Tier probiert, und bei Fehler uebernimmt Claude.
if had_error and not local_only:
logger.info("[router] lokaler Tool-Fehler → Claude uebernimmt")
return None
continue # naechste Runde mit Tool-Ergebnissen
final = (res.get("content") or "").strip()
break
else:
logger.info("[router] lokal Tool-Loop-Limit → %s",
"Fallback (localOnly)" if local_only else "Claude")
if not local_only:
return None
final = final or "[Lokales LLM: Tool-Loop-Limit erreicht.]"
# Sicherheitsnetz: das lokale Modell wickelt seine Antwort manchmal
# faelschlich komplett in <voice>...</voice> (aus dem Kontext imitiert).
# Das wuerde die Anzeige leeren (Display strippt <voice>). Tags raus —
# der lokale Reply ist kurz, Plain-Text dient Anzeige UND TTS.
final = re.sub(r"</?voice>", "", final).strip()
if local_only:
final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
if not final:
return "[Lokales LLM lieferte keine Antwort.]"
logger.info("[router] lokal (localOnly) beantwortet")
self.conversation.add("assistant", final, project_id=active_project_id)
return final
# Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
# Identity-Break → Claude uebernehmen.
if (not final or router_mod.ESCALATE_MARKER in final
or looks_like_identity_break(final)):
logger.info("[router] lokal eskaliert → Claude")
return None
# Anti-Halluzination: local behauptet manchmal eine Musik-/Skill-Aktion
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
if not self._local_ran_skill and _looks_like_skill_action(final):
logger.info("[router] lokal: Skill-Aktion behauptet ohne Tool-Call → Claude")
return None
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
self.conversation.add("assistant", final, project_id=active_project_id)
return final
# ── 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) -> tuple:
"""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)
# Metric: Fast-Path spart einen ganzen Claude-Call zum Nulltarif.
try:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
return fast_reply, "fast-path", speak, converse
# 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:
# speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# 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 = ""
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
# Konversation bleiben gesprochen.
self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
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"])
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or ""
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
self._claude_turn_speak, self._claude_turn_converse = \
self._skill_response_flags(_tn)
# 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)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
# ── 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,
speak=bool(arguments.get("speak", False)),
converse=bool(arguments.get("converse", False)),
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_get":
sk_name = (arguments.get("name") or "").strip()
if not sk_name:
return "FEHLER: name ist Pflicht."
src = skills_mod.read_skill_source(sk_name)
if src is None:
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
m = src["manifest"]
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
meta = {
"name": m.get("name"), "description": m.get("description"),
"execution": m.get("execution"), "entry": m.get("entry"),
"active": m.get("active"), "args": m.get("args"),
"requires": m.get("requires"),
"config_schema": m.get("config_schema"),
"fast_patterns": m.get("fast_patterns"),
"speak": m.get("speak"), "converse": m.get("converse"),
}
return (
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
f"README:\n{src.get('readme') or '(leer)'}"
)
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 "speak" in arguments and arguments["speak"] is not None:
patch["speak"] = bool(arguments["speak"])
if "converse" in arguments and arguments["converse"] is not None:
patch["converse"] = bool(arguments["converse"])
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)
# Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer
# DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path)
# liest sie und steuert damit Vorlesen (speak) + 30s-Weiter-
# lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht
# gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False.
self._last_skill_flags = None
if res.get("ok"):
try:
_j = json.loads((res.get("stdout") or "").strip())
self._last_skill_flags = _j if isinstance(_j, dict) else None
except Exception:
self._last_skill_flags = None
# 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 == "web_search":
query = (arguments.get("query") or "").strip()
if not query:
return "FEHLER: query ist Pflicht."
try:
n = int(arguments.get("max_results", 5))
except (TypeError, ValueError):
n = 5
return _web_search(query, max(1, min(n, 10)))
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