""" Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden? Zwei Arten von Kandidaten: • Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen. • Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale, Reserve-Modelle, Drafts aus früheren Versuchen). Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch (User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer). Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt: Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen. Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus einer festen Liste — siehe ALTRESTE unten. """ import os import shutil import stat import subprocess import threading import time from dataclasses import dataclass from pathlib import Path from config import MODELS_DIR from services import llamaswap AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"} SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"} AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht # Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026). HINWEISE = { "Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn " "einmal Probleme macht.", "Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.", "Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, " "nie in Betrieb).", "DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.", "drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.", "gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.", "Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.", "Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).", } def _ordner_von(pfad: str) -> str | None: """Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt).""" if not os.path.isabs(pfad): return None try: rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve()) except (ValueError, OSError): return None return rel.parts[0] if len(rel.parts) > 1 else None def _genutzte_ordner(models: dict) -> dict[str, set[str]]: """Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft).""" genutzt: dict[str, set[str]] = {} for name, spec in models.items(): if not isinstance(spec, dict): continue for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt if (ordner := _ordner_von(wort.strip("'\""))): genutzt.setdefault(ordner, set()).add(name) return genutzt def _groesse(pfad: Path) -> int: summe = 0 for wurzel, _, dateien in os.walk(pfad): for datei in dateien: try: summe += os.path.getsize(os.path.join(wurzel, datei)) except OSError: pass return summe def _immer_warm(cfg: dict) -> set[str]: return {m for g in (cfg.get("groups") or {}).values() if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []} def kandidaten() -> list[dict]: """Alles, was sich löschen ließe — größte zuerst. Löscht nichts.""" cfg = llamaswap.read_config() models = cfg.get("models") or {} warm = _immer_warm(cfg) genutzt = _genutzte_ordner(models) liste: list[dict] = [] for name, spec in models.items(): aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []} if aliase & AKTIVE_ROLLEN or name in warm: continue eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene) rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle." liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1), "hinweis": HINWEISE.get(name, rolle)}) try: ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir()) except OSError: ordner = [] for pfad in ordner: if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."): continue liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1), "hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")}) return sorted(liste, key=lambda k: -k["groesse_gb"]) def _ordner_loeschen(name: str) -> int: """Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück.""" wurzel = Path(MODELS_DIR).resolve() pfad = (wurzel / name).resolve() if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."): raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.") groesse = _groesse(pfad) shutil.rmtree(pfad) return groesse def loeschen(art: str, name: str) -> dict: """Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück.""" if not any(k["art"] == art and k["name"] == name for k in kandidaten()): return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."} if not AUF_DER_BOX: return {"ok": False, "detail": "Löschen geht nur auf der Box."} frei = 0 try: if art == "ordner": frei = _ordner_loeschen(name) else: with llamaswap.config_sperre(): cfg = llamaswap.read_config() models = cfg.get("models") or {} vorher = _genutzte_ordner(models) del models[name] for gruppe in (cfg.get("groups") or {}).values(): if isinstance(gruppe, dict) and name in (gruppe.get("members") or []): gruppe["members"] = [m for m in gruppe["members"] if m != name] llamaswap.write_config(cfg) nachher = _genutzte_ordner(models) for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt frei += _ordner_loeschen(ordner) except (OSError, ValueError) as e: return {"ok": False, "detail": f"Löschen ging nicht: {e}"} return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."} # --- Altreste neben dem Betrieb (seit 24.09.2026) --------------------------------------------------------------- # Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft: # Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine # Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck # mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad. # # Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle # von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive # (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien # des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt # (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*). @dataclass(frozen=True) class Altrest: id: str name: str pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut hinweis: str art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload) sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen _UNITS = "~/.config/systemd/user/" _STEMPEL = "~/.local/share/systemd/timers/stamp-" _SKRIPTE = "~/.hermes/scripts/" ALTRESTE: tuple[Altrest, ...] = ( Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",), "Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); " "nichts ruft diesen Ordner auf."), Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",), "Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und " "llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.", sudo=True), Altrest("mem0", "mem0-Umgebung", ("~/.mem0",), "Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete " "Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)), Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",), "Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes " "abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."), Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"), "Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli " "eingebaut). Kein Dienst und kein Job nutzt sie."), Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",), "Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."), Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",), "VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), " "Lucy-Desktop bringt einen eigenen mit."), Altrest("worktrees", "Alte Git-Arbeitskopien", ("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work", "~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz", "~/mission-control-v2/.worktrees/t_b7b426dc"), "Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo " "erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"), Altrest("test-reste", "Test-Reste im Home-Ordner", ("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe", "~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"), "Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des " "alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in " "~/.lucy-stimme."), Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum", ("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py", "~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"), "Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt " "sie; Hermes selbst bleibt unverändert."), Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts", tuple(_SKRIPTE + s for s in ( "blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh", "hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh", "selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")), "Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver " "Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."), Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",), "Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."), Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",), "Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."), Altrest("alt-units", "Abgeschaltete Alt-Units", tuple(_UNITS + u for u in ( "mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service", "mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service", "lucy-stimme.service.bak-20260904")) + (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"), "Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen " "davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.", art="unit", sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")), ) REPO = Path(__file__).resolve().parents[2] ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten _altreste_lock = threading.Lock() _altreste_cache: dict = {"ts": 0.0, "liste": None} def _home() -> Path: return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home()))) def _pfad(roh: str) -> Path: return _home() / roh[2:] if roh.startswith("~/") else Path(roh) def _belegt(pfad: Path) -> int: """Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python).""" try: st = pfad.lstat() except OSError: return 0 if not stat.S_ISDIR(st.st_mode): return st.st_size summe = 0 for wurzel, _, dateien in os.walk(pfad): for datei in dateien: try: summe += os.lstat(os.path.join(wurzel, datei)).st_size except OSError: pass return summe def _menge(n: int) -> str: """Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“.""" if n >= 1e9: return f"{n / 1e9:.1f} GB".replace(".", ",") if n >= 1e6: return f"{n / 1e6:.0f} MB" if n >= 1e3: return f"{n / 1e3:.0f} KB" return "unter 1 KB" def _befehl(args: list[str]) -> tuple[bool, str]: """Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund).""" try: r = subprocess.run(args, capture_output=True, text=True, timeout=60) except (OSError, subprocess.SubprocessError) as exc: return False, f"{exc.__class__.__name__}: {exc}" return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300] def _vorhanden(a: Altrest) -> list[tuple[str, Path]]: return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()] def _sperrgrund(a: Altrest) -> str | None: """Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten.""" for unit in a.sperre: ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"]) z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {} if z.get("ActiveState") in ("active", "activating", "reloading"): return f"{unit} läuft gerade, {a.name} wird also noch gebraucht." if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"): return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht." return None def altreste(frisch: bool = False) -> list[dict]: """Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts.""" with _altreste_lock: if not frisch and _altreste_cache["liste"] is not None \ and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S: return [dict(e) for e in _altreste_cache["liste"]] liste = [] for a in ALTRESTE: da = _vorhanden(a) if not da or _sperrgrund(a): continue groesse = sum(_belegt(p) for _, p in da) liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse, "groesse": _menge(groesse), "hinweis": a.hinweis}) liste.sort(key=lambda e: -e["groesse_bytes"]) with _altreste_lock: _altreste_cache.update(ts=time.time(), liste=liste) return [dict(e) for e in liste] def _entfernen(pfad: Path, a: Altrest) -> None: if pfad.is_symlink() or not pfad.is_dir(): pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel return try: shutil.rmtree(pfad) except PermissionError: if not a.sudo: raise # /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo. ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)]) if not ok: raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None def altrest_loeschen(kennung: str) -> dict: """Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht.""" a = next((x for x in ALTRESTE if x.id == kennung), None) if a is None: return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."} if not AUF_DER_BOX: return {"ok": False, "detail": "Löschen geht nur auf der Box."} da = _vorhanden(a) if not da: return {"ok": False, "detail": f"{a.name}: schon weg."} if (grund := _sperrgrund(a)): return {"ok": False, "detail": grund} frei, fehler = 0, [] for roh, pfad in da: groesse = _belegt(pfad) try: _entfernen(pfad, a) frei += groesse except OSError as exc: fehler.append(f"{roh} ({exc.strerror or exc})") if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo _befehl(["git", "-C", str(REPO), "worktree", "prune"]) elif a.art == "unit": _befehl(["systemctl", "--user", "daemon-reload"]) with _altreste_lock: _altreste_cache.update(ts=0.0, liste=None) if fehler: return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: " + "; ".join(fehler)} return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}