Files
ARIA-AGENT/aria-brain/main.py
T
duffyduckandClaude Opus 4.8 93401d42d1 feat(voice): Wake-Word per Sprachbefehl ausschalten
"Computer, Wake-Word aus" / "mach das Ohr aus" / "hör auf zuzuhören" / "geh
schlafen" → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte
Ruhe). Wieder-An nur über den Ohr-Button (bewusst, weil dann taub — Voice-
Wieder-An ist physikalisch unmöglich, wenn nichts mehr hört).

Deterministischer Detektor _user_wants_wake_off im Brain (kein LLM-Call nötig,
still). Signal fließt als wake_off durch ChatOut → Bridge → App-Payload; die App
löst denselben Pfad wie toggleWakeWord-off aus (cancelRecording +
wakeWordService.stop() + setWakeWordActive(false)). Detektor gegen 13 Positiv-
+ 9 Negativ-Fällen verifiziert (fängt nicht 'Licht aus' / 'Konversation Ende').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:27:48 +02:00

1658 lines
60 KiB
Python

"""
ARIA Brain — FastAPI-Einstieg.
Phase B Punkt 1: nur Skeleton.
- /health → Liveness
- /memory/list → alle Punkte (gefiltert)
- /memory/pinned → Hot Memory
- /memory/search?q=...&k=5 → semantische Suche
- /memory/save → neuen Punkt anlegen
- /memory/update/{id} → Punkt aendern (re-embed wenn content geaendert)
- /memory/delete/{id} → Punkt loeschen
- /memory/stats → Anzahl Punkte pro Type
/chat (Conversation-Loop) und /skills/* kommen in spaeteren Phasen.
"""
from __future__ import annotations
import logging
import os
from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel, Field
from memory import Embedder, VectorStore, MemoryPoint
from conversation import Conversation
from proxy_client import ProxyClient
from agent import Agent
import skills as skills_mod
import metrics as metrics_mod
import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
import project_vms as project_vms_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
def _seed_spotify_fast_patterns() -> None:
"""One-shot Migration: schreibt Standard-Steuer-Patterns ins Spotify-Skill
wenn das Skill existiert + aktiv ist + noch keine fast_patterns hat.
Nach diesem Run kann ARIA die Patterns frei via skill_update aendern."""
manifest = skills_mod.read_manifest("spotify")
if not manifest:
logger.info("[migrate] spotify skill nicht vorhanden — nichts zu tun")
return
if manifest.get("fast_patterns"):
logger.info("[migrate] spotify hat schon fast_patterns (%d) — skip",
len(manifest["fast_patterns"]))
return
default_patterns = [
# NEXT
{"match": r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$",
"args": {"path": "/v1/me/player/next", "method": "POST"},
"reply": "Spotify: nächster Track ⏭"},
{"match": r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$",
"args": {"path": "/v1/me/player/next", "method": "POST"},
"reply": "Spotify: nächster Track ⏭"},
# PREVIOUS
{"match": r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$",
"args": {"path": "/v1/me/player/previous", "method": "POST"},
"reply": "Spotify: vorheriger Track ⏮"},
{"match": r"^(zurueck|zurück)$",
"args": {"path": "/v1/me/player/previous", "method": "POST"},
"reply": "Spotify: vorheriger Track ⏮"},
# PAUSE
{"match": r"^(pause|pausiere|pausieren|stop|stopp|halt)$",
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
"reply": "Spotify: pausiert ⏸"},
{"match": r"^(musik|spotify) (pause|aus|stop|stopp)$",
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
"reply": "Spotify: pausiert ⏸"},
# PLAY
{"match": r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$",
"args": {"path": "/v1/me/player/play", "method": "PUT"},
"reply": "Spotify: spielt ▶"},
{"match": r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$",
"args": {"path": "/v1/me/player/play", "method": "PUT"},
"reply": "Spotify: spielt ▶"},
]
skills_mod.update_skill("spotify", {"fast_patterns": default_patterns})
logger.info("[migrate] spotify fast_patterns gesetzt (%d Eintraege)",
len(default_patterns))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
Trigger-Background-Loop anwerfen. Beim Shutdown: Loop stoppen."""
try:
result = seed_rules_mod.apply(store(), embedder())
logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
# Einmalige Migration: Spotify-Skill ohne fast_patterns kriegt die Standard-
# Patterns injiziert. Idempotent — wenn schon welche da sind, nichts tun.
# ARIA kann sie spaeter via skill_update beliebig erweitern/ersetzen.
try:
_seed_spotify_fast_patterns()
except Exception as exc:
logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc)
# Einmalige Migration: project_id aus conversation.jsonl nach chat_backup.jsonl
# zurueckschreiben, damit alt-getaggte Projekt-Nachrichten (getaggt bevor
# chat_backup project_id fuehrte) in der UI wieder im richtigen Projekt
# landen. Idempotent (Marker), nicht-destruktiv (.bak), atomar.
try:
import migrate_backfill_projectid
res = migrate_backfill_projectid.run()
logger.info("Lifespan: chat_backup project_id Backfill: %s", res)
except Exception as exc:
logger.warning("Lifespan: project_id Backfill Migration: %s", exc)
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Lifespan: Trigger-Loop gestoppt")
app = FastAPI(title="ARIA Brain", version="0.1.0", lifespan=lifespan)
_embedder: Optional[Embedder] = None
_store: Optional[VectorStore] = None
_conversation: Optional[Conversation] = None
_proxy: Optional[ProxyClient] = None
_agent: Optional[Agent] = None
def embedder() -> Embedder:
global _embedder
if _embedder is None:
_embedder = Embedder()
return _embedder
def store() -> VectorStore:
global _store
if _store is None:
_store = VectorStore(host=QDRANT_HOST, port=QDRANT_PORT)
return _store
def conversation() -> Conversation:
global _conversation
if _conversation is None:
_conversation = Conversation()
return _conversation
def proxy_client() -> ProxyClient:
global _proxy
if _proxy is None:
_proxy = ProxyClient()
return _proxy
def agent() -> Agent:
global _agent
if _agent is None:
_agent = Agent(store(), embedder(), conversation(), proxy_client())
return _agent
# ─── Pydantic-Schemas ─────────────────────────────────────────────────
class MemoryIn(BaseModel):
type: str = Field(..., description="identity|rule|preference|tool|skill|fact|conversation|reminder")
title: str
content: str
pinned: bool = False
category: str = ""
source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
# nach dem Save via /memory/{id}/attachments hinzugefuegt — hier eher fuer
# Bootstrap-Import/Restore-Faelle relevant).
attachments: List[dict] = Field(default_factory=list)
class MemoryUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
pinned: Optional[bool] = None
category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None
class MemoryOut(BaseModel):
id: str
type: str
title: str
content: str
pinned: bool
category: str
source: str
scope: str = "personal"
tags: List[str]
created_at: str
updated_at: str
conversation_id: Optional[str] = None
score: Optional[float] = None
attachments: List[dict] = Field(default_factory=list)
@classmethod
def from_point(cls, p: MemoryPoint) -> "MemoryOut":
return cls(**p.__dict__)
class AttachmentUploadBody(BaseModel):
"""Base64-Upload via JSON — Diagnostic schickt Files so."""
name: str
data_base64: str
# ─── Health ───────────────────────────────────────────────────────────
@app.get("/health")
def health():
try:
n = store().count()
return {"status": "ok", "memory_count": n, "qdrant": f"{QDRANT_HOST}:{QDRANT_PORT}"}
except Exception as exc:
return {"status": "degraded", "error": str(exc), "qdrant": f"{QDRANT_HOST}:{QDRANT_PORT}"}
# ─── Memory-Endpoints ─────────────────────────────────────────────────
@app.get("/memory/get/{point_id}", response_model=MemoryOut)
def memory_get(point_id: str):
"""Einzelner Memory mit allen Feldern (inkl. Anhaengen).
Pfad-Prefix /memory/get/ vermeidet Konflikt mit /memory/list, /memory/save etc."""
m = store().get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
return MemoryOut.from_point(m)
@app.get("/memory/stats")
def memory_stats():
s = store()
points = s.list_all()
by_type = {}
pinned = 0
for p in points:
by_type[p.type] = by_type.get(p.type, 0) + 1
if p.pinned:
pinned += 1
return {"total": len(points), "pinned": pinned, "by_type": by_type}
@app.get("/memory/list", response_model=List[MemoryOut])
def memory_list(type: Optional[str] = None, limit: int = 200):
s = store()
points = s.list_by_type(type, limit=limit) if type else s.list_all(limit=limit)
return [MemoryOut.from_point(p) for p in points]
@app.get("/memory/pinned", response_model=List[MemoryOut])
def memory_pinned():
return [MemoryOut.from_point(p) for p in store().list_pinned()]
@app.get("/memory/search-text", response_model=List[MemoryOut])
def memory_search_text(
q: str,
k: int = 50,
type: Optional[str] = None,
include_pinned: bool = True,
):
"""Volltext-Substring-Suche (case-insensitive) ueber Title + Content +
Category + Tags. Findet exakte Begriffe — z.B. 'auto' matched 'Stefans Auto'.
Im Gegensatz zu /memory/search (semantic) keine 'klingt aehnlich'-Treffer."""
points = store().search_text(
q, k=k, type_filter=type,
exclude_pinned=not include_pinned,
)
return [MemoryOut.from_point(p) for p in points]
@app.get("/memory/search", response_model=List[MemoryOut])
def memory_search(
q: str,
k: int = 5,
type: Optional[str] = None,
include_pinned: bool = False,
score_threshold: Optional[float] = 0.30,
):
"""Semantische Suche. score_threshold filtert schwache Treffer raus
(Default 0.30 — MiniLM-multilingual liefert <0.25 fuer Rauschen).
Mit score_threshold=0 wird komplett Top-k zurueckgegeben."""
vec = embedder().embed(q)
points = store().search(
vec, k=k, type_filter=type, exclude_pinned=not include_pinned,
score_threshold=score_threshold if score_threshold and score_threshold > 0 else None,
)
return [MemoryOut.from_point(p) for p in points]
@app.post("/memory/save", response_model=MemoryOut)
def memory_save(body: MemoryIn):
s = store()
vec = embedder().embed(body.content)
point = MemoryPoint(
id="",
type=body.type,
title=body.title,
content=body.content,
pinned=body.pinned,
category=body.category,
source=body.source,
scope=body.scope,
tags=body.tags,
conversation_id=body.conversation_id,
attachments=body.attachments or [],
)
pid = s.upsert(point, vec)
saved = s.get(pid)
return MemoryOut.from_point(saved)
@app.patch("/memory/update/{point_id}", response_model=MemoryOut)
def memory_update(point_id: str, body: MemoryUpdate):
s = store()
existing = s.get(point_id)
if not existing:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
content_changed = body.content is not None and body.content != existing.content
if body.title is not None:
existing.title = body.title
if body.content is not None:
existing.content = body.content
if body.pinned is not None:
existing.pinned = body.pinned
if body.category is not None:
existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None:
existing.tags = body.tags
vec = embedder().embed(existing.content) if content_changed else None
if vec is None:
# Vektor unveraendert lassen — nur Payload neu schreiben
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
s.client.set_payload(
collection_name=COLLECTION,
payload=existing.to_payload() | {"updated_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat()},
points=[point_id],
)
saved = s.get(point_id)
else:
s.upsert(existing, vec)
saved = s.get(point_id)
return MemoryOut.from_point(saved)
@app.delete("/memory/delete/{point_id}")
def memory_delete(point_id: str):
s = store()
if not s.get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
s.delete(point_id)
# Anhaenge mit-loeschen damit nichts verwaist
try:
import memory_attachments as mem_att
n = mem_att.delete_all(point_id)
if n:
logger.info("Memory %s + %d Anhaenge geloescht", point_id, n)
except Exception as exc:
logger.warning("Anhang-Cleanup fuer %s fehlgeschlagen: %s", point_id, exc)
return {"deleted": point_id}
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
@app.get("/memory/{point_id}/attachments")
def memory_attachments_list(point_id: str):
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
in der DB, aber wir mergen vorsichtshalber mit dem Filesystem-Stand
(falls ein Upload-Restart zwischendrin schiefging)."""
import memory_attachments as mem_att
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
return {"memory_id": point_id, "attachments": mem_att.list_attachments(point_id)}
def _commit_attachment_meta(point_id: str, meta: dict) -> MemoryOut:
"""Shared-Helper: nach FS-Write das Payload um den neuen Anhang updaten.
Duplikat-Name wird ersetzt, sonst hinten dran."""
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
atts = [a for a in (m.attachments or []) if a.get("name") != meta["name"]]
atts.append(meta)
m.attachments = atts
from memory.vector_store import COLLECTION
import datetime as _dt
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
s.client.set_payload(
collection_name=COLLECTION,
payload=m.to_payload() | {"updated_at": m.updated_at},
points=[point_id],
)
return MemoryOut.from_point(s.get(point_id))
@app.post("/memory/{point_id}/attachments", response_model=MemoryOut)
def memory_attachments_add(point_id: str, body: AttachmentUploadBody):
"""Anhang als Base64 hochladen — fuer Diagnostic + interne Tools.
Fuer grosse Files lieber multipart-Variante (/upload) nutzen,
Base64 sprengt schnell die Bash-ARG_MAX-Grenze beim curl."""
import memory_attachments as mem_att
if not store().get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
try:
meta = mem_att.save_from_base64(point_id, body.name, body.data_base64)
except ValueError as exc:
raise HTTPException(400, str(exc))
return _commit_attachment_meta(point_id, meta)
@app.post("/memory/{point_id}/attachments/upload", response_model=MemoryOut)
async def memory_attachments_upload(point_id: str, file: UploadFile = File(...)):
"""Multipart-Upload — Standard fuer Browser-FormData und curl -F.
Verwendung:
curl -F file=@foto.jpg "$ARIA_BRAIN_URL/memory/<id>/attachments/upload"
"""
import memory_attachments as mem_att
if not store().get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
data = await file.read()
try:
meta = mem_att.save_attachment(point_id, file.filename or "datei", data)
except ValueError as exc:
raise HTTPException(400, str(exc))
return _commit_attachment_meta(point_id, meta)
@app.delete("/memory/{point_id}/attachments/{filename}", response_model=MemoryOut)
def memory_attachments_delete(point_id: str, filename: str):
"""Einzelnen Anhang loeschen (FS + Payload-Eintrag)."""
import memory_attachments as mem_att
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
removed_fs = mem_att.delete_attachment(point_id, filename)
safe = filename # Cleanup synchron mit FS — Payload-Match per name
atts = [a for a in (m.attachments or []) if a.get("name") not in (filename, safe)]
m.attachments = atts
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
import datetime as _dt
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
s.client.set_payload(
collection_name=COLLECTION,
payload=m.to_payload() | {"updated_at": m.updated_at},
points=[point_id],
)
if not removed_fs and not atts:
# weder im FS noch im Payload war was — Anhang existierte nicht
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
return MemoryOut.from_point(s.get(point_id))
@app.get("/memory/{point_id}/attachments/{filename}")
def memory_attachments_get(point_id: str, filename: str):
"""Liefert die Bytes eines Anhangs. Diagnostic-Server kann das
durchproxien zur Vorschau/Download in der UI."""
import memory_attachments as mem_att
import mimetypes as _mt
data = mem_att.read_bytes(point_id, filename)
if data is None:
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
mime = _mt.guess_type(filename)[0] or "application/octet-stream"
return Response(content=data, media_type=mime)
# ─── Migration aus brain-import/ ──────────────────────────────────────
IMPORT_DIR = os.environ.get("IMPORT_DIR", "/import")
@app.post("/memory/migrate")
def memory_migrate():
"""Liest /import/*.md und schreibt atomare Memory-Punkte in die DB.
Idempotent: bei Re-Run werden Punkte mit gleicher migration_key ersetzt."""
from pathlib import Path
from migration import run_migration
s = store()
e = embedder()
result = run_migration(Path(IMPORT_DIR), s, e)
return result
@app.get("/memory/import-files")
def memory_import_files():
"""Listet was unter /import/ liegt — fuer die Diagnostic-UI."""
from pathlib import Path
d = Path(IMPORT_DIR)
if not d.exists():
return {"import_dir": str(d), "exists": False, "files": []}
out = []
for p in sorted(d.iterdir()):
if p.is_file():
try:
out.append({"name": p.name, "size": p.stat().st_size})
except Exception:
pass
return {"import_dir": str(d), "exists": True, "files": out}
# ─── Bootstrap-Snapshot ───────────────────────────────────────────────
# "Bootstrap" = alle pinned Memories. Export/Import zum schnellen
# Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap")
def memory_export_bootstrap(scope: str = "system"):
"""Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
scope='system' → nur generische Regeln (fuer ein frisches System),
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
scope='all' → alles pinned (Vollbackup).
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
s = store()
if scope == "all":
pinned = s.list_pinned()
elif scope in ("system", "personal"):
pinned = s.list_pinned_by_scope(scope)
else:
raise HTTPException(400, f"Ungueltiger scope: {scope}")
return {
"version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).isoformat(),
"count": len(pinned),
"memories": [
{
"type": p.type,
"title": p.title,
"content": p.content,
"pinned": True,
"category": p.category,
"source": p.source,
"scope": p.scope,
"tags": p.tags,
}
for p in pinned
],
}
class BootstrapBundle(BaseModel):
version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict]
@app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle):
"""Importiert ein Bootstrap-Bundle scope-sicher.
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
(Vollbackup) werden alle pinned ersetzt.
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
Caller soll erst exportieren und dann importieren.
"""
if not body.memories:
raise HTTPException(400, "Bundle hat keine memories — Abbruch zur Sicherheit")
s = store()
e = embedder()
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
bundle_scope = body.scope
if bundle_scope not in ("system", "personal", "all"):
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
# Nur die pinned Punkte des betroffenen scope loeschen.
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
if bundle_scope in ("system", "personal"):
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
s.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
)
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0
for m in body.memories:
content = (m.get("content") or "").strip()
if not content:
continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint(
id="",
type=m.get("type", "fact"),
title=m.get("title", "(ohne Titel)"),
content=content,
pinned=True,
category=m.get("category", ""),
source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])),
)
vec = e.embed(content)
s.upsert(point, vec)
created += 1
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ──────────────────────────────────────────────
class ChatIn(BaseModel):
message: str
source: str = "" # "app" / "diagnostic" / "stt" — optional
# Multi-Threading: Client bestimmt pro Request welches Projekt (leer = Hauptchat).
# Kein globaler active_project-State mehr im Brain — parallele Requests fuer
# verschiedene Projekte laufen echt parallel, nur Requests fuers gleiche
# Projekt queuen (per-Projekt-Lock).
project_id: str = ""
class ChatOut(BaseModel):
reply: str
turns: int
distilling: bool
events: list = Field(default_factory=list)
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude"
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = True
# Stellt ARIA eine blockierende Rueckfrage (braucht Stefans Antwort, bevor der
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei). Wieder-An nur ueber App-Button.
wake_off: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
project_id: str = ""
# Per-Projekt async-Locks fuer Queue-Behavior: Requests fuers gleiche Projekt
# warten aufeinander (queue), Requests fuer verschiedene Projekte laufen echt
# parallel. Hauptchat = Lock unter key "" (leerer String).
_project_locks: dict[str, asyncio.Lock] = {}
_project_locks_meta_lock = asyncio.Lock()
# Pro Projekt eine Liste noch-nicht-verarbeiteter Requests. Wird beim Enqueue
# ergaenzt, beim Fertig-Werden gepoppt. Ermoeglicht Queue-Aware-Prompting:
# waehrend ARIA an Task N arbeitet, sieht sie N+1..N+k als System-Prompt-Hinweis
# und kann entscheiden ob eine spaetere Nachricht die aktuelle korrigiert/
# annuliert → dann Skip-Antwort statt Ausfuehren.
_project_pending: dict[str, list[dict]] = {}
async def _get_project_lock(project_id: str) -> asyncio.Lock:
"""Holt (oder erzeugt) den asyncio.Lock fuer ein bestimmtes Projekt.
Nutzt _project_locks_meta_lock zur Vermeidung von Race Conditions
beim ersten-Zugriff pro Projekt."""
async with _project_locks_meta_lock:
lock = _project_locks.get(project_id)
if lock is None:
lock = asyncio.Lock()
_project_locks[project_id] = lock
return lock
def _project_queue_snapshot() -> dict:
"""Snapshot fuer /projects/queue-status: welche Projekte arbeiten gerade,
wieviele wait-in-queue haben, welche sind idle."""
out = {}
# Zeige nur Kontexte mit Aktivitaet — locked oder pending
seen: set = set()
for pid, lock in _project_locks.items():
pending = len(_project_pending.get(pid, []))
is_busy = lock.locked()
# busy: gerade in Verarbeitung. queue: N weitere warten dahinter.
# Der Busy-Request zaehlt NICHT in queue (er ist ja aus pending schon "raus").
out[pid or "__main__"] = {
"busy": is_busy,
"queue_size": max(0, pending - (1 if is_busy else 0)),
}
seen.add(pid)
for pid, pend in _project_pending.items():
if pid in seen:
continue
out[pid or "__main__"] = {"busy": False, "queue_size": len(pend)}
return out
@app.post("/chat", response_model=ChatOut)
async def chat(body: ChatIn, background: BackgroundTasks):
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
im Hintergrund nachdem die Response rausging.
Multi-Threading: Requests fuers gleiche Projekt (project_id gleich)
laufen serialisiert durch den per-Projekt-Lock — Queue-Behavior.
Verschiedene Projekte laufen parallel."""
pid = (body.project_id or "").strip()
lock = await _get_project_lock(pid)
# Vor dem Lock in die Pending-Liste, damit die verlaufende Task sehen kann
# was NACH ihr in der Warteschlange steht (Queue-Aware Prompting).
import uuid as _uuid
req_id = _uuid.uuid4().hex
_project_pending.setdefault(pid, []).append({
"id": req_id, "message": body.message, "source": body.source,
})
try:
async with lock:
# Snapshot: was liegt NACH mir in der Queue?
after_me = [
e["message"] for e in _project_pending.get(pid, [])
if e["id"] != req_id
]
a = agent()
try:
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
pending_queue=after_me,
),
)
except ValueError as exc:
raise HTTPException(400, str(exc))
except RuntimeError as exc:
logger.error("chat fehlgeschlagen: %s", exc)
raise HTTPException(502, str(exc))
needs_distill = a.conversation.needs_distill()
if needs_distill:
background.add_task(a.distill_old_turns)
return ChatOut(
reply=reply,
turns=len(a.conversation.turns),
distilling=needs_distill,
events=a.pop_events(),
project_id=pid,
answered_by=answered_by,
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
)
finally:
_project_pending[pid] = [
e for e in _project_pending.get(pid, []) if e["id"] != req_id
]
@app.get("/projects/queue-status")
def projects_queue_status():
"""Snapshot: fuer jeden Projekt-Kontext (inkl. Hauptchat unter __main__)
- busy: True wenn gerade ein Request in Verarbeitung
- queue_size: wieviele weitere warten dahinter"""
return {"contexts": _project_queue_snapshot()}
# ── Projekte ────────────────────────────────────────────────────────
def _project_file_count(pid: str) -> int:
"""Anzahl Dateien in /shared/projects/<pid>/ (rekursiv, gecappt). 0 = leer."""
base = os.path.join("/shared/projects", pid or "")
if not os.path.isdir(base):
return 0
cnt = 0
try:
for _dp, dns, fns in os.walk(base):
dns[:] = [d for d in dns if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")]
cnt += len(fns)
if cnt > 999:
return 999
except Exception:
return 0
return cnt
def _enrich_projects(projects: list) -> list:
"""Ergaenzt has_files + file_count pro Projekt (fuer das Datei-Symbol in der
Liste). Das ersetzt das manuelle Code-Flag als primaeren Code-Indikator."""
for p in projects or []:
if not isinstance(p, dict):
continue
c = _project_file_count(p.get("id") or "")
p["file_count"] = c
p["has_files"] = c > 0
return projects
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
st = projects_mod.status()
_enrich_projects(st.get("projects", []))
if st.get("active"):
_enrich_projects([st["active"]])
return st
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": _enrich_projects(
projects_mod.list_projects(include_archived=include_archived))}
class ProjectCreateBody(BaseModel):
name: str
description: str = ""
@app.post("/projects/create")
def projects_create(body: ProjectCreateBody):
try:
p = projects_mod.create_project(body.name, body.description)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return p
class ProjectSwitchBody(BaseModel):
project_id: str = ""
@app.post("/projects/switch")
def projects_switch(body: ProjectSwitchBody):
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
if body.project_id:
p = projects_mod.get_project(body.project_id)
if not p:
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
projects_mod.set_active(body.project_id)
return projects_mod.status()
@app.post("/projects/{project_id}/end")
def projects_end(project_id: str):
if not projects_mod.end_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
@app.post("/projects/{project_id}/archive")
def projects_archive(project_id: str):
if not projects_mod.archive_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return {"id": project_id, "status": "archived"}
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
_PROJECT_FILES_ROOT = "/shared/projects"
_PROJECT_FILE_MAX = 512 * 1024
def _project_dir(project_id: str) -> str:
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
root = os.path.realpath(_PROJECT_FILES_ROOT)
if base != root and not base.startswith(root + os.sep):
raise HTTPException(status_code=400, detail="ungueltige project_id")
return base
@app.get("/projects/{project_id}/files")
def project_files(project_id: str):
base = _project_dir(project_id)
out = []
if os.path.isdir(base):
for dirpath, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in
(".git", "node_modules", "__pycache__", ".venv", "venv")]
for f in files:
full = os.path.join(dirpath, f)
rel = os.path.relpath(full, base).replace("\\", "/")
try:
sz = os.path.getsize(full)
except OSError:
sz = 0
out.append({"path": rel, "size": sz})
out.sort(key=lambda x: x["path"])
return {"projectId": project_id, "files": out}
@app.get("/projects/{project_id}/file")
def project_file(project_id: str, path: str, binary: bool = False):
base = _project_dir(project_id)
target = os.path.realpath(os.path.join(base, path))
if target != base and not target.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
if binary:
import base64
import mimetypes
if os.path.getsize(target) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
with open(target, "rb") as f:
data = f.read()
mime, _ = mimetypes.guess_type(target)
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
"base64": base64.b64encode(data).decode("ascii")}
if os.path.getsize(target) > _PROJECT_FILE_MAX:
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"projectId": project_id, "path": path, "content": content}
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
def _docker_gateway() -> str:
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
try:
import socket as _sock
import struct as _struct
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
except Exception:
pass
return ""
def _ssh_host(*cmd: str, timeout: int = 25):
import subprocess
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
_ARIA_VM_HOST, *[str(c) for c in cmd]]
try:
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc:
return 1, "", str(exc)
def _ssh_aria_vm(*args: str, timeout: int = 25):
return _ssh_host("aria-vm", *args, timeout=timeout)
def _vm_running_names() -> set:
rc, out, _err = _ssh_aria_vm("list", timeout=15)
names = set()
if rc == 0:
for line in out.splitlines():
parts = line.split()
if parts and "laeuft" in line:
names.add(parts[0])
return names
class VmAddBody(BaseModel):
name: str
arch: str = "i386"
iso: str = ""
floppy: str = ""
disk: str = ""
vnc_display: int = 1
mem: int = 1024
create_disk: bool = False
size: str = "10G"
def _vm_boot_args(v: dict) -> list:
args = ["boot", v.get("name", "?"),
"--vnc-display", str(v.get("vnc_display", 1)),
"--mem", str(v.get("mem", 1024))]
if v.get("disk"):
args += ["--disk", v["disk"]]
if v.get("floppy"):
args += ["--floppy", v["floppy"]]
if v.get("iso"):
args += ["--iso", v["iso"]]
return args
def _vm_boot_cmd(v: dict) -> str:
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
return "aria-vm " + " ".join(_vm_boot_args(v))
@app.get("/projects/{project_id}/vms")
def project_vms_list(project_id: str):
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
running = _vm_running_names()
for v in vms:
v["running"] = v.get("name") in running
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
v["boot_cmd"] = _vm_boot_cmd(v)
return {"projectId": project_id, "vms": vms}
@app.post("/projects/{project_id}/vms")
def project_vm_add(project_id: str, body: VmAddBody):
try:
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
body.floppy, body.disk, body.vnc_display, body.mem)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if body.create_disk:
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
vm["create_result"] = out.strip() or err.strip()
vm["create_ok"] = (rc == 0)
return vm
@app.delete("/projects/{project_id}/vms/{name}")
def project_vm_remove(project_id: str, name: str, purge: bool = False):
ok = project_vms_mod.remove_vm(project_id, name)
if not ok:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
if purge:
_ssh_aria_vm("rm", name)
return {"ok": True, "name": name}
@app.post("/projects/{project_id}/vms/{name}/boot")
def project_vm_boot(project_id: str, name: str):
vm = project_vms_mod.get_vm(project_id, name)
if not vm:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
boot_args = _vm_boot_args(vm)
gw = _docker_gateway()
if gw:
boot_args += ["--vnc-bind", gw]
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/stop")
def project_vm_stop(project_id: str, name: str):
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/screenshot")
def project_vm_screenshot(project_id: str, name: str):
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
import base64
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
if rc != 0:
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
path = ""
for line in out.splitlines():
if line.startswith("screenshot="):
path = line.split("=", 1)[1].strip()
if not path:
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
if rc2 != 0 or not b64.strip():
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
b64 = b64.strip()
try:
data = base64.b64decode(b64)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
fname = os.path.basename(path)
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
proj_rel = ""
try:
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
os.makedirs(shots_dir, exist_ok=True)
with open(os.path.join(shots_dir, fname), "wb") as f:
f.write(data)
proj_rel = "screenshots/" + fname
except Exception:
proj_rel = ""
return {"ok": True, "name": name, "filename": fname,
"projectPath": proj_rel, "base64": b64}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
@app.post("/conversation/reset")
def conversation_reset():
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
Destillierte facts bleiben in der DB."""
conversation().reset()
return {"ok": True, "turns": 0}
class ConvDeleteBody(BaseModel):
role: str
content: str
ts_iso_hint: Optional[str] = None
@app.post("/conversation/delete-turn")
def conversation_delete_turn(body: ConvDeleteBody):
"""Entfernt einen einzelnen Turn aus dem Rolling-Window + jsonl.
Match per role + content (erstes Vorkommen wenn ts_iso_hint None,
sonst nahester zur Zeit). 404 wenn kein Match.
POST statt DELETE weil FastAPI 0.115 keine Bodys auf DELETE
erlaubt — semantisch trotzdem eine Loeschung."""
ok = conversation().remove_by_match(
role=body.role, content=body.content, ts_iso_hint=body.ts_iso_hint,
)
if not ok:
raise HTTPException(404, "Turn mit diesem role+content nicht gefunden")
return {"ok": True, "turns": len(conversation().turns)}
@app.post("/conversation/distill")
def conversation_distill_now():
"""Manueller Trigger fuer Destillat — fuer Tests oder vor einem
bewussten Reset."""
return agent().distill_old_turns()
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")
def metrics_calls():
"""Liefert Aggregate fuer 1h / 5h / 24h / 30d.
Jedes Window: {window_seconds, calls, tokens_in, tokens_out, by_model}."""
return metrics_mod.stats()
# ─── Triggers (passive Aufweck-Quellen) ─────────────────────────────
class TriggerTimerBody(BaseModel):
name: str
fires_at: str # ISO timestamp
message: str
author: str = "stefan"
class TriggerWatcherBody(BaseModel):
name: str
condition: str
message: str
check_interval_sec: int = 300
throttle_sec: int = 3600
author: str = "stefan"
class TriggerPatch(BaseModel):
active: bool | None = None
message: str | None = None
condition: str | None = None
throttle_sec: int | None = None
check_interval_sec: int | None = None
fires_at: str | None = None
@app.get("/triggers/list")
def triggers_list(active_only: bool = False):
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
@app.post("/triggers/check-now")
async def triggers_check_now():
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
zu warten. Wird von der Bridge nach jedem location_update gerufen
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
return await background_mod.tick_now()
@app.get("/triggers/conditions")
def triggers_conditions():
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
(mit aktuellen Werten)."""
current = watcher_mod.collect_variables()
# near() ist ein callable in vars_ — fuer die UI rausfiltern
serializable = {k: v for k, v in current.items() if not callable(v)}
return {
"variables": watcher_mod.describe_variables(),
"functions": watcher_mod.describe_functions(),
"current": serializable,
}
@app.get("/triggers/{name}")
def triggers_get(name: str):
t = triggers_mod.read(name)
if t is None:
raise HTTPException(404, f"Trigger '{name}' nicht gefunden")
return t
@app.get("/triggers/{name}/logs")
def triggers_get_logs(name: str, limit: int = 50):
return {"logs": triggers_mod.list_logs(name, limit=limit)}
@app.post("/triggers/timer")
def triggers_create_timer(body: TriggerTimerBody):
try:
return triggers_mod.create_timer(
name=body.name, fires_at_iso=body.fires_at,
message=body.message, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/triggers/watcher")
def triggers_create_watcher(body: TriggerWatcherBody):
try:
return triggers_mod.create_watcher(
name=body.name, condition=body.condition,
message=body.message,
check_interval_sec=body.check_interval_sec,
throttle_sec=body.throttle_sec,
author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.patch("/triggers/{name}")
def triggers_patch(name: str, body: TriggerPatch):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
try:
return triggers_mod.update(name, patch)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/triggers/{name}")
def triggers_delete(name: str):
try:
triggers_mod.delete(name)
except ValueError as exc:
raise HTTPException(404, str(exc))
return {"deleted": name}
# ─── Skills ─────────────────────────────────────────────────────────
class SkillCreate(BaseModel):
name: str
description: str
execution: str # local-venv | local-bin | bash
entry_code: str
readme: str = ""
args: list = Field(default_factory=list)
requires: dict = Field(default_factory=dict)
pip_packages: list = Field(default_factory=list)
author: str = "stefan"
config_schema: list = Field(default_factory=list)
class SkillRun(BaseModel):
name: str
args: dict = Field(default_factory=dict)
timeout_sec: int = 300
class SkillPatch(BaseModel):
description: str | None = None
active: bool | None = None
args: list | None = None
entry_code: str | None = None
readme: str | None = None
pip_packages: list | None = None
config_schema: list | None = None
class SkillConfigSet(BaseModel):
values: dict
class SkillRollback(BaseModel):
version_id: str
@app.get("/skills/list")
def skills_list(active_only: bool = False):
return {"skills": skills_mod.list_skills(active_only=active_only)}
@app.get("/skills/{name}")
def skills_get(name: str):
m = skills_mod.read_manifest(name)
if m is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
readme = skills_mod.read_readme(name)
return {"manifest": m, "readme": readme}
class SkillScaffold(BaseModel):
name: str
template: str # oauth-api | apikey-api | file-process
params: dict = Field(default_factory=dict)
author: str = "stefan"
@app.get("/skills/templates")
def skills_templates_list():
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
import skill_templates as st
return {"templates": st.list_templates()}
@app.post("/skills/scaffold")
def skills_scaffold(body: SkillScaffold):
"""Baut einen Skill aus einem Template (oauth-api / apikey-api / file-process)."""
try:
return skills_mod.scaffold_skill(
name=body.name, template=body.template,
params=body.params, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/skills/create")
def skills_create(body: SkillCreate):
try:
return skills_mod.create_skill(
name=body.name,
description=body.description,
execution=body.execution,
entry_code=body.entry_code,
readme=body.readme,
args=body.args,
requires=body.requires,
pip_packages=body.pip_packages,
author=body.author,
config_schema=body.config_schema,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/skills/run")
def skills_run(body: SkillRun):
try:
return skills_mod.run_skill(body.name, args=body.args, timeout_sec=body.timeout_sec)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.patch("/skills/{name}")
def skills_patch(name: str, body: SkillPatch):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
try:
return skills_mod.update_skill(name, patch)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/skills/{name}")
def skills_delete(name: str):
try:
skills_mod.delete_skill(name)
except ValueError as exc:
raise HTTPException(404, str(exc))
return {"deleted": name}
@app.get("/skills/{name}/logs")
def skills_logs(name: str, limit: int = 50):
return {"logs": skills_mod.list_logs(name, limit=limit)}
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
@app.get("/skills/{name}/config")
def skills_config_get(name: str):
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
'***SET***')."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {
"schema": manifest.get("config_schema") or [],
"values": skills_mod.get_skill_config_masked(name),
}
@app.post("/skills/{name}/config")
def skills_config_set(name: str, body: SkillConfigSet):
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
skills_mod.set_skill_config(name, body.values)
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
# ── Skill-Versions (P4): rollback ──────────────────────────────────
@app.get("/skills/{name}/versions")
def skills_versions_list(name: str):
if skills_mod.read_manifest(name) is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {"versions": skills_mod.list_skill_versions(name)}
@app.post("/skills/{name}/rollback")
def skills_rollback(name: str, body: SkillRollback):
try:
return skills_mod.rollback_skill(name, body.version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/skills/{name}/versions/{version_id}")
def skills_versions_delete(name: str, version_id: str):
try:
return skills_mod.delete_skill_version(name, version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.get("/skills/{name}/export")
def skills_export(name: str):
try:
data = skills_mod.export_skill(name)
except ValueError as exc:
raise HTTPException(404, str(exc))
return Response(
content=data,
media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="skill-{name}.tar.gz"'},
)
@app.post("/skills/import")
async def skills_import(request: Request, overwrite: bool = False):
data = await request.body()
if not data:
raise HTTPException(400, "Leerer Body")
try:
manifest = skills_mod.import_skill(data, overwrite=overwrite)
except ValueError as exc:
raise HTTPException(400, str(exc))
return {"imported": manifest}
# ── OAuth ─────────────────────────────────────────────────────────
@app.get("/oauth/services")
async def oauth_services_list():
"""Liste aller Services mit Status (configured/authenticated/expires)."""
return {"services": oauth_mod.list_services()}
@app.get("/oauth/apps")
async def oauth_apps_get():
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
safe = {}
for service, entry in apps.items():
if not isinstance(entry, dict):
continue
safe[service] = {
"client_id": entry.get("client_id", ""),
"has_client_secret": bool(entry.get("client_secret")),
"scopes": entry.get("scopes"),
"auth_url": entry.get("auth_url"),
"token_url": entry.get("token_url"),
}
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
class OAuthAppIn(BaseModel):
service: str
client_id: str = ""
client_secret: str = ""
scopes: Optional[List[str]] = None
auth_url: Optional[str] = None
token_url: Optional[str] = None
@app.post("/oauth/apps")
async def oauth_apps_set(body: OAuthAppIn):
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
kann fuer reine scope-Aenderungen)."""
service = (body.service or "").strip()
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
entry = apps.get(service) or {}
if body.client_id:
entry["client_id"] = body.client_id.strip()
if body.client_secret:
entry["client_secret"] = body.client_secret.strip()
if body.scopes is not None:
entry["scopes"] = body.scopes
if body.auth_url:
entry["auth_url"] = body.auth_url.strip()
if body.token_url:
entry["token_url"] = body.token_url.strip()
apps[service] = entry
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
return {"ok": True, "service": service}
@app.delete("/oauth/apps/{service}")
async def oauth_apps_delete(service: str):
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
if service in apps:
apps.pop(service)
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
# Token auch wegwerfen
oauth_mod.revoke(service)
return {"ok": True}
@app.post("/oauth/{service}/revoke")
async def oauth_revoke_endpoint(service: str):
return {"ok": oauth_mod.revoke(service)}
@app.get("/oauth/{service}/token")
async def oauth_token_endpoint(service: str):
"""Liefert das aktuelle access_token fuer einen Service (mit Auto-Refresh
wenn < 60s Restzeit). Nur fuer interne Skill-Aufrufe gedacht — Skills
sollen NIEMALS hardcoded client_secrets haben, sondern dieses Endpoint
pollen. Antwort: {access_token, expires_at, expires_in_sec}.
Bei nicht-autorisiert: 401 mit klarer Message."""
try:
rec = oauth_mod.get_token(service)
except RuntimeError as exc:
raise HTTPException(401, str(exc))
expires_at = int(rec.get("expires_at") or 0)
import time as _t
return {
"access_token": rec.get("access_token"),
"expires_at": expires_at,
"expires_in_sec": max(0, expires_at - int(_t.time())),
}
class OAuthAuthorizeIn(BaseModel):
service: str
scopes: Optional[List[str]] = None
@app.post("/oauth/authorize")
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
try:
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
except RuntimeError as exc:
raise HTTPException(400, str(exc))
@app.post("/internal/oauth-callback")
async def oauth_callback_internal(request: Request):
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
Macht den state-Match + token-exchange und persistiert."""
try:
body = await request.json()
except Exception as exc:
raise HTTPException(400, f"bad json: {exc}")
service = (body.get("service") or "").strip()
code = (body.get("code") or "").strip()
state = (body.get("state") or "").strip()
err = body.get("error") or None
err_desc = body.get("errorDescription") or None
if not service:
raise HTTPException(400, "service erforderlich")
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
return result