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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+95
-95
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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")))
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user