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:
@@ -7,7 +7,7 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
|
||||
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
|
||||
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).
|
||||
|
||||
@@ -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),
|
||||
**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 ·
|
||||
system/restart · system/self-update · connect` (Details in `docs/STATUS.md`).
|
||||
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
|
||||
memory/*` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
|
||||
|
||||
## Entwickeln
|
||||
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import Request
|
||||
|
||||
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)
|
||||
|
||||
@@ -39,6 +39,7 @@ app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(connect.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
|
||||
+3
-1
@@ -19,6 +19,8 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# 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_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.
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"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")))
|
||||
|
||||
# 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).
|
||||
yaml = YAML()
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -17,7 +17,10 @@
|
||||
- [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/
|
||||
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 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
|
||||
- [ ] **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
|
||||
- `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/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
|
||||
Phase 3 (Memory + MCP) lokal bauen: Memory-Governance-UI, `mcp_memory.py` portieren (Shared SQLite),
|
||||
`mcp_mc.py` neu (Stack-Management-Tools für Hermes). **Oder** Box-Deploy einrichten (systemd-USER-Dienst,
|
||||
sudo-frei). Beim Box-Deploy: `deploy/deploy.sh` vorher auf User-Dienst umstellen (kein /opt/sudo).
|
||||
**Phase 4 (Hermes-Schicht) braucht die Box** — hermes-webui als Dienst, Brain=model:auto, Tools/MCP
|
||||
verdrahten (FS/Shell/SSH→Win/Netz + mcp_mc + mcp_memory), LiteLLM-Caveat verifizieren. Vorher sinnvoll:
|
||||
**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").
|
||||
|
||||
+160
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-150
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-CzNBzGRb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DSNYfW6y.css">
|
||||
<script type="module" crossorigin src="/assets/index-ByXSUESS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CE_qxW4X.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelsView } from "@/views/ModelsView"
|
||||
import { RoutingView } from "@/views/RoutingView"
|
||||
import { SystemView } from "@/views/SystemView"
|
||||
import { ConnectView } from "@/views/ConnectView"
|
||||
import { MemoryView } from "@/views/MemoryView"
|
||||
import { Placeholder } from "@/views/Placeholder"
|
||||
import { api, type Health } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -94,7 +95,8 @@ export default function App() {
|
||||
{view === "routing" && <RoutingView />}
|
||||
{view === "system" && <SystemView />}
|
||||
{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} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -100,6 +100,22 @@ export interface ConnectResp {
|
||||
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 {
|
||||
status: string
|
||||
version: string
|
||||
|
||||
@@ -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
@@ -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