diff --git a/CLAUDE.md b/CLAUDE.md index 8fedd2d..78f4651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,6 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh - **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`. - **`hw_math.py`** — Odysseus-Fit-Mathe: VRAM/RAM-Bedarf, tps-Schätzung, `max_ctx_for`, `extract_params_b`. - **`recipes.py`** — kuratierte Use-Case-Stacks fürs Cookbook 2.0. -- **`hermes_agent.py`** — Kern des Hermes-Agenten: Tool-Definitionen, Tool-Dispatcher, LLM-Loop (ReAct), Modell-Routing. **⚠️ DEPRECATED ab v9** — wird durch das Nous Hermes-Agent-Framework ersetzt (siehe ROADMAP v9). Bleibt bis Phase 3 bestehen, dann löschen. - **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code. - **`routers/*.py`** — ein Router je Bereich: - `models.py` — status/download/register/update_model/unload/chat @@ -24,7 +23,7 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh - `integration.py` — test (Engine-Verbindungstest) - `news.py` — RSS-Aggregation via stdlib - `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien - - `hermes.py` — Chat-WebSocket/transcribe(Whisper)/tts(Piper)/status/pubkey + - `hermes.py` — Chat-WS **proxyt zum Hermes-Agent-Server (:8642)** + transcribe(Whisper)/tts(Piper)/status/pubkey **Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`): - **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich. @@ -85,8 +84,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen). | `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload | | `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle | | `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis | -| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks | -| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks | +| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) | +| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) | | `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH | | `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username | | `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes | @@ -113,7 +112,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen). - **Piper TTS**: Binary + Stimm-Modell müssen separat installiert werden (nicht via pip). - **Whisper**: lädt beim ersten Aufruf ~1.4 GB Modell herunter (einmalig, dann gecacht). - **Hermes SSH-Key**: muss auf dem Bosgame generiert werden (`~/.ssh/id_ed25519_hermes_agent`). -- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. `hermes_agent.py` muss entsprechend lesen. +- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. Konsumenten müssen entsprechend lesen. +- **Hermes-Server-Key**: `routers/hermes.py` proxyt zu `:8642` und liest den Key bei Bedarf aus `~/.hermes/.env` (`API_SERVER_KEY`) — MC muss als User `hitonabi` laufen, sonst kein Lesezugriff. - **OpenCode Config**: Datei heißt `opencode.jsonc` (nicht `.json`), Key ist `"providers"` (Plural). - **ConnectPanel hinter NPM-Proxy**: `location.hostname` zeigt Proxy-Domain → LAN-IP Override im Verbinden-Tab setzen (localStorage). @@ -124,7 +124,7 @@ Oder über den Self-Update-Button in Mission Control (empfohlen). **v8.1 (Bug-Fix)**: system_status-Keys, Model-Routing, asyncio, sudo-PW für Engine-Update, Chat-History in sessionStorage. **v8.2 (UX)**: Guide-Tabs + Begriffe oben, Mobile Bottom-Nav (≤520px), Download-Links, ConnectPanel-Fixes. **v8.3 (Memory Import)**: Import aus Cloud-KIs (Claude/Gemini/ChatGPT) — zeilenweiser Batch-POST. -**v9 (Hermes-Agent-Adoption, IN ARBEIT, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. 5 Phasen, siehe ROADMAP v9. Aktuell: Phase 0 (Proof auf Bosgame). +**v9 (Hermes-Agent-Adoption, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. Phasen 0–2 live + Phase 3 (harter Cutover): `hermes_agent.py` entfernt, `routers/hermes.py` proxyt zu `:8642`, Modell-Routing macht Hermes selbst. Offen: Self-Update auch für `hermes-gateway`, Status-/Scheduler-/Skills-Panels (Phase 4). Siehe ROADMAP v9. Offene v8-Einrichtungsschritte (kein Code, nur Setup): - Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup) diff --git a/README.md b/README.md index 03e5680..ac3b94e 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ Alle Einstellungen über Umgebungsvariablen in der systemd-Unit: | `MC_UPDATE_CMD` | leer | Engine-Update-Befehl | | `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle | | `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank | -| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks | -| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks | +| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) | +| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) | | `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff | | `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary | | `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme | diff --git a/ROADMAP.md b/ROADMAP.md index 0a92615..72a8173 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,8 +20,8 @@ **Phasen:** - [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`. - [x] **Phase 1 — Memory-Bridge (✅ 2026-06-23):** `mcp_memory.py` via `hermes mcp add mission-control-memory --command /opt/mission-control/.venv/bin/python --env MC_URL=http://127.0.0.1:9000 --args …/mcp_memory.py` registriert (5/5 Tools, Test ✓). Erkenntnis: `mcp_memory.py` ist stdio-MCP→HTTP-Wrapper auf MC `/api/memory` — **MC :9000 muss laufen**. Proof: Hermes liest beide Bestands-Memories *und* schrieb neuen Eintrag (verifiziert in SQLite, 2→3). v7/v8.3-Gedächtnis ohne Migration im Agenten. Hinweis: Hermes' **eigenes** Session-Memory läuft zusätzlich → zwei Schichten im Blick behalten. -- [ ] **Phase 2 — Control-Plane:** systemd-Dienst `hermes-agent.service` + MC-Panels (Agent-Status, Chat→:8642, Voice-Wrap). -- [ ] **Phase 3 — Aufräumen:** `hermes_agent.py` + tote Tools löschen, `routers/hermes.py` zu Proxy verschlanken, Self-Update auf beide Dienste erweitern. +- [x] **Phase 2 — Control-Plane (✅ 2026-06-23):** Hermes als **systemd-User-Service** `hermes-gateway` (`hermes gateway install`, Linger aktiv → boot-/logout-fest, kein sudo). API-Server `:8642` aktiviert (`~/.hermes/.env`: `API_SERVER_ENABLED/KEY/PORT/HOST`). Produktionspfad bewiesen: Tool-Call via `curl :8642/v1/chat/completions`. mcp_memory.py läuft als Kindprozess im Dienst. **UI-Hälfte:** `routers/hermes.py`-Chat-WS proxyt jetzt streamend zu `:8642` (Key aus `~/.hermes/.env`); WS-Contract (`thinking/token/done/error`) beibehalten → **HermesPanel unverändert, kein Frontend-Build nötig**. Status-Endpoint meldet zusätzlich Hermes-Erreichbarkeit. +- [x] **Phase 3 — Aufräumen (✅ 2026-06-23, harter Cutover):** `hermes_agent.py` gelöscht (ReAct-Loop, tote Tools, Modell-Routing weg); `config.py` um `HERMES_SIMPLE/COMPLEX_MODEL` bereinigt, `HERMES_API_URL/KEY` ergänzt; `routers/hermes.py` zu reinem Proxy verschlankt. **Rest-offen:** Self-Update auch für `hermes-gateway` (aktuell nur `mission-control`); HermesPanel-Altlast (Windows-SSH-Setup-Sektion) ist nur noch kosmetisch. - [ ] **Phase 4 — Auszahlung:** Cron-Scheduler + Skills im Dashboard sichtbar machen (das geschenkte v9). **Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · HermesPanel behalten (nicht hermes-webui, das ist an Interna gekoppelt) · erst Qwen3-coder behalten, dann Hermes-4.3-36B A/B-testen · WebUI = Lockstep-Falle, stattdessen stabiler OpenAI-API-Server. diff --git a/config.py b/config.py index ba7a75e..185b0b5 100644 --- a/config.py +++ b/config.py @@ -44,9 +44,31 @@ UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "") DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!) -# Hermes Agent -HERMES_SIMPLE_MODEL = os.environ.get("HERMES_SIMPLE_MODEL", "scout") -HERMES_COMPLEX_MODEL = os.environ.get("HERMES_COMPLEX_MODEL", "coder") +# Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9). +# Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst. +HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642/v1").rstrip("/") + + +def _read_hermes_api_key() -> str: + """API-Key des Hermes-Agent-Servers: bevorzugt Env HERMES_API_KEY, + sonst aus ~/.hermes/.env (Schluessel API_SERVER_KEY) — so muss das + Secret nicht dupliziert werden.""" + k = os.environ.get("HERMES_API_KEY", "") + if k: + return k + envf = Path(os.path.expanduser("~/.hermes/.env")) + if envf.exists(): + try: + for line in envf.read_text(errors="replace").splitlines(): + line = line.strip() + if line.startswith("API_SERVER_KEY="): + return line.split("=", 1)[1].strip() + except Exception: + pass + return "" + + +HERMES_API_KEY = _read_hermes_api_key() HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "") HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC") HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent"))) diff --git a/hermes_agent.py b/hermes_agent.py deleted file mode 100644 index 460362b..0000000 --- a/hermes_agent.py +++ /dev/null @@ -1,463 +0,0 @@ -""" -Hermes Agent — lokaler KI-Assistent fuer das Agentic OS. - -Empfaengt Aufgaben per Text (und Voice), nutzt llama-swap als LLM-Backend, -kann per Tools auf das Bosgame und spaeter per SSH auf den Windows-PC zugreifen. -""" - -import asyncio -import json -import subprocess -from pathlib import Path -from typing import Callable, Awaitable - -import httpx - -from config import LLAMA_SWAP_URL, HERMES_SIMPLE_MODEL, HERMES_COMPLEX_MODEL - -# --------------------------------------------------------------------------- -# Tool-Definitionen (OpenAI Function Calling Format) -# --------------------------------------------------------------------------- - -TOOL_DEFS = [ - { - "type": "function", - "function": { - "name": "read_file", - "description": "Liest eine Textdatei auf dem Bosgame.", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string", "description": "Absoluter Pfad zur Datei"} - }, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_directory", - "description": "Listet Dateien und Ordner eines Verzeichnisses auf dem Bosgame.", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string", "description": "Absoluter Pfad zum Verzeichnis"} - }, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "run_command", - "description": ( - "Fuehrt einen Lese-Befehl auf dem Bosgame aus (kein sudo, kein rm). " - "Geeignet fuer: ls, cat, ps, df, free, journalctl, systemctl status, ip, curl, etc." - ), - "parameters": { - "type": "object", - "properties": { - "command": {"type": "string", "description": "Shell-Befehl"} - }, - "required": ["command"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_system_status", - "description": "Gibt aktuellen System-Status zurueck: CPU, RAM, GPU, laufende Modelle.", - "parameters": {"type": "object", "properties": {}}, - }, - }, - { - "type": "function", - "function": { - "name": "get_memories", - "description": "Laedt alle gespeicherten Fakten und Entscheidungen.", - "parameters": {"type": "object", "properties": {}}, - }, - }, - { - "type": "function", - "function": { - "name": "add_memory", - "description": "Speichert eine neue Information dauerhaft ins Gedaechtnis.", - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string", "description": "Der zu speichernde Fakt"}, - "category": { - "type": "string", - "enum": ["user", "instruction", "stable", "versioned", "ephemeral"], - "description": ( - "user=Fakten ueber den User (Wer ist er? Wie arbeitet er?), " - "instruction=Verhaltensregeln fuer alle KI-Tools, " - "stable=Projektfakten, " - "versioned=Tech-Versionen (bei Updates ueberschreiben), " - "ephemeral=temporaer (7 Tage)" - ), - }, - }, - "required": ["content"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "web_search", - "description": "Sucht im Internet nach aktuellen Informationen.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Suchbegriff"} - }, - "required": ["query"], - }, - }, - }, -] - -# --------------------------------------------------------------------------- -# Tool-Implementierungen -# --------------------------------------------------------------------------- - -_BLOCKED_CMDS = { - "rm", "rmdir", "mv", "cp", "dd", "mkfs", "fdisk", - "parted", "shutdown", "reboot", "halt", "poweroff", - "chmod", "chown", "passwd", "userdel", "useradd", -} - - -def _exec_read_file(path: str) -> str: - try: - p = Path(path) - if not p.exists(): - return f"Datei nicht gefunden: {path}" - content = p.read_text(errors="replace") - if len(content) > 8000: - return content[:8000] + f"\n\n... (gekuerzt, gesamt {len(content):,} Zeichen)" - return content - except Exception as e: - return f"Fehler beim Lesen: {e}" - - -def _exec_list_directory(path: str) -> str: - try: - p = Path(path) - if not p.exists(): - return f"Pfad nicht gefunden: {path}" - items = sorted(p.iterdir(), key=lambda x: (x.is_file(), x.name.lower())) - lines = [] - for item in items[:80]: - if item.is_dir(): - lines.append(f"📁 {item.name}/") - else: - size = item.stat().st_size - sz = f"{size/1024/1024:.1f} MB" if size > 1024*1024 else f"{size/1024:.1f} KB" if size > 1024 else f"{size} B" - lines.append(f"📄 {item.name} ({sz})") - total = sum(1 for _ in p.iterdir()) - if total > 80: - lines.append(f"... und {total - 80} weitere") - return "\n".join(lines) if lines else "(leer)" - except Exception as e: - return f"Fehler: {e}" - - -def _exec_run_command(command: str) -> str: - first = command.strip().split()[0] if command.strip() else "" - if first in _BLOCKED_CMDS or "sudo" in command: - return f"Befehl '{first}' blockiert. Nutze Mission Control fuer Systemoperationen." - try: - result = subprocess.run( - command, shell=True, capture_output=True, text=True, timeout=30 - ) - out = (result.stdout + result.stderr).strip() - if len(out) > 4000: - out = out[:4000] + "\n... (Ausgabe gekuerzt)" - return out or "(kein Output)" - except subprocess.TimeoutExpired: - return "Timeout nach 30 Sekunden." - except Exception as e: - return f"Fehler: {e}" - - -def _exec_get_system_status() -> str: - base = "http://127.0.0.1:9000" - try: - s = httpx.get(f"{base}/api/status", timeout=5).json() - models = s.get("models", []) - running = [m for m in models if m.get("state") in ("running", "ready", "loading")] - model_str = ", ".join(m["name"] for m in running) if running else "keines" - except Exception: - model_str = "unbekannt" - - try: - d = httpx.get(f"{base}/api/system/status", timeout=5).json() - cpu = d.get("cpu", {}) - ram = d.get("ram", {}) - gtt = d.get("gpu", {}).get("gtt", {}) - cpu_pct = round(cpu.get("percent", 0), 1) - cpu_temp = cpu.get("temp", "?") - ram_used = round(ram.get("used", 0) / (1024 ** 3), 1) - ram_total = round(ram.get("total", 0) / (1024 ** 3), 1) - gtt_used = round(gtt.get("used", 0) / (1024 ** 3), 1) - gtt_total = round(gtt.get("total", 0) / (1024 ** 3), 1) - return ( - f"Aktives Modell: {model_str}\n" - f"CPU: {cpu_pct}% | " - f"RAM: {ram_used} / {ram_total} GB | " - f"GPU: {gtt_used} / {gtt_total} GB GTT\n" - f"Temp: CPU {cpu_temp}°C" - ) - except Exception as e: - return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}" - - -def _exec_get_memories() -> str: - try: - items = httpx.get("http://127.0.0.1:9000/api/memory", timeout=5).json() - if not items: - return "Kein Gedaechtnis vorhanden." - return "\n".join(f"[{m['category']}] {m['content']}" for m in items) - except Exception as e: - return f"Fehler: {e}" - - -def _exec_add_memory(content: str, category: str = "stable") -> str: - try: - r = httpx.post( - "http://127.0.0.1:9000/api/memory", - json={"content": content, "category": category, "source": "hermes"}, - timeout=5, - ) - r.raise_for_status() - return f"Gespeichert: {content}" - except Exception as e: - return f"Fehler: {e}" - - -def _exec_web_search(query: str) -> str: - try: - r = httpx.get( - "https://api.duckduckgo.com/", - params={"q": query, "format": "json", "no_html": 1, "skip_disambig": 1}, - timeout=10, - headers={"User-Agent": "HermesAgent/1.0"}, - follow_redirects=True, - ) - data = r.json() - results = [] - if data.get("AbstractText"): - results.append(data["AbstractText"]) - for rt in data.get("RelatedTopics", [])[:4]: - if isinstance(rt, dict) and rt.get("Text"): - results.append(rt["Text"]) - return "\n\n".join(results) if results else "Keine direkten Ergebnisse. Versuche eine genauere Suchanfrage." - except Exception as e: - return f"Websuche fehlgeschlagen: {e}" - - -def execute_tool(name: str, args: dict) -> str: - if name == "read_file": - return _exec_read_file(args.get("path", "")) - if name == "list_directory": - return _exec_list_directory(args.get("path", "")) - if name == "run_command": - return _exec_run_command(args.get("command", "")) - if name == "get_system_status": - return _exec_get_system_status() - if name == "get_memories": - return _exec_get_memories() - if name == "add_memory": - return _exec_add_memory(args.get("content", ""), args.get("category", "stable")) - if name == "web_search": - return _exec_web_search(args.get("query", "")) - return f"Unbekanntes Tool: {name}" - -# --------------------------------------------------------------------------- -# Modell-Auswahl -# --------------------------------------------------------------------------- - -_COMPLEX_KW = { - # Erstellen / Schreiben - "schreib", "schreibe", "erstell", "erstelle", "generier", "generiere", - "baue", "bau", - # Planen / Analysieren / Recherchieren - "plan", "analysier", "analysiere", "recherchier", "recherchiere", - "vergleich", "vergleiche", "bewert", "bewerte", "untersuche", "untersuchen", - # Entwicklung / Code - "refactor", "debug", "implementier", "implementiere", "entwickl", "entwickle", - "code", "programm", "programmier", "programmiere", - # Web-Suche / Recherche - "such im internet", "suche im internet", "such online", "suche online", - "such nach aktuell", "suche nach aktuell", - # Erklären (ausführlich) - "erklaer ausfuehrlich", "erklaere ausfuehrlich", "erklaer detailliert", - "erklaere detailliert", "erklaer mir genau", "was ist der unterschied", - # Optimieren / Verbessern - "optimier", "optimiere", "verbessere", "verbessern", "reparier", "repariere", - # Multi-Step / Komplex - "konfiguriere", "konfigurieren", "installier", "installiere", - "wie kann ich", "schritt fuer schritt", "schritt für schritt", -} - - -def choose_model(message: str) -> str: - """Waehlt simple/complex Modell — upgraded wenn noetig, degradiert nie.""" - lower = message.lower() - wants_complex = any(kw in lower for kw in _COMPLEX_KW) - - # Nicht downgraden wenn komplexes Modell bereits geladen ist - try: - s = httpx.get("http://127.0.0.1:9000/api/status", timeout=3).json() - loaded = {m["name"] for m in s.get("models", []) if m.get("state") in ("running", "ready")} - if HERMES_COMPLEX_MODEL in loaded: - return HERMES_COMPLEX_MODEL - except Exception: - pass - - return HERMES_COMPLEX_MODEL if wants_complex else HERMES_SIMPLE_MODEL - -# --------------------------------------------------------------------------- -# System-Prompt -# --------------------------------------------------------------------------- - -SYSTEM_PROMPT = """\ -Du bist Hermes, ein lokaler KI-Assistent der dauerhaft auf dem Bosgame M5 laeuft. - -Dein Zuhause (Bosgame M5): -- AMD Strix Halo, ~124 GB GTT-Speicher, Ubuntu 26.04, Kernel 7.0 -- LAN-IP: 192.168.178.151 -- Dienste: llama-swap :8080 (Inference), Mission Control :9000 (Dashboard) -- Modelle: coder (Qwen3-30B-A3B), scout (Qwen3-8B), vision (Qwen3-VL) -- Du laeuft als Teil von Mission Control - -Gespeichertes Wissen (nach Kategorie): -{memories} - -Verhaltensregeln (fest): -- Antworte praegnant auf Deutsch (max 2-3 Saetze wenn nicht ausdruecklich mehr gewuenscht) -- Kuendige an was du tust bevor du es tust -- Frag nach bevor du Dateien ueberschreibst oder Dienste neustartest -- Nutze get_system_status() wenn du nicht sicher bist was gerade laeuft -- Bei einfachen Fragen: keine Tools, direkt antworten - -Gedaechtnis-Kategorien (fuer add_memory): -- user → Fakten ueber den User: Wer er ist, wie er arbeitet, was er bevorzugt -- instruction → Verhaltensregeln die fuer ALLE KI-Tools gelten sollen -- stable → Technische Projektfakten (IPs, Ports, Architektur) -- versioned → Tech-Versionen (bei Updates ueberschreiben, nicht anhaengen) -- ephemeral → Temporaerer Kontext (7 Tage, dann weg) - -Speichere wichtige Erkenntnisse aktiv per add_memory() — besonders wenn der User -etwas ueber sich selbst erwaehnt (→ user) oder Verhaltensregeln nennt (→ instruction). -""" - -# --------------------------------------------------------------------------- -# LLM-Aufruf (synchron, in Executor ausfuehren) -# --------------------------------------------------------------------------- - - -def _call_llm(model: str, messages: list) -> dict: - r = httpx.post( - f"{LLAMA_SWAP_URL}/v1/chat/completions", - json={ - "model": model, - "messages": messages, - "tools": TOOL_DEFS, - "tool_choice": "auto", - "temperature": 0.3, - }, - timeout=120, - ) - r.raise_for_status() - return r.json() - -# --------------------------------------------------------------------------- -# Haupt-Agent-Loop -# --------------------------------------------------------------------------- - -SendFn = Callable[[dict], Awaitable[None]] - - -async def run_agent(message: str, send: SendFn) -> None: - """ - Fuehrt den Agenten-Loop aus und streamt Ergebnisse via send()-Callback. - Nachrichten-Typen: - {"type": "info", "content": str} — Status-Meldung - {"type": "thinking"} — LLM denkt nach - {"type": "token", "content": str} — Wort der Antwort - {"type": "tool_call", "name": str, "args": dict} - {"type": "tool_result", "name": str, "content": str} - {"type": "error", "content": str} - {"type": "done"} - """ - memories = _exec_get_memories() - model = choose_model(message) - - await send({"type": "info", "content": f"Modell: {model}"}) - - messages = [ - {"role": "system", "content": SYSTEM_PROMPT.format(memories=memories)}, - {"role": "user", "content": message}, - ] - - for _round in range(8): - await send({"type": "thinking"}) - - try: - result = await asyncio.get_running_loop().run_in_executor( - None, lambda: _call_llm(model, messages) - ) - except Exception as exc: - await send({"type": "error", "content": f"LLM nicht erreichbar: {exc}"}) - return - - choice = result["choices"][0] - msg = choice["message"] - finish = choice.get("finish_reason", "") - tool_calls = msg.get("tool_calls") or [] - - # Text-Antwort: Wort fuer Wort streamen - if msg.get("content"): - words = msg["content"].split(" ") - for i, word in enumerate(words): - token = word + (" " if i < len(words) - 1 else "") - await send({"type": "token", "content": token}) - await asyncio.sleep(0.012) - - if not tool_calls or finish == "stop": - break - - # Tool-Calls ausfuehren - messages.append(msg) - - for tc in tool_calls: - fn = tc["function"] - name = fn["name"] - try: - args = json.loads(fn.get("arguments", "{}")) - except Exception: - args = {} - - await send({"type": "tool_call", "name": name, "args": args}) - - tool_out = await asyncio.get_running_loop().run_in_executor( - None, lambda n=name, a=args: execute_tool(n, a) - ) - - preview = tool_out[:300] + "..." if len(tool_out) > 300 else tool_out - await send({"type": "tool_result", "name": name, "content": preview}) - - messages.append({ - "role": "tool", - "tool_call_id": tc["id"], - "content": tool_out, - }) - - await send({"type": "done"}) diff --git a/routers/hermes.py b/routers/hermes.py index ee75fb5..45d83da 100644 --- a/routers/hermes.py +++ b/routers/hermes.py @@ -18,6 +18,7 @@ import threading from pathlib import Path from typing import Optional +import httpx from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect from fastapi.responses import Response, JSONResponse @@ -25,8 +26,8 @@ from auth import auth from config import ( PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE, HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY, + HERMES_API_URL, HERMES_API_KEY, ) -from hermes_agent import run_agent router = APIRouter(prefix="/api") @@ -100,7 +101,12 @@ def _tts(text: str) -> Optional[bytes]: @router.websocket("/hermes/chat") async def hermes_chat(websocket: WebSocket): - """Streaming Chat mit dem Hermes Agent via WebSocket.""" + """Streaming-Chat: proxyt zum Hermes-Agent-Server (:8642, OpenAI-kompatibel). + + Behaelt den WS-Contract des Frontends bei (thinking/token/done/error), damit + die HermesPanel unveraendert bleibt. Modell-Wahl, Tools, Gedaechtnis und + Mehr-Schritt-Logik macht der Hermes-Agent selbst. + """ # Manuelle Token-Auth (WS kann keine HTTP-Header senden) from auth import TOKEN token_param = websocket.query_params.get("token", "") @@ -121,18 +127,71 @@ async def hermes_chat(websocket: WebSocket): if not message: continue - async def send(obj: dict) -> None: - await websocket.send_json(obj) - - await run_agent(message, send) + await websocket.send_json({"type": "thinking"}) + try: + await _proxy_chat(message, websocket) + except Exception as exc: + await websocket.send_json( + {"type": "error", "content": f"Hermes nicht erreichbar: {exc}"} + ) + await websocket.send_json({"type": "done"}) except WebSocketDisconnect: pass - except Exception as exc: - try: - await websocket.send_json({"type": "error", "content": str(exc)}) - except Exception: - pass + except Exception: + pass + + +async def _proxy_chat(message: str, websocket: WebSocket) -> None: + """Eine Antwort vom Hermes-Agent-Server holen und Tokens ans WS relayen. + + Unterstuetzt sowohl SSE-Streaming als auch eine nicht-streamende + JSON-Antwort — in beiden Faellen genau EIN Agent-Lauf (kein Doppel-Call). + """ + headers = {"Content-Type": "application/json"} + if HERMES_API_KEY: + headers["Authorization"] = f"Bearer {HERMES_API_KEY}" + body = { + "model": "hermes-agent", + "messages": [{"role": "user", "content": message}], + "stream": True, + } + timeout = httpx.Timeout(300.0, connect=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream( + "POST", f"{HERMES_API_URL}/chat/completions", headers=headers, json=body + ) as resp: + if resp.status_code != 200: + detail = (await resp.aread()).decode(errors="replace")[:300] + await websocket.send_json( + {"type": "error", "content": f"HTTP {resp.status_code}: {detail}"} + ) + return + + if "text/event-stream" in resp.headers.get("content-type", ""): + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + delta = json.loads(data)["choices"][0].get("delta", {}) + piece = delta.get("content") or "" + except Exception: + piece = "" + if piece: + await websocket.send_json({"type": "token", "content": piece}) + else: + # Server streamt nicht → ganze JSON-Antwort als ein Token senden + raw = await resp.aread() + try: + d = json.loads(raw) + content = d["choices"][0]["message"].get("content", "") or "" + except Exception: + content = raw.decode(errors="replace") + if content: + await websocket.send_json({"type": "token", "content": content}) @router.post("/hermes/transcribe", dependencies=[Depends(auth)]) @@ -206,7 +265,15 @@ def hermes_status(): except Exception: ssh_ok = False + # Hermes-Agent-Server (:8642) — erreichbar? + try: + httpx.get(HERMES_API_URL.rsplit("/v1", 1)[0] + "/", timeout=2) + hermes = "ready" + except Exception: + hermes = "offline" + return { + "hermes": hermes, "whisper": whisper, "piper": piper, "ssh_configured": ssh_configured,