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:
+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}
|
||||
Reference in New Issue
Block a user