feat(2.0): Phase 3 — Memory + MCP
Geteiltes Gedaechtnis: services/memory.py (SQLite/WAL, 5 Kategorien, Dedupe-Kurator deterministisch), routers/memory.py (CRUD/export/dedupe). MCP: mcp/mcp_memory.py (Guard-Beschreibungen gegen 14B-Loop) + mcp/mcp_mc.py NEU (Stack-Management fuer Hermes: list/discover/register/route/restart/ status). Frontend MemoryView (Add/Filter/Suche/Delete/Aufraeumen). Lokal verifiziert: CRUD + Dedupe (TestClient), MCP-Server syntax-OK, Frontend-Build + Browser (MemoryView). Docs aktualisiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
#!/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/eintragen, Routing setzen, Dienste neu starten,
|
||||
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.
|
||||
|
||||
Env: MC_URL (default http://127.0.0.1:9000), 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:9000").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=20)
|
||||
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)
|
||||
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()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_models() -> str:
|
||||
"""Listet die in llama-swap konfigurierten Modelle (Name, Rolle, Kontext, Fähigkeiten)."""
|
||||
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."
|
||||
|
||||
|
||||
@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."""
|
||||
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 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 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')}'."
|
||||
|
||||
|
||||
@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 restart_service(service: str) -> str:
|
||||
"""Startet einen erlaubten Dienst neu (mission-control-2 | litellm-gateway | hermes-webui)."""
|
||||
res = _post("/api/system/restart", {"service": service})
|
||||
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mission Control 2.0 — Memory MCP Server (geteiltes Gedächtnis für ALLE Tools).
|
||||
|
||||
Stdio-MCP-Server. Läuft als Subprocess von Cline/Claude Code/OpenCode/Hermes.
|
||||
Ruft die /api/memory-Endpunkte von Mission Control via HTTP auf.
|
||||
|
||||
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
|
||||
damit kleine Modelle (z.B. Hermes 14B) nicht in Aufruf-Schleifen laufen.
|
||||
|
||||
Env: MC_URL (default http://192.168.178.151:9000), MC_TOKEN (optional).
|
||||
Install (auf dem Rechner des Tools): pip install mcp httpx
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://192.168.178.151:9000").rstrip("/")
|
||||
MC_TOKEN = os.environ.get("MC_TOKEN", "")
|
||||
|
||||
mcp = FastMCP("mission-control-memory")
|
||||
|
||||
|
||||
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=10)
|
||||
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=10)
|
||||
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=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _delete(path: str):
|
||||
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_memories(category: str = "") -> str:
|
||||
"""Lädt gespeicherte Fakten/Entscheidungen aus dem geteilten Gedächtnis.
|
||||
Nutze dies EINMAL am Session-Beginn, wenn du Projekt-Kontext brauchst — nicht wiederholt.
|
||||
category: user | instruction | stable | versioned | ephemeral | (leer = alle)"""
|
||||
items = _get("/api/memory", **({"category": category} if category else {}))
|
||||
if not items:
|
||||
return "Keine Memories gespeichert."
|
||||
icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": "⏱", "user": "👤", "instruction": "📋"}
|
||||
return "\n".join(
|
||||
f"{icon.get(m['category'], '·')} [{m['category']}] {m['content']} (ID: {m['id'][:8]})"
|
||||
for m in items
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_memories(q: str) -> str:
|
||||
"""Sucht per Stichwort in den Fakten. Nutze dies VOR add_memory (Dubletten-Check)
|
||||
oder wenn du eine konkrete frühere Entscheidung suchst."""
|
||||
items = _get("/api/memory", q=q)
|
||||
if not items:
|
||||
return f"Keine Treffer für '{q}'."
|
||||
return "\n".join(f"[{m['category']}] {m['content']} (ID: {m['id'][:8]})" for m in items)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_memory(content: str, category: str = "stable", source: str = "agent") -> str:
|
||||
"""Speichert EINEN dauerhaften Fakt im geteilten Gedächtnis (alle Tools sehen ihn).
|
||||
Nur aufrufen, WENN gerade etwas Dauerhaftes entstanden ist (Konvention, Architektur-
|
||||
Entscheidung, Tech-Version, Nutzer-Präferenz) UND es noch nicht existiert (vorher
|
||||
search_memories!). Knapp & atomar. Existiert ein passender Eintrag → update_memory.
|
||||
category: user | instruction | stable | versioned | ephemeral"""
|
||||
m = _post("/api/memory", {"content": content, "category": category, "source": source})
|
||||
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_memory(memory_id: str, content: str = "", category: str = "") -> str:
|
||||
"""Aktualisiert einen bestehenden Eintrag (nur gesetzte Felder)."""
|
||||
data = {k: v for k, v in (("content", content), ("category", category)) if v}
|
||||
if not data:
|
||||
return "Nichts zu aktualisieren."
|
||||
m = _put(f"/api/memory/{memory_id}", data)
|
||||
return f"Aktualisiert: {m['content']}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_memory(memory_id: str) -> str:
|
||||
"""Löscht einen veralteten/falschen Eintrag anhand seiner ID."""
|
||||
_delete(f"/api/memory/{memory_id}")
|
||||
return f"Eintrag {memory_id[:8]} gelöscht."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP-Server (auf dem Rechner installieren, wo das Tool/Hermes läuft)
|
||||
mcp>=1.2
|
||||
httpx>=0.27
|
||||
Reference in New Issue
Block a user