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:
Hitonabi
2026-06-25 08:16:12 +02:00
parent 6c8b6d81fe
commit 81468df9c0
17 changed files with 777 additions and 163 deletions
+5 -3
View File
@@ -7,7 +7,7 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui · Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar. Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phase 2 (System/OS + Connect) ✅ ## Status: Phase 3 (Memory + MCP) ✅
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md). Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
@@ -18,9 +18,11 @@ Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei), - **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei),
**Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override). **Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override).
- **Phase 3** — Geteiltes **Gedächtnis** (SQLite/WAL, 5 Kategorien, Dedupe-Kurator) + **MCP-Server**
(`mcp/mcp_memory.py` shared, `mcp/mcp_mc.py` Stack-Management für Hermes), MemoryView.
API: `health · models · discover · fit · models/register · groups · routing · system/status · API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
system/restart · system/self-update · connect` (Details in `docs/STATUS.md`). memory/*` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
## Entwickeln ## Entwickeln
+2 -1
View File
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.requests import Request from starlette.requests import Request
from config import FRONTEND_DIST, VERSION from config import FRONTEND_DIST, VERSION
from routers import connect, health, models, routing, system from routers import connect, health, memory, models, routing, system
app = FastAPI(title="Mission Control 2.0", version=VERSION) app = FastAPI(title="Mission Control 2.0", version=VERSION)
@@ -39,6 +39,7 @@ app.include_router(models.router)
app.include_router(routing.router) app.include_router(routing.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(connect.router) app.include_router(connect.router)
app.include_router(memory.router)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+3 -1
View File
@@ -19,6 +19,8 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. # Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json"))) DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen. # Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
_DEFAULT_CMD_TEMPLATE = ( _DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} " "llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
@@ -44,7 +46,7 @@ PORT = int(os.environ.get("MC_PORT", "9000"))
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist"))) FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton). # Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase2" VERSION = "2.0.0-phase3"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). # Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML() yaml = YAML()
+61
View File
@@ -0,0 +1,61 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "stable"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
+149
View File
@@ -0,0 +1,149 @@
"""
Geteiltes Gedächtnis (die „Verfassung") — SQLite aus stdlib, WAL-Mode.
Portiert aus Mission Control v1 (routers/memory.py), DB-Logik als Service isoliert.
5 Kategorien: user · instruction · stable · versioned · ephemeral (7-Tage-TTL).
Dedupe = deterministischer Kurator (exakt/enthalten/ähnlich), KEIN LLM.
"""
import re
import sqlite3
import uuid
from datetime import datetime, timezone
from difflib import SequenceMatcher
from config import MEMORY_DB
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
_conn: sqlite3.Connection | None = None
def db() -> sqlite3.Connection:
global _conn
if _conn is None:
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'stable',
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
_conn.execute(
"DELETE FROM memories WHERE category='ephemeral'"
" AND datetime(created_at) < datetime('now','-7 days')"
)
_conn.commit()
return _conn
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def list_memories(q: str = "", category: str = "") -> list[dict]:
sql, params, conds = "SELECT * FROM memories", [], []
if q:
conds.append("content LIKE ?"); params.append(f"%{q}%")
if category:
conds.append("category = ?"); params.append(category)
if conds:
sql += " WHERE " + " AND ".join(conds)
sql += " ORDER BY created_at DESC"
return [dict(r) for r in db().execute(sql, params).fetchall()]
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
now, mid = _now(), str(uuid.uuid4())
db().execute(
"INSERT INTO memories (id,content,category,source,created_at,updated_at) VALUES (?,?,?,?,?,?)",
(mid, content.strip(), category, source, now, now),
)
db().commit()
return {"id": mid, "content": content.strip(), "category": category,
"source": source, "created_at": now, "updated_at": now}
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
row = db().execute("SELECT * FROM memories WHERE id=?", (mid,)).fetchone()
if not row:
return None
now = _now()
new_content = content.strip() if content is not None else row["content"]
new_cat = category if category is not None else row["category"]
db().execute("UPDATE memories SET content=?,category=?,updated_at=? WHERE id=?",
(new_content, new_cat, now, mid))
db().commit()
return {"id": mid, "content": new_content, "category": new_cat,
"source": row["source"], "created_at": row["created_at"], "updated_at": now}
def delete_memory(mid: str) -> bool:
if not db().execute("SELECT id FROM memories WHERE id=?", (mid,)).fetchone():
return False
db().execute("DELETE FROM memories WHERE id=?", (mid,))
db().commit()
return True
def export_text() -> dict:
rows = db().execute("SELECT * FROM memories ORDER BY category, updated_at DESC").fetchall()
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r["category"] != current:
lines.append(f"\n## {r['category']}\n")
current = r["category"]
lines.append(f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_")
return {"text": "\n".join(lines), "count": len(rows)}
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Deterministischer Kurator: findet Dubletten (exakt/enthalten/ähnlich) je Kategorie,
behält den vollständigsten (längsten) Eintrag. Konservativ, kein LLM."""
rows = [dict(r) for r in db().execute(
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC").fetchall()]
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a["content"])
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b["category"] != a["category"]:
continue
nb = _norm(b["content"])
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]},
"remove": [{"id": d["id"], "content": d["content"]} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
db().execute("DELETE FROM memories WHERE id=?", (d["id"],)); removed += 1
if removed:
db().commit()
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
+11 -4
View File
@@ -17,7 +17,10 @@
- [x] **Phase 2 — System/OS + Connect:** System-Status (psutil CPU/RAM/Disk, sysfs GPU/Temp guarded), - [x] **Phase 2 — System/OS + Connect:** System-Status (psutil CPU/RAM/Disk, sysfs GPU/Temp guarded),
Wartung (restart/self-update als systemd-USER-Dienst, sudo-frei), Connect-Snippets (Cline/OpenCode/ Wartung (restart/self-update als systemd-USER-Dienst, sudo-frei), Connect-Snippets (Cline/OpenCode/
Zed/Continue/Claude Code/Memory-MCP → Gateway `model:auto` + LAN-IP-Override). Lokal verifiziert. Zed/Continue/Claude Code/Memory-MCP → Gateway `model:auto` + LAN-IP-Override). Lokal verifiziert.
- [ ] **Phase 3 — Memory + MCP** (Governance-UI, mcp_memory.py portiert, mcp_mc.py neu). - [x] **Phase 3 — Memory + MCP:** Memory-Service (SQLite/WAL, 5 Kategorien, Dedupe-Kurator), Router
(CRUD/export/dedupe), `mcp/mcp_memory.py` (Guard-Beschreibungen gegen Loop) + `mcp/mcp_mc.py` NEU
(Stack-Management-Tools für Hermes: list/discover/register/route/restart/status). Frontend
MemoryView (Add/Filter/Suche/Delete/Aufräumen). Lokal verifiziert (CRUD+Dedupe; MCP syntax-OK).
- [ ] **Phase 4 — Hermes-Schicht** (hermes-webui Dienst, Brain=auto, Tools/MCP verdrahten). **Braucht Box.** - [ ] **Phase 4 — Hermes-Schicht** (hermes-webui Dienst, Brain=auto, Tools/MCP verdrahten). **Braucht Box.**
- [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health). - [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
- [ ] **Phase 6 — Cutover** (/opt → v2, v1 aus). **Braucht Box.** - [ ] **Phase 6 — Cutover** (/opt → v2, v1 aus). **Braucht Box.**
@@ -45,8 +48,12 @@ cd frontend && npm run build
- `GET /api/system/status` — CPU/RAM/GPU/Disk/Temp - `GET /api/system/status` — CPU/RAM/GPU/Disk/Temp
- `POST /api/system/restart` (Whitelist), `POST /api/system/self-update` — Wartung (Box, sudo-frei) - `POST /api/system/restart` (Whitelist), `POST /api/system/self-update` — Wartung (Box, sudo-frei)
- `GET /api/connect?host=` — IDE-/Agent-Snippets (Gateway + Memory-MCP) - `GET /api/connect?host=` — IDE-/Agent-Snippets (Gateway + Memory-MCP)
- `GET/POST/PUT/DELETE /api/memory[...]` + `/api/memory/export` + `/api/memory/dedupe` — geteiltes Gedächtnis
- `mcp/mcp_memory.py` (geteiltes Memory) + `mcp/mcp_mc.py` (Stack-Management für Hermes) — stdio-MCP
## Nächster sinnvoller Schritt ## Nächster sinnvoller Schritt
Phase 3 (Memory + MCP) lokal bauen: Memory-Governance-UI, `mcp_memory.py` portieren (Shared SQLite), **Phase 4 (Hermes-Schicht) braucht die Box** — hermes-webui als Dienst, Brain=model:auto, Tools/MCP
`mcp_mc.py` neu (Stack-Management-Tools für Hermes). **Oder** Box-Deploy einrichten (systemd-USER-Dienst, verdrahten (FS/Shell/SSH→Win/Netz + mcp_mc + mcp_memory), LiteLLM-Caveat verifizieren. Vorher sinnvoll:
sudo-frei). Beim Box-Deploy: `deploy/deploy.sh` vorher auf User-Dienst umstellen (kein /opt/sudo). **Box-Deploy** einrichten (systemd-USER-Dienst, sudo-frei; `deploy/deploy.sh` auf User-Dienst umstellen,
kein /opt/sudo) + **LiteLLM-Gateway** auf der Box starten und `model:auto`/Complexity-Router verifizieren.
Lokal noch machbar ohne Box: Phase-4-Frontend (Agent-Status/Verdrahtungs-Anzeige + „Hermes öffnen").
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CzNBzGRb.js"></script> <script type="module" crossorigin src="/assets/index-ByXSUESS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSNYfW6y.css"> <link rel="stylesheet" crossorigin href="/assets/index-CE_qxW4X.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -1
View File
@@ -6,6 +6,7 @@ import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView" import { RoutingView } from "@/views/RoutingView"
import { SystemView } from "@/views/SystemView" import { SystemView } from "@/views/SystemView"
import { ConnectView } from "@/views/ConnectView" import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api" import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -94,7 +95,8 @@ export default function App() {
{view === "routing" && <RoutingView />} {view === "routing" && <RoutingView />}
{view === "system" && <SystemView />} {view === "system" && <SystemView />}
{view === "connect" && <ConnectView />} {view === "connect" && <ConnectView />}
{!["models", "routing", "system", "connect"].includes(view) && ( {view === "memory" && <MemoryView />}
{!["models", "routing", "system", "connect", "memory"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} /> <Placeholder title={active.label} hint={active.hint} />
)} )}
</main> </main>
+16
View File
@@ -100,6 +100,22 @@ export interface ConnectResp {
tools: Record<string, ConnectTool> tools: Record<string, ConnectTool>
} }
export interface Memory {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
}
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
removed: number
applied: boolean
}
export interface Health { export interface Health {
status: string status: string
version: string version: string
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react"
import { Trash2, Sparkles } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
const CAT_LABEL: Record<string, string> = {
user: "👤 User", instruction: "📋 Regel", stable: "🔵 Fakt",
versioned: "🟡 Version", ephemeral: "⏱ Temporär",
}
export function MemoryView() {
const [items, setItems] = useState<Memory[]>([])
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("stable")
const [error, setError] = useState("")
function load() {
const params = new URLSearchParams()
if (q) params.set("q", q)
if (filter) params.set("category", filter)
api<Memory[]>(`/api/memory?${params}`).then(setItems).catch((e) => setError(String(e)))
}
useEffect(load, [q, filter])
async function add() {
if (!content.trim()) return
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
setContent("")
load()
}
async function del(id: string) {
await api(`/api/memory/${id}`, { method: "DELETE" })
load()
}
async function cleanup() {
const dry = await api<DedupeResult>("/api/memory/dedupe", {
method: "POST", body: JSON.stringify({ apply: false }),
})
if (dry.duplicate_count === 0) {
alert("Keine Dubletten gefunden — alles sauber.")
return
}
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) })
load()
}
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Gedächtnis</h1>
<p className="text-sm text-muted-foreground">
Die geteilte Verfassung" alle Tools (Hermes, IDEs) lesen/schreiben hier via MCP.
</p>
</div>
<button
onClick={cleanup}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary" /> Aufräumen
</button>
</div>
{/* Add */}
<div className="rounded-xl border border-border bg-card p-3">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Neuen Fakt / Regel hinzufügen…"
rows={2}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div className="mt-2 flex items-center gap-2">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CAT_LABEL[c]}</option>
))}
</select>
<button onClick={add} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
Speichern
</button>
</div>
</div>
{/* Filter */}
<div className="flex flex-wrap items-center gap-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={() => setFilter("")}
className={cn("rounded-md px-2.5 py-1.5 text-xs", !filter ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
>
Alle
</button>
{CATEGORIES.map((c) => (
<button
key={c}
onClick={() => setFilter(c)}
className={cn("rounded-md px-2.5 py-1.5 text-xs", filter === c ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
>
{CAT_LABEL[c]}
</button>
))}
</div>
{error && <div className="text-sm text-muted-foreground">Fehler: {error}</div>}
{/* List */}
<div className="space-y-2">
{items.length === 0 && <div className="text-sm text-muted-foreground">Keine Einträge.</div>}
{items.map((m) => (
<div key={m.id} className="flex items-start gap-3 rounded-lg border border-border bg-card p-3">
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{CAT_LABEL[m.category] || m.category}
</span>
<span className="flex-1 text-sm">{m.content}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{m.source}</span>
<button onClick={() => del(m.id)} className="shrink-0 text-muted-foreground hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)
}
+114
View File
@@ -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()
+108
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
# MCP-Server (auf dem Rechner installieren, wo das Tool/Hermes läuft)
mcp>=1.2
httpx>=0.27