Feat: Agent-Memory auf Mem0 (auto-lernend, semantisch) via Sidecar

Paket A des Plans. Ersetzt die flache SQLite-Fakten-DB durch Mem0 (LLM-Auto-
Extraktion + Vektor/Chroma-Suche). Architektur erzwungen durch Python-Split:
MC2-Backend laeuft auf 3.14 (kann mem0 nicht importieren), mem0+chromadb nur
auf 3.12 (~/.mem0/venv) -> Mem0-Sidecar (FastAPI, localhost:8765), MC2 spricht
ihn per HTTP. /api/memory-Form bleibt unveraendert (UI + MCP kompatibel).

- mem0_service/: Sidecar (app.py), Migration (migrate.py), deps.
  - Embeddings: neue llama-swap embed-Rolle (Qwen3-Embedding-0.6B, 1024 Dim,
    pooling last) ueber /v1/embeddings.
  - LLM-Extraktion: lokales fast-Hirn; NoThinkLLM schaltet Qwen3-Thinking ab
    (sonst bricht json_object-Extraktion ab), custom_instructions halten Deutsch.
- backend/services/memory.py: duenner HTTP-Client auf den Sidecar (semantische
  Suche mit score, verbatim add, learn()). Router: /api/memory/learn.
- mcp/mcp_memory.py: neues learn-Tool (Auto-Lernen aus Gespraechs-Turns),
  search jetzt semantisch.
- Frontend: Relevanz-Score + Auto/Manuell-Herkunft im Gedaechtnis-Tab.
- deploy/: mem0-service.service + deploy.sh (uv-Install, Migration, Restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 19:24:22 +02:00
parent 019f08093d
commit 05bef8642d
13 changed files with 590 additions and 185 deletions
+5
View File
@@ -20,7 +20,12 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
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. # Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db"))) MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
# 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.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server — # Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching # llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
+14
View File
@@ -24,11 +24,25 @@ class DedupeIn(BaseModel):
threshold: float = 0.85 threshold: float = 0.85
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "stable"
@router.get("/memory/export") @router.get("/memory/export")
def export() -> dict: def export() -> dict:
return memory.export_text() return memory.export_text()
@router.post("/memory/learn", status_code=201)
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
return memory.learn(text=body.text, messages=body.messages,
source=body.source, category=body.category)
@router.post("/memory/dedupe") @router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict: def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold) return memory.dedupe(apply=body.apply, threshold=body.threshold)
+77 -84
View File
@@ -1,133 +1,127 @@
""" """
Geteiltes Gedächtnis (die „Verfassung") — SQLite aus stdlib, WAL-Mode. Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
Portiert aus Mission Control v1 (routers/memory.py), DB-Logik als Service isoliert.
5 Kategorien: user · instruction · stable · versioned · ephemeral (7-Tage-TTL). Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
Dedupe = deterministischer Kurator (exakt/enthalten/ähnlich), KEIN LLM. läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
""" """
import re import re
import sqlite3
import uuid
from datetime import datetime, timezone
from difflib import SequenceMatcher from difflib import SequenceMatcher
from config import MEMORY_DB import httpx
from config import MEM0_SERVICE_URL
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral") CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
_conn: sqlite3.Connection | None = None _TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
def db() -> sqlite3.Connection: def _get(path: str, **params) -> list | dict:
global _conn r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
if _conn is None: r.raise_for_status()
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True) return r.json()
_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: def _post(path: str, data: dict) -> dict:
return datetime.now(timezone.utc).isoformat() r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict) -> dict:
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _delete(path: str) -> dict:
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def list_memories(q: str = "", category: str = "") -> list[dict]: def list_memories(q: str = "", category: str = "") -> list[dict]:
sql, params, conds = "SELECT * FROM memories", [], [] """Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
if q: return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
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: def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
now, mid = _now(), str(uuid.uuid4()) """Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
db().execute( return _post("/memory", {"content": content.strip(), "category": category, "source": source})
"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: 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() try:
if not row: return _put(f"/memory/{mid}", {"content": content, "category": category})
return None except httpx.HTTPStatusError as exc:
now = _now() if exc.response.status_code == 404:
new_content = content.strip() if content is not None else row["content"] return None
new_cat = category if category is not None else row["category"] raise
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: def delete_memory(mid: str) -> bool:
if not db().execute("SELECT id FROM memories WHERE id=?", (mid,)).fetchone(): try:
return False _delete(f"/memory/{mid}")
db().execute("DELETE FROM memories WHERE id=?", (mid,)) return True
db().commit() except httpx.HTTPStatusError as exc:
return True if exc.response.status_code == 404:
return False
raise
def learn(text: str | None = None, messages: list[dict] | None = None,
source: str = "auto", category: str = "stable") -> dict:
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
return _post("/learn", {"text": text, "messages": messages,
"source": source, "category": category})
def export_text() -> dict: def export_text() -> dict:
rows = db().execute("SELECT * FROM memories ORDER BY category, updated_at DESC").fetchall() rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"] lines = ["# Mission Control — Gedächtnis\n"]
current = "" current = ""
for r in rows: for r in rows:
if r["category"] != current: if r.get("category") != current:
lines.append(f"\n## {r['category']}\n") current = r.get("category", "")
current = r["category"] lines.append(f"\n## {current}\n")
lines.append(f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_") src = r.get("source", "")
when = (r.get("updated_at") or "")[:10]
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
return {"text": "\n".join(lines), "count": len(rows)} return {"text": "\n".join(lines), "count": len(rows)}
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
def _norm(s: str) -> str: def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE) s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip() return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict: def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Deterministischer Kurator: findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, """Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
behält den vollständigsten (längsten) Eintrag. Konservativ, kein LLM.""" Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
rows = [dict(r) for r in db().execute( manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC").fetchall()] rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
used: set[str] = set() used: set[str] = set()
groups: list[dict] = [] groups: list[dict] = []
for i, a in enumerate(rows): for i, a in enumerate(rows):
if a["id"] in used: if a["id"] in used:
continue continue
na = _norm(a["content"]) na = _norm(a.get("content", ""))
if not na: if not na:
continue continue
dups = [] dups = []
for b in rows[i + 1:]: for b in rows[i + 1:]:
if b["id"] in used or b["category"] != a["category"]: if b["id"] in used or b.get("category") != a.get("category"):
continue continue
nb = _norm(b["content"]) nb = _norm(b.get("content", ""))
if not nb: if not nb:
continue continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold: if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
@@ -135,15 +129,14 @@ def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
if dups: if dups:
used.add(a["id"]) used.add(a["id"])
groups.append({ groups.append({
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]}, "keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
"remove": [{"id": d["id"], "content": d["content"]} for d in dups], "remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
}) })
dup_count = sum(len(g["remove"]) for g in groups) dup_count = sum(len(g["remove"]) for g in groups)
removed = 0 removed = 0
if apply: if apply:
for g in groups: for g in groups:
for d in g["remove"]: for d in g["remove"]:
db().execute("DELETE FROM memories WHERE id=?", (d["id"],)); removed += 1 if delete_memory(d["id"]):
if removed: removed += 1
db().commit()
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply} return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
+17
View File
@@ -19,16 +19,33 @@ fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip "$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt" "$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
else
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
fi
# systemd-USER-Units installieren/aktualisieren # systemd-USER-Units installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user" mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service" cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
# Hermes-Terminal (ttyd -> `hermes chat`), in MC2 als Terminal-Seite eingebettet. # Hermes-Terminal (ttyd -> `hermes chat`), in MC2 als Terminal-Seite eingebettet.
# Einmalig manuell noetig: `sudo apt install -y ttyd` + `sudo systemctl disable --now ttyd` (apt-Default-Dienst). # Einmalig manuell noetig: `sudo apt install -y ttyd` + `sudo systemctl disable --now ttyd` (apt-Default-Dienst).
cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.service" cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable hermes-terminal >/dev/null 2>&1 || true systemctl --user enable hermes-terminal >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
systemctl --user restart mission-control-2 systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/dev/null || true command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/dev/null || true
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=MC2 Mem0 Sidecar — auto-lernendes, semantisches Gedächtnis (mem0 + chroma)
Documentation=https://github.com/mem0ai/mem0
After=network.target
[Service]
# Mem0 + chromadb laufen nur unter Python 3.12 (~/.mem0/venv) — das MC2-Backend (3.14)
# kann sie nicht importieren. Darum dieser schlanke Sidecar; MC2 spricht ihn per HTTP an.
# Bind 127.0.0.1: nur lokal erreichbar (MC2 proxyt nach außen).
Type=simple
WorkingDirectory=%h/mission-control-v2/mem0_service
Environment=MEM0_PORT=8765
Environment=MEM0_CHROMA_PATH=/srv/models/mem0/chroma
Environment=MEM0_HISTORY_DB=/srv/models/mem0/history.db
Environment=MEM0_EMBED_URL=http://127.0.0.1:8080/v1
Environment=MEM0_LLM_URL=http://127.0.0.1:8080/v1
Environment=MEM0_EMBED_MODEL=embed
Environment=MEM0_LLM_MODEL=fast
Environment=MEM0_EMBED_DIMS=1024
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=%h/.mem0/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8765
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BtXN9NH2.js"></script> <script type="module" crossorigin src="/assets/index-BohGf1r3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SuFuJOVE.css"> <link rel="stylesheet" crossorigin href="/assets/index-SuFuJOVE.css">
</head> </head>
<body> <body>
+1
View File
@@ -267,6 +267,7 @@ export interface Memory {
source: string source: string
created_at: string created_at: string
updated_at: string updated_at: string
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
} }
export interface DedupeResult { export interface DedupeResult {
+22 -2
View File
@@ -8,6 +8,9 @@ import { cn } from "@/lib/utils"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"] const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
// Herkunft: automatisch gelernt (Mem0-Extraktion / Agent) vs. manuell angelegt.
const AUTO_SOURCES = new Set(["auto", "agent"])
const CAT_CONFIG: Record<string, { label: string; icon: any; color: string; border: string; bg: string; text: string }> = { const CAT_CONFIG: Record<string, { label: string; icon: any; color: string; border: string; bg: string; text: string }> = {
user: { label: "User", icon: User, color: "text-cyan-400", border: "border-cyan-500/30", bg: "bg-cyan-500/10", text: "text-cyan-400" }, user: { label: "User", icon: User, color: "text-cyan-400", border: "border-cyan-500/30", bg: "bg-cyan-500/10", text: "text-cyan-400" },
instruction: { label: "Regel", icon: Scroll, color: "text-violet-400", border: "border-violet-500/30", bg: "bg-violet-500/10", text: "text-violet-400" }, instruction: { label: "Regel", icon: Scroll, color: "text-violet-400", border: "border-violet-500/30", bg: "bg-violet-500/10", text: "text-violet-400" },
@@ -154,7 +157,7 @@ export function MemoryView() {
<input <input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Gedächtnis durchsuchen..." placeholder="Semantisch durchsuchen (nach Bedeutung, nicht nur Stichwort)..."
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground" className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/> />
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
@@ -228,7 +231,24 @@ export function MemoryView() {
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-[9px] font-mono text-muted-foreground/60 bg-background/20 px-1.5 py-0.5 rounded uppercase tracking-wider"> {typeof m.score === "number" && (
<span
className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded uppercase tracking-wider"
title="Relevanz der semantischen Suche"
>
{Math.round(m.score * 100)}%
</span>
)}
<span
className={cn(
"flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider",
AUTO_SOURCES.has(m.source)
? "text-emerald-400 bg-emerald-500/10"
: "text-muted-foreground/60 bg-background/20"
)}
title={AUTO_SOURCES.has(m.source) ? "Automatisch gelernt" : "Manuell angelegt"}
>
{AUTO_SOURCES.has(m.source) && <Sparkles className="h-2.5 w-2.5" />}
{m.source} {m.source}
</span> </span>
<button <button
+22 -3
View File
@@ -68,12 +68,16 @@ def get_memories(category: str = "") -> str:
@mcp.tool() @mcp.tool()
def search_memories(q: str) -> str: def search_memories(q: str) -> str:
"""Sucht per Stichwort in den Fakten. Nutze dies VOR add_memory (Dubletten-Check) """Sucht SEMANTISCH (nach Bedeutung, nicht nur Stichwort) in den Fakten. Nutze dies
oder wenn du eine konkrete frühere Entscheidung suchst.""" VOR add_memory (Dubletten-Check) oder wenn du eine konkrete frühere Entscheidung suchst.
Treffer sind nach Relevanz sortiert."""
items = _get("/api/memory", q=q) items = _get("/api/memory", q=q)
if not items: if not items:
return f"Keine Treffer für '{q}'." return f"Keine Treffer für '{q}'."
return "\n".join(f"[{m['category']}] {m['content']} (ID: {m['id'][:8]})" for m in items) def _line(m):
sc = f" {m['score']:.2f}" if isinstance(m.get("score"), (int, float)) else ""
return f"[{m['category']}{sc}] {m['content']} (ID: {m['id'][:8]})"
return "\n".join(_line(m) for m in items)
@mcp.tool() @mcp.tool()
@@ -104,5 +108,20 @@ def delete_memory(memory_id: str) -> str:
return f"Eintrag {memory_id[:8]} gelöscht." return f"Eintrag {memory_id[:8]} gelöscht."
@mcp.tool()
def learn(text: str) -> str:
"""Lässt das Gedächtnis AUTOMATISCH aus einem Gesprächsausschnitt lernen: reiche
rohe Nutzer-Aussagen/Turns durch — das Gedächtnis EXTRAHIERT die dauerhaften Fakten
selbst und mischt sie ein (ohne Dubletten). Nutze dies, wenn der Nutzer beiläufig etwas
Dauerhaftes über sich/seine Vorlieben/das Projekt erwähnt hat — du musst nicht selbst
den Fakt formulieren. Für einen bereits fertig formulierten Einzel-Fakt → add_memory."""
res = _post("/api/memory/learn", {"text": text})
items = res.get("results", []) if isinstance(res, dict) else []
learned = [r for r in items if r.get("event") in (None, "ADD", "UPDATE")]
if not learned:
return "Nichts Neues gelernt (keine dauerhaften Fakten erkannt)."
return "Gelernt:\n" + "\n".join(f"· {r.get('content')}" for r in learned)
if __name__ == "__main__": if __name__ == "__main__":
mcp.run() mcp.run()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Mem0-Sidecar für Mission Control 2.0 — das auto-lernende, semantische Gedächtnis.
WARUM ein eigener Dienst? Mem0 + chromadb laufen nur unter Python 3.12 (`~/.mem0/venv`),
das MC2-Backend aber unter Python 3.14 (kann mem0 nicht importieren). Darum kapselt dieser
schlanke FastAPI-Dienst die Mem0-Memory-Instanz und exponiert sie auf localhost. MC2
(`backend/services/memory.py`) spricht ihn per HTTP an — die `/api/memory`-API-Form bleibt
nach außen unverändert (UI + MCP-Server kompatibel).
Aufbau:
- Vektor-Store : Chroma embedded (kein Docker auf der Box) — Pfad MEM0_CHROMA_PATH.
- Embeddings : llama.cpp `embed`-Rolle über llama-swap (/v1/embeddings, 1024 Dim).
- LLM-Extraktion: lokales `fast`-Hirn (Qwen3.6) über llama-swap (/v1/chat/completions).
Thinking wird abgeschaltet (NoThinkLLM), sonst bricht die JSON-Extraktion ab.
Läuft als systemd-User-Dienst (deploy/mem0-service.service) im `~/.mem0/venv`.
Bind: 127.0.0.1 (nur lokal; MC2 proxyt nach außen).
"""
import logging
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from mem0 import Memory
from mem0.configs.llms.openai import OpenAIConfig
from mem0.llms.openai import OpenAILLM
log = logging.getLogger("mem0_service")
# --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ----------
USER_ID = os.environ.get("MEM0_USER_ID", "mission-control") # ein geteiltes Gehirn
EMBED_URL = os.environ.get("MEM0_EMBED_URL", "http://127.0.0.1:8080/v1")
LLM_URL = os.environ.get("MEM0_LLM_URL", "http://127.0.0.1:8080/v1")
EMBED_MODEL = os.environ.get("MEM0_EMBED_MODEL", "embed")
LLM_MODEL = os.environ.get("MEM0_LLM_MODEL", "fast")
EMBED_DIMS = int(os.environ.get("MEM0_EMBED_DIMS", "1024"))
CHROMA_PATH = os.environ.get("MEM0_CHROMA_PATH", "/srv/models/mem0/chroma")
HISTORY_DB = os.environ.get("MEM0_HISTORY_DB", "/srv/models/mem0/history.db")
COLLECTION = os.environ.get("MEM0_COLLECTION", "mc2")
API_KEY = os.environ.get("MEM0_API_KEY", "sk-local") # llama.cpp ignoriert den Key
LLM_MAX_TOKENS = int(os.environ.get("MEM0_LLM_MAX_TOKENS", "2048"))
SEARCH_TOP_K = int(os.environ.get("MEM0_SEARCH_TOP_K", "50"))
# 5 Kategorien wie im alten System (UI-Kompatibilität). Mem0 selbst kennt keine
# Kategorien — wir führen sie als Metadaten mit.
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
class NoThinkLLM(OpenAILLM):
"""`fast` (Qwen3.6) ist ein Thinking-Modell. Unter response_format=json_object
verbrennt das Reasoning Tokens und schneidet die JSON-Fakten ab (leeres Ergebnis).
`enable_thinking=false` (chat_template_kwargs) → deterministische, schnelle Extraktion."""
def generate_response(self, messages, response_format=None, tools=None,
tool_choice="auto", **kwargs):
eb = dict(kwargs.pop("extra_body", {}) or {})
eb.setdefault("chat_template_kwargs", {"enable_thinking": False})
return super().generate_response(
messages, response_format=response_format, tools=tools,
tool_choice=tool_choice, extra_body=eb, **kwargs,
)
def build_memory() -> Memory:
os.environ.setdefault("OPENAI_API_KEY", API_KEY)
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.makedirs(CHROMA_PATH, exist_ok=True)
os.makedirs(os.path.dirname(HISTORY_DB), exist_ok=True)
cfg = {
"vector_store": {"provider": "chroma", "config": {
"collection_name": COLLECTION, "path": CHROMA_PATH}},
"embedder": {"provider": "openai", "config": {
"model": EMBED_MODEL, "embedding_dims": EMBED_DIMS,
"openai_base_url": EMBED_URL, "api_key": API_KEY}},
"llm": {"provider": "openai", "config": {
"model": LLM_MODEL, "openai_base_url": LLM_URL, "api_key": API_KEY,
"temperature": 0.1, "max_tokens": LLM_MAX_TOKENS}},
"history_db_path": HISTORY_DB,
# Fakten in der Originalsprache halten (deutsche Eingaben bleiben deutsch).
"custom_instructions": (
"Bewahre die Originalsprache der Fakten — deutsche Eingaben bleiben deutsch. "
"Schreibe jeden Fakt knapp und atomar."
),
}
m = Memory.from_config(cfg)
# LLM gegen die Thinking-freie Variante tauschen (gleiche Verbindung).
m.llm = NoThinkLLM(OpenAIConfig(
model=LLM_MODEL, openai_base_url=LLM_URL, api_key=API_KEY,
temperature=0.1, max_tokens=LLM_MAX_TOKENS,
))
return m
app = FastAPI(title="MC2 Mem0 Sidecar")
_mem: Memory | None = None
def mem() -> Memory:
global _mem
if _mem is None:
_mem = build_memory()
return _mem
@app.on_event("startup")
def _startup() -> None:
logging.basicConfig(level=logging.INFO)
try:
mem()
log.info("Mem0 bereit (chroma=%s, embed=%s, llm=%s)", CHROMA_PATH, EMBED_MODEL, LLM_MODEL)
except Exception:
log.exception("Mem0-Init fehlgeschlagen (Dienst läuft, /health meldet down)")
# --- Mapping Mem0 <-> MC2-API-Form -----------------------------------------------
def _to_item(r: dict, score=None) -> dict:
meta = r.get("metadata") or {}
out = {
"id": r.get("id"),
"content": r.get("memory", ""),
"category": meta.get("category") or "stable",
"source": meta.get("source") or "auto",
"created_at": r.get("created_at") or "",
"updated_at": r.get("updated_at") or r.get("created_at") or "",
}
if score is not None:
try:
out["score"] = round(float(score), 4)
except (TypeError, ValueError):
pass
return out
class MemIn(BaseModel):
content: str
category: str = "stable"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "stable"
@app.get("/health")
def health() -> dict:
try:
mem().get_all(filters={"user_id": USER_ID}, top_k=1)
return {"ok": True}
except Exception as exc: # noqa: BLE001
raise HTTPException(503, f"mem0 nicht bereit: {exc}")
@app.get("/memory")
def list_memory(q: str = "", category: str = "") -> list[dict]:
"""q gesetzt → semantische Suche (mit Relevanz-Score). Sonst → alle Fakten."""
if q:
res = mem().search(q, filters={"user_id": USER_ID}, top_k=SEARCH_TOP_K)
items = [_to_item(r, r.get("score")) for r in res.get("results", [])]
else:
res = mem().get_all(filters={"user_id": USER_ID}, top_k=1000)
items = [_to_item(r) for r in res.get("results", [])]
items.sort(key=lambda i: i.get("created_at", ""), reverse=True)
if category:
items = [i for i in items if i["category"] == category]
return items
@app.post("/memory", status_code=201)
def add_memory(body: MemIn) -> dict:
"""Manueller/agentischer Einzel-Fakt → VERBATIM speichern (infer=False, keine
LLM-Umformung). Auto-Lernen aus Gesprächen läuft über /learn (infer=True)."""
content = body.content.strip()
if not content:
raise HTTPException(400, "content leer")
res = mem().add(
[{"role": "user", "content": content}],
user_id=USER_ID, infer=False,
metadata={"category": body.category, "source": body.source},
)
results = res.get("results", [])
if not results:
raise HTTPException(500, "Mem0 hat nichts gespeichert")
new_id = results[0]["id"]
full = mem().get(new_id) or {}
return _to_item(full) if full else _to_item({"id": new_id, "memory": content,
"metadata": {"category": body.category, "source": body.source}})
@app.put("/memory/{mid}")
def update_memory(mid: str, body: MemUp) -> dict:
existing = mem().get(mid)
if not existing:
raise HTTPException(404, "Eintrag nicht gefunden")
cur = _to_item(existing)
new_content = body.content.strip() if body.content is not None else cur["content"]
new_cat = body.category if body.category is not None else cur["category"]
mem().update(mid, data=new_content,
metadata={"category": new_cat, "source": cur["source"]})
full = mem().get(mid) or {}
return _to_item(full)
@app.delete("/memory/{mid}")
def delete_memory(mid: str) -> dict:
if not mem().get(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
mem().delete(mid)
return {"ok": True}
@app.post("/learn")
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst
(infer=True) und entscheidet ADD/UPDATE/NONE gegen das bestehende Gedächtnis."""
msgs = body.messages
if not msgs and body.text:
msgs = [{"role": "user", "content": body.text}]
if not msgs:
raise HTTPException(400, "text oder messages erforderlich")
res = mem().add(
msgs, user_id=USER_ID, infer=True,
metadata={"category": body.category, "source": body.source},
)
return {"results": [
{"id": r.get("id"), "content": r.get("memory"), "event": r.get("event")}
for r in res.get("results", [])
]}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MEM0_PORT", "8765")))
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Einmalige Migration: alte SQLite-Fakten (`memories`-Tabelle aus dem v1/v2-Memory)
→ Mem0. Läuft im ~/.mem0/venv. Idempotent über eine Marker-Datei.
Jeder Alt-Eintrag wird VERBATIM übernommen (infer=False), Kategorie/Source als
Metadaten. Beim aktuellen Box-Stand ist die DB leer → No-Op, aber für
Reproduzierbarkeit/künftige Migrationen vorhanden.
Aufruf: ~/.mem0/venv/bin/python migrate.py [pfad/zur/mc2-memory.db]
"""
import os
import sqlite3
import sys
from app import USER_ID, mem # nutzt dieselbe Mem0-Instanz/Config
DEFAULT_DB = os.environ.get("MC_MEMORY_DB", "/srv/models/mc2-memory.db")
MARKER = os.environ.get("MEM0_MIGRATION_MARKER", "/srv/models/mem0/.migrated_sqlite")
def main() -> int:
db_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DB
if os.path.exists(MARKER):
print(f"Migration bereits erledigt (Marker {MARKER}).")
return 0
if not os.path.exists(db_path):
print(f"Keine Alt-DB unter {db_path} — nichts zu migrieren.")
os.makedirs(os.path.dirname(MARKER), exist_ok=True)
open(MARKER, "w").close()
return 0
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT content, category, source FROM memories ORDER BY created_at"
).fetchall()
except sqlite3.OperationalError:
rows = []
conn.close()
m = mem()
n = 0
for r in rows:
content = (r["content"] or "").strip()
if not content:
continue
m.add([{"role": "user", "content": content}], user_id=USER_ID, infer=False,
metadata={"category": r["category"] or "stable",
"source": r["source"] or "migrated"})
n += 1
os.makedirs(os.path.dirname(MARKER), exist_ok=True)
open(MARKER, "w").close()
print(f"Migriert: {n} Einträge aus {db_path} → Mem0.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
# Zusatz-Abhängigkeiten für den Mem0-Sidecar im ~/.mem0/venv (Python 3.12).
# mem0ai + chromadb sind dort bereits installiert; hier nur der HTTP-Server.
fastapi>=0.115
uvicorn[standard]>=0.30