diff --git a/client/hermes-pc/executor.py b/client/hermes-pc/executor.py index 3138f3f..d402843 100644 --- a/client/hermes-pc/executor.py +++ b/client/hermes-pc/executor.py @@ -126,7 +126,24 @@ def _get_local_ip() -> str: return socket.gethostbyname(socket.gethostname()) +def _ensure_streams() -> None: + """Unter pythonw (kein Konsolenfenster) sind sys.stdout/stderr = None → + uvicorns Logging crasht beim Start. Dann auf eine Logdatei umbiegen.""" + import sys + if sys.stdout is not None and sys.stderr is not None: + return + logdir = os.path.join( + os.environ.get("LOCALAPPDATA", os.path.dirname(os.path.abspath(__file__))), + "HermesPCExecutor", + ) + os.makedirs(logdir, exist_ok=True) + f = open(os.path.join(logdir, "executor.log"), "a", buffering=1, encoding="utf-8") + sys.stdout = f + sys.stderr = f + + if __name__ == "__main__": + _ensure_streams() my_ip = _get_local_ip() print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT}") uvicorn.run(app, host="0.0.0.0", port=MY_PORT) diff --git a/mcp/mcp_mc.py b/mcp/mcp_mc.py index bebc7d3..baf188b 100644 --- a/mcp/mcp_mc.py +++ b/mcp/mcp_mc.py @@ -3,12 +3,16 @@ Mission Control 2.0 — Stack-Management MCP Server (für Hermes). Gibt dem Hermes-Agenten Werkzeuge, um den lokalen LLM-Stack SELBST zu steuern: -Modelle ansehen/entdecken/eintragen, Routing setzen, Dienste neu starten, +Modelle ansehen/entdecken/installieren/löschen, Rollen & Routing setzen, Modelle +laden/entladen, Backups, Dienste prüfen/neu starten, Updates prüfen/anwenden, System-Status lesen. Spricht die MC-2-REST-API (/api/*). Damit ist Hermes echte Control-Plane des Stacks (Plan: „MC-Mgmt-MCP, damit Hermes den Stack selbst steuert"). Läuft typischerweise lokal auf der Box neben Hermes. +Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ), +damit kleine Modelle nicht in Aufruf-Schleifen laufen. + Env: MC_URL (default http://127.0.0.1:9001), MC_TOKEN (optional). Install: pip install mcp httpx """ @@ -30,13 +34,13 @@ def _h() -> dict: def _get(path: str, **params): - r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=20) + r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=30) r.raise_for_status() return r.json() -def _post(path: str, data: dict): - r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data, timeout=60) +def _post(path: str, data: dict | None = None): + r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120) r.raise_for_status() return r.json() @@ -47,21 +51,34 @@ def _put(path: str, data: dict): return r.json() +def _delete(path: str): + r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=30) + r.raise_for_status() + return r.json() + + +# ── Modelle: ansehen / entdecken ──────────────────────────────────────────── + @mcp.tool() def list_models() -> str: - """Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten).""" + """Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten) + und welche gerade geladen sind.""" data = _get("/api/models") - return "\n".join( - f"- {m['name']} (Rolle: {m.get('role') or '—'}, ctx: {m.get('ctx')}, " - f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})" - for m in data.get("models", []) - ) or "Keine Modelle konfiguriert." + running = set(data.get("running") or []) + lines = [] + for m in data.get("models", []): + warm = " [LÄUFT]" if m["name"] in running else "" + lines.append( + f"- {m['name']}{warm} (Rolle: {m.get('role') or '—'}, ctx: {m.get('ctx')}, " + f"tools: {m['capabilities']['tools']}, MoE: {m['capabilities']['moe']})" + ) + return "\n".join(lines) or "Keine Modelle konfiguriert." @mcp.tool() def discover_models() -> str: """Zeigt die aktuell besten Modelle je Kategorie (live von HuggingFace, Hardware-Fit). - Nutze dies, bevor du ein neues Modell vorschlägst/einträgst.""" + Nutze dies, bevor du ein neues Modell vorschlägst/installierst.""" data = _get("/api/discover") out = [f"System-RAM: {data.get('sys_ram_gb')} GB"] for c in data.get("categories", []): @@ -73,21 +90,94 @@ def discover_models() -> str: @mcp.tool() -def system_status() -> str: - """Live-Auslastung der Box (CPU/RAM/GPU/Disk).""" - s = _get("/api/system/status") - g = s.get("gpu") or {} - return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · " - f"GPU {g.get('busy_percent', '—')}% · Disk {(s.get('disk') or {}).get('percent', '—')}%") +def hf_search(q: str) -> str: + """Sucht GGUF-Modelle auf HuggingFace nach Stichwort. Nutze dies, wenn du ein konkretes + Modell finden willst, das nicht in discover_models auftaucht.""" + res = _get("/api/hf/search", q=q).get("results", []) + if not res: + return f"Keine HF-Treffer für '{q}'." + return json.dumps(res[:15], ensure_ascii=False, indent=2) @mcp.tool() -def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str: - """Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias). - role z.B. fast/heavy/coder/vision/agent. jinja=True für Tool-Calling (Agent-Hirn).""" - res = _post("/api/models/register", - {"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja}) - return f"Eingetragen als '{res.get('model_id')}'." +def hf_quants(repo: str) -> str: + """Listet die verfügbaren Quantisierungen (Q4_K_M, Q8_0, …) eines HF-Repos. + Nutze dies VOR install_model, um die passende Quant-Stufe zu wählen.""" + d = _get("/api/hf/quants", repo=repo) + return f"Repo {d['repo']} — Quants: " + ", ".join(d.get("quants", [])) or "Keine GGUF-Quants gefunden." + + +# ── Modelle: installieren / löschen / laden ───────────────────────────────── + +@mcp.tool() +def install_model(repo: str, role: str = "", quant: str = "Q4_K_M", + ctx: int = 0, jinja: bool = False) -> str: + """Lädt ein GGUF-Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in + llama-swap ein. Akzeptiert volle HF-URL ODER org/repo. ctx=0 → setup-bewusst automatisch. + jinja=True für Tool-Calling-Modelle (Agent-Hirne). role z.B. fast/heavy/coder/vision. + Gibt eine job_id zurück — Fortschritt via list_jobs prüfen.""" + body = {"repo": repo, "quant": quant, "jinja": jinja} + if role: + body["role"] = role + if ctx > 0: + body["ctx"] = ctx + r = _post("/api/models/install", body) + return (f"Installation gestartet: model_id={r.get('model_id')}, job_id={r.get('job_id')}, " + f"{r.get('files')} Datei(en), ~{round((r.get('total_bytes') or 0)/1e9, 1)} GB. " + f"Fortschritt mit list_jobs prüfen.") + + +@mcp.tool() +def list_jobs() -> str: + """Zeigt laufende/abgeschlossene Hintergrund-Jobs (v.a. Modell-Downloads) mit Fortschritt.""" + jobs = _get("/api/jobs").get("jobs", []) + if not jobs: + return "Keine Jobs." + out = [] + for j in jobs: + prog = j.get("progress") + p = f" {round(prog*100)}%" if isinstance(prog, (int, float)) else "" + out.append(f"- [{j.get('id', '?')[:8]}] {j.get('label', '?')} — {j.get('status', '?')}{p}") + return "\n".join(out) + + +@mcp.tool() +def cancel_job(job_id: str) -> str: + """Bricht einen laufenden Job (z.B. Download) ab.""" + ok = _post(f"/api/jobs/{job_id}/cancel").get("ok") + return "Job abgebrochen." if ok else "Job nicht gefunden / nicht abbrechbar." + + +@mcp.tool() +def delete_model(model_id: str) -> str: + """Entfernt ein Modell aus llama-swap (Eintrag + Dateien). NUR wenn der Nutzer es + will oder es eindeutig veraltet/ersetzt ist — vorher list_models prüfen.""" + _delete(f"/api/models/{model_id}") + return f"Modell '{model_id}' gelöscht." + + +@mcp.tool() +def load_model(model_id: str) -> str: + """Lädt ein Modell aktiv in den Speicher (warm). Nützlich, um Latenz beim ersten + echten Request zu vermeiden.""" + return "Geladen." if _post(f"/api/models/{model_id}/load").get("ok") else "Laden fehlgeschlagen." + + +@mcp.tool() +def unload_model(model_id: str = "") -> str: + """Entlädt ein Modell aus dem Speicher (gibt RAM/GTT frei). Leer = ALLE entladen.""" + path = f"/api/models/{model_id}/unload" if model_id else "/api/models/unload" + return "Entladen." if _post(path).get("ok") else "Entladen fehlgeschlagen." + + +# ── Rollen & Routing ──────────────────────────────────────────────────────── + +@mcp.tool() +def set_model_role(model_id: str, role: str = "") -> str: + """Setzt/entfernt den Rollen-Alias eines Modells (fast/heavy/coder/vision/hermes/…). + Leer = Rolle entfernen. So bestimmst du z.B. welches Modell 'heavy' ist.""" + _post(f"/api/models/{model_id}/role", {"role": role or None}) + return f"Rolle für '{model_id}': {role or '— (entfernt)'}" @mcp.tool() @@ -103,12 +193,93 @@ def routing_overview() -> str: return json.dumps(_get("/api/routing"), ensure_ascii=False, indent=2) +@mcp.tool() +def register_model(model_path: str, role: str = "", ctx: int = 8192, jinja: bool = False) -> str: + """Trägt ein bereits heruntergeladenes GGUF als llama-swap-Modell ein (cmd + Rollen-Alias). + role z.B. fast/heavy/coder/vision/hermes. jinja=True für Tool-Calling (Agent-Hirn). + Für Download+Eintrag in einem Schritt nutze install_model.""" + res = _post("/api/models/register", + {"model_path": model_path, "role": role or None, "ctx": ctx, "jinja": jinja}) + return f"Eingetragen als '{res.get('model_id')}'." + + +# ── System / Status / Wartung ─────────────────────────────────────────────── + +@mcp.tool() +def system_status() -> str: + """Live-Auslastung der Box (CPU/RAM/GPU/Disk).""" + s = _get("/api/system/status") + g = s.get("gpu") or {} + return (f"CPU {s['cpu']['percent']}% · RAM {s['ram']['percent']}% · " + f"GPU {g.get('busy_percent', '—')}% · Disk {(s.get('disk') or {}).get('percent', '—')}%") + + +@mcp.tool() +def list_services() -> str: + """Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice).""" + data = _get("/api/system/services") + return "\n".join( + f"{'✅' if s['ok'] else '❌'} {s['name']} ({s['url']})" + for s in data.get("services", []) + ) or "Keine Dienste." + + @mcp.tool() def restart_service(service: str) -> str: - """Startet einen erlaubten Dienst neu (mission-control-2 | litellm-gateway | hermes-webui).""" + """Startet einen erlaubten Dienst neu + (mission-control-2 | hermes-gateway | hermes-webui | mem0-service | voice-service).""" res = _post("/api/system/restart", {"service": service}) return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}" +@mcp.tool() +def backup_now() -> str: + """Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten + Änderungen oder wenn der Nutzer ein Backup will.""" + r = _post("/api/system/backup") + return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}" + + +@mcp.tool() +def list_backups() -> str: + """Listet vorhandene Backups (Datei, Zeit, Größe).""" + bks = _get("/api/system/backups").get("backups", []) + if not bks: + return "Keine Backups." + return json.dumps(bks, ensure_ascii=False, indent=2) + + +@mcp.tool() +def token_stats() -> str: + """Token-Verbrauch + Cloud-Ersparnis (was die lokale Nutzung gegenüber Cloud-APIs spart).""" + return json.dumps(_get("/api/system/token-stats"), ensure_ascii=False, indent=2) + + +@mcp.tool() +def check_updates() -> str: + """Prüft, ob Updates für OS / Engine (llama.cpp) / Router (llama-swap) / Hermes anstehen.""" + return json.dumps(_get("/api/maintenance/updates"), ensure_ascii=False, indent=2) + + +@mcp.tool() +def apply_update(kind: str) -> str: + """Wendet ein Update an. kind: os | engine | swap | hermes. NUR wenn der Nutzer es will + oder check_updates ein anstehendes Update zeigt. Kann den jeweiligen Dienst kurz neu starten.""" + kind = kind.lower().strip() + routes = {"os": "/api/maintenance/os-update", "engine": "/api/maintenance/engine-update", + "swap": "/api/maintenance/swap-update", "hermes": "/api/maintenance/hermes-update"} + if kind not in routes: + return "Unbekannte Update-Art. Erlaubt: os | engine | swap | hermes." + return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2) + + +@mcp.tool() +def service_logs(service: str, lines: int = 100) -> str: + """Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway, + mission-control-2, voice-service.""" + return json.dumps(_get("/api/maintenance/logs", service=service, lines=lines), + ensure_ascii=False, indent=2) + + if __name__ == "__main__": mcp.run()