feat: lower memory dedupe threshold for more aggressive cleaning
This commit is contained in:
+326
-326
@@ -1,326 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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/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
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
|
||||
MC_TOKEN = os.environ.get("MC_TOKEN", "")
|
||||
|
||||
mcp = FastMCP("mission-control-stack")
|
||||
|
||||
|
||||
def _h() -> dict:
|
||||
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
|
||||
|
||||
|
||||
def _get(path: str, **params):
|
||||
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 | None = None):
|
||||
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict):
|
||||
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
|
||||
r.raise_for_status()
|
||||
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)
|
||||
und welche gerade geladen sind."""
|
||||
data = _get("/api/models")
|
||||
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/installierst."""
|
||||
data = _get("/api/discover")
|
||||
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
|
||||
for c in data.get("categories", []):
|
||||
rec = c.get("recommended") or "—"
|
||||
out.append(f"\n## {c['title']} (beste Wahl: {rec})")
|
||||
for m in c["models"][:3]:
|
||||
out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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 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()
|
||||
def set_route(name: str, target_alias: str) -> str:
|
||||
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
|
||||
_put("/api/routing/route", {"name": name, "target_alias": target_alias})
|
||||
return f"Routing gesetzt: {name} → {target_alias}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def routing_overview() -> str:
|
||||
"""Zeigt das aktuelle Gateway-Routing + Fallbacks."""
|
||||
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. Erlaubt: llama-swap (Engine) | mission-control-2 |
|
||||
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart
|
||||
(llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
|
||||
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)
|
||||
|
||||
|
||||
# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ─────────
|
||||
@mcp.tool()
|
||||
def reminder_create(text: str, when: str, repeat: str = "") -> str:
|
||||
"""Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV
|
||||
an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden
|
||||
will ('erinner mich', 'sag mir um', 'jeden Morgen').
|
||||
when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00.
|
||||
Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext
|
||||
umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (Mo–Fr) | weekly (wöchentlich).
|
||||
text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor — das ergänzt die Box)."""
|
||||
try:
|
||||
item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"]
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
|
||||
rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "")
|
||||
return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}“"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reminder_list() -> str:
|
||||
"""Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen
|
||||
hiermit die Nummer ermitteln."""
|
||||
items = _get("/api/reminders")["items"]
|
||||
if not items:
|
||||
return "Keine Erinnerungen angelegt."
|
||||
rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"}
|
||||
return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')} — {i['text']}" for i in items)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reminder_delete(reminder_id: int) -> str:
|
||||
"""Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf
|
||||
ausdrücklichen Wunsch des Commanders."""
|
||||
try:
|
||||
gone = _delete(f"/api/reminders/{reminder_id}")["deleted"]
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
|
||||
return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)."
|
||||
|
||||
|
||||
@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()
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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/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
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
|
||||
MC_TOKEN = os.environ.get("MC_TOKEN", "")
|
||||
|
||||
mcp = FastMCP("mission-control-stack")
|
||||
|
||||
|
||||
def _h() -> dict:
|
||||
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
|
||||
|
||||
|
||||
def _get(path: str, **params):
|
||||
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 | None = None):
|
||||
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data or {}, timeout=120)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict):
|
||||
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=20)
|
||||
r.raise_for_status()
|
||||
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)
|
||||
und welche gerade geladen sind."""
|
||||
data = _get("/api/models")
|
||||
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/installierst."""
|
||||
data = _get("/api/discover")
|
||||
out = [f"System-RAM: {data.get('sys_ram_gb')} GB"]
|
||||
for c in data.get("categories", []):
|
||||
rec = c.get("recommended") or "—"
|
||||
out.append(f"\n## {c['title']} (beste Wahl: {rec})")
|
||||
for m in c["models"][:3]:
|
||||
out.append(f" - {m['repo']} · {m['fit']['text']} (~{m['fit']['req_gb']} GB)")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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 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()
|
||||
def set_route(name: str, target_alias: str) -> str:
|
||||
"""Setzt das Gateway-Routing: Gateway-Modellname (fast/heavy/auto/…) → llama-swap-Alias."""
|
||||
_put("/api/routing/route", {"name": name, "target_alias": target_alias})
|
||||
return f"Routing gesetzt: {name} → {target_alias}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def routing_overview() -> str:
|
||||
"""Zeigt das aktuelle Gateway-Routing + Fallbacks."""
|
||||
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. Erlaubt: llama-swap (Engine) | mission-control-2 |
|
||||
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart
|
||||
(llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
|
||||
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)
|
||||
|
||||
|
||||
# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ─────────
|
||||
@mcp.tool()
|
||||
def reminder_create(text: str, when: str, repeat: str = "") -> str:
|
||||
"""Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV
|
||||
an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden
|
||||
will ('erinner mich', 'sag mir um', 'jeden Morgen').
|
||||
when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00.
|
||||
Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext
|
||||
umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (Mo–Fr) | weekly (wöchentlich).
|
||||
text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor — das ergänzt die Box)."""
|
||||
try:
|
||||
item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"]
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
|
||||
rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "")
|
||||
return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}“"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reminder_list() -> str:
|
||||
"""Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen
|
||||
hiermit die Nummer ermitteln."""
|
||||
items = _get("/api/reminders")["items"]
|
||||
if not items:
|
||||
return "Keine Erinnerungen angelegt."
|
||||
rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"}
|
||||
return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')} — {i['text']}" for i in items)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reminder_delete(reminder_id: int) -> str:
|
||||
"""Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf
|
||||
ausdrücklichen Wunsch des Commanders."""
|
||||
try:
|
||||
gone = _delete(f"/api/reminders/{reminder_id}")["deleted"]
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}"
|
||||
return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)."
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user