diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a3be460..2abe218 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -126,6 +126,10 @@ cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh" # Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts. cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh" cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh" +# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen, +# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst. +cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py" +cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" # Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke — # legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope). cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" @@ -136,7 +140,7 @@ cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.s # statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb # --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing). [ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md" -chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" +chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py" # Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes. mkdir -p "$HOME/.hermes/skills/wartung" cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md" diff --git a/deploy/mem0-konsolidierung.py b/deploy/mem0-konsolidierung.py new file mode 100644 index 0000000..c91adc2 --- /dev/null +++ b/deploy/mem0-konsolidierung.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""mem0-Konsolidierung (monatlich, 19.07.2026 — Nachtrag Phase 2 Drei-Welten-Plan). + +mem0 OSS v2 ist ADD-only: Es legt Fakten an, loest aber Widersprueche nie auf und +legt Aehnliches nie zusammen — ohne Pflege waechst das Gedaechtnis unbegrenzt und +der Recall verrauscht. Der Steward-Dedupe faengt nur exakte Dubletten. Dieser Lauf +macht die fehlende Konsolidierung: + + 1. Cluster aehnlicher Fakten holen — ueber den bestehenden /graph-Endpunkt des + Sidecars (Kosinus der gespeicherten Embeddings, kein Re-Embedding, kein + Chroma-Zugriff noetig). + 2. Je Cluster entscheidet das lokale Hirn (Alias `fast`, Thinking aus wie im + Sidecar): zusammenlegen / Widerspruch aufloesen (neuester Zeitstempel + gewinnt) / NICHTS tun. + 3. Anwenden ueber die normalen Sidecar-Endpunkte (PUT/DELETE /memory/). + +Sicherheitsleitplanken (hart im Code, nicht im Prompt): + - Pro Cluster ueberlebt IMMER mindestens ein Fakt. + - Nur ids aus dem Cluster, max. 1 Update pro Cluster, Inhalt <= 600 Zeichen. + - LLM-Fehler / kaputtes JSON => Cluster bleibt unangetastet. + - Kappung pro Lauf (MAX_CLUSTERS), damit ein Lauf nie das halbe Gedaechtnis + umpfluegt — der naechste Monat macht weiter. + +Aufruf: mem0-konsolidierung.py [--apply] (Default: Dry-Run, druckt nur Plan) +""" +import json +import sys +import time +import urllib.request + +MEM0 = "http://127.0.0.1:8765" +LLM = "http://127.0.0.1:8080/v1/chat/completions" +LLM_MODEL = "fast" +MIN_SCORE = 0.82 +MAX_CLUSTERS = 15 +MAX_FACTS_PER_CLUSTER = 6 +APPLY = "--apply" in sys.argv + + +def _http(method: str, url: str, body: dict | None = None, timeout: float = 120.0): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode() or "{}") + + +def clusters_from_graph() -> tuple[dict, list]: + g = _http("GET", f"{MEM0}/graph?min_score={MIN_SCORE}&top_k=5", timeout=180) + nodes = {n["id"]: n for n in g.get("nodes", [])} + parent: dict = {} + + def find(x): + parent.setdefault(x, x) + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for e in g.get("edges", []): + a, b = find(e["source"]), find(e["target"]) + if a != b: + parent[a] = b + groups: dict = {} + for nid in parent: + groups.setdefault(find(nid), []).append(nid) + clusters = [sorted(v) for v in groups.values() if len(v) >= 2] + clusters.sort(key=len, reverse=True) + return nodes, clusters[:MAX_CLUSTERS] + + +def judge(facts: list[dict]) -> list[dict]: + """LLM entscheidet pro Cluster. Fehler => [] (nichts tun).""" + listing = "\n".join( + f"- id={f['id']} | kategorie={f.get('category')} | stand={f.get('updated_at', '')[:10]} | {f.get('content', '')}" + for f in facts) + system = ( + "Du konsolidierst ein Langzeitgedaechtnis. Unten steht EINE Gruppe semantisch " + "aehnlicher Fakten. Entscheide konservativ: " + "(a) Sind es Facetten DESSELBEN Fakts: fasse sie in EINEM praezisen deutschen Satz " + "zusammen -> genau ein update auf die beste id, die anderen delete. " + "(b) Widersprechen sie sich: der Fakt mit dem NEUESTEN Stand gewinnt -> veraltete delete. " + "(c) Sind es VERSCHIEDENE Fakten (nur thematisch verwandt): TUE NICHTS. " + "Im Zweifel IMMER (c). Antworte NUR als JSON: " + '{"actions":[{"op":"update","id":"...","content":"..."},{"op":"delete","id":"..."}]} ' + 'oder {"actions":[]}.' + ) + try: + resp = _http("POST", LLM, { + "model": LLM_MODEL, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": "Fakten-Gruppe:\n" + listing}], + "temperature": 0.1, "max_tokens": 800, + "response_format": {"type": "json_object"}, + "chat_template_kwargs": {"enable_thinking": False}, + }) + raw = resp["choices"][0]["message"]["content"] or "{}" + actions = json.loads(raw).get("actions", []) + except Exception as exc: + print(f" (LLM-Fehler, Gruppe unangetastet: {exc})") + return [] + # Harte Validierung — Leitplanken siehe Docstring. + ids = {f["id"] for f in facts} + updates = [a for a in actions if a.get("op") == "update" and a.get("id") in ids + and isinstance(a.get("content"), str) and 0 < len(a["content"]) <= 600] + deletes = [a for a in actions if a.get("op") == "delete" and a.get("id") in ids] + if len(updates) > 1: + return [] + del_ids = {a["id"] for a in deletes} - {a["id"] for a in updates} + if len(del_ids) >= len(ids): + return [] + return updates + [{"op": "delete", "id": i} for i in sorted(del_ids)] + + +def main() -> None: + t0 = time.time() + nodes, clusters = clusters_from_graph() + total = len(nodes) + print(f"mem0-Konsolidierung {'(APPLY)' if APPLY else '(DRY-RUN)'} — " + f"{total} Fakten, {len(clusters)} Gruppen >= {MIN_SCORE}") + n_upd = n_del = 0 + for ci, cl in enumerate(clusters, 1): + facts = [nodes[i] for i in cl][:MAX_FACTS_PER_CLUSTER] + actions = judge(facts) + if not actions: + continue + print(f"Gruppe {ci} ({len(facts)} Fakten):") + for f in facts: + print(f" [{f['id'][:8]}] {f.get('content', '')[:90]}") + for a in actions: + if a["op"] == "update": + print(f" -> UPDATE {a['id'][:8]}: {a['content'][:90]}") + n_upd += 1 + if APPLY: + _http("PUT", f"{MEM0}/memory/{a['id']}", {"content": a["content"]}) + else: + print(f" -> DELETE {a['id'][:8]}") + n_del += 1 + if APPLY: + _http("DELETE", f"{MEM0}/memory/{a['id']}") + print(f"Ergebnis: {n_upd} zusammengefasst, {n_del} geloescht, " + f"{total - (n_del if APPLY else 0)} Fakten verbleiben. " + f"({int(time.time() - t0)} s)") + + +if __name__ == "__main__": + main() diff --git a/deploy/mem0-konsolidierung.sh b/deploy/mem0-konsolidierung.sh new file mode 100644 index 0000000..7b933fb --- /dev/null +++ b/deploy/mem0-konsolidierung.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Monatlicher mem0-Konsolidierungslauf (Cron-Wrapper, 3. des Monats 06:40 — +# nach Restore-Probe am 1. und Venv-Audit am 2.). no_agent-Job: stdout geht +# als Bericht direkt an Telegram. Logik: deploy/mem0-konsolidierung.py (--apply). +set -u +exec python3 "$HOME/.hermes/scripts/mem0-konsolidierung.py" --apply 2>&1