434 Commits
Author SHA1 Message Date
HitonabiandClaude Opus 5.5 371dcc7ce3 Welle 1 · Ausführer: Zertifikats-Zwischenspeicher hieß wie seine Funktion; PVE/PBS mit Rechnernamen
Der erste echte Bericht trug statt der Zertifikate „'function' object is not subscriptable“: def _zertifikate()
überschrieb das gleichnamige Dict. Jetzt _zertifikate_stand, mit Test über genau diesen Weg (auf dem Host
nachgeprüft: 12 Zertifikate). Proxmox-Host und PBS heißen in der Liste nach ihrem Rechnernamen statt „localhost“.
OFFENE-FAEDEN: Welle 1 erledigt, AdGuard-Zugang offen beim User.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:59:00 +02:00
HitonabiandClaude Opus 5.5 d063f745f6 Welle 1 · dist gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:54:14 +02:00
HitonabiandClaude Opus 5.5 ad8dbbc1b3 Welle 1 · Live-Zahlen je Dienst auf den Kacheln, AdGuard-Zugang in den Einstellungen
Jede Gerätekachel (Homelab-Cockpit und Übersicht) zeigt eine Live-Zahl, wo es eine gibt: Proxmox-Host „8 von 8
Gästen an“, PBS „letzte Sicherung vor 13 h“, NPMplus „10 Zertifikate gültig“ (aus der Zertifikats-Wache), Arcane
„11 von 12 Containern an“ (Arcane-API mit dem vorhandenen Schlüssel) und AdGuard „12 % geblockt“. Für AdGuard gibt es
unter Einstellungen → Homelab einen Zugang, der vor dem Speichern gegen AdGuard geprüft wird und nur im Homelab-Teil
liegt (0600, das Passwort steht in keiner Antwort). Arcane und AdGuard werden höchstens alle zwei Minuten gefragt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:52:41 +02:00
HitonabiandClaude Opus 5.5 53ea0cd9b6 Welle 1 · Zertifikats-Wache: NPMplus, Proxmox-Host und PBS mit Grenzen nach Laufzeit
Der Ausführer liest alle 30 Minuten die https-Zertifikate (NPMplus samt dem, was es für den Namen ausliefert,
Proxmox-Host, PBS). Weil NPMplus das kurzlebige Profil von Let's Encrypt nutzt (6,7 Tage), gilt keine feste
30-Tage-Grenze: gelb „wird nicht erneuert“, sobald weniger als ein Drittel der Laufzeit übrig ist (langlebige: 30 Tage
vorher), rot unter einem Sechstel (1–7 Tage vor Ablauf), gelb auch, wenn NPMplus noch das alte ausliefert. Je Quelle
ein Hinweis; das Homelab-Cockpit zeigt die Liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:45:57 +02:00
HitonabiandClaude Opus 5.5 afa3a476b5 Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät
Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup),
je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore
voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei
Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht
geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je
Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:37:21 +02:00
HitonabiandClaude Opus 5.5 f7a9b8ad50 Welle 1 · Vorhersage „voll in etwa N Tagen“: Pool, NAS, Systemplatte, Container, VMs und die Platte der KI-Box
Aus dem Verlauf der Woche (Tagesmittel, Schutz gegen einmalige Sprünge, schneller werdendes Volllaufen zählt sofort):
gelb unter 14, rot unter 3 Tagen. Der Wächter meldet es, auch wenn der Füllstand selbst noch unauffällig ist;
Speicherkarte, Geräte-Tabelle, Instrumente der Übersicht und die KI-Box-Seite zeigen es an. Jeder Bericht des
Ausführers schreibt dafür die Belegungen in die Messreihe „speicher“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:21:38 +02:00
HitonabiandClaude Opus 5.5 34360c9644 Welle 1 · Totmannschalter: Wächter meldet Abläufe, die gar nicht erst laufen; Seite Dienste zeigt die Zeitpläne
services/ablaeufe.py: je systemd-Timer der Box der erwartete Takt plus Karenz
(LastTriggerUSec über --timestamp=unix), Hermes-Cron-Jobs mit verpasstem
next_run_at, der Runner der Gitea-Ampel (Admin-API, Token aus
~/.git-credentials, nie ausgegeben). Überfällig oder gestoppt → Hinweis nach
fünf Takten, die Sonntags-Updates rot. GET /api/system/ablaeufe; Panel
„Zeitpläne“ unter den Diensten. Tests fragen Gitea nie echt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:01:21 +02:00
HitonabiandClaude Opus 5.5 4b57a12257 homelab: Import in der Route „Alle Update-Rückwege löschen“ nachgetragen (ruff F821), Test ruft sie auf; Logo-Skript ruff-sauber
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:55:04 +02:00
HitonabiandClaude Opus 5.5 5c6e73c69c design v5: Bausteine der KI-Box-Updates mit Logo; dist gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:08 +02:00
HitonabiandClaude Opus 5.5 0d2687436e design v5: alle Seiten im neuen Look
- Gemeinsamer Lagekopf für beide Cockpits (Statusblock mit Schein, Kacheln mit
  Logo/Symbol und Zustandspunkt); KI-Box-Lampen als Dienst-Kacheln, Texte in
  normaler Schreibung. Alte Lampen-Komponente und Leuchtenfarben entfernt.
- Instrumente, Zählwerk, Checkliste, Flugplan, Radar-Kasten im neuen Stil;
  Instrumente ruhig in Indigo, Farbe für Zustände erst ab der Grenze.
- Übrige Seiten (Updates, Modelle, Nutzung, Suche, Monitoring, Dienste,
  Einstellungen, Protokoll, Dialoge): Glasflächen, feine Linien, Etiketten
  statt Instrumenten-Schild, Glas-Eingabefelder, deckende Dialoge.
- Dienste der KI-Box und Geräte im Monitoring mit Symbol bzw. Logo.
- Einstellungen: Karten mit Bereichs-Schein; Lizenzhinweis der Logos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:46:35 +02:00
HitonabiandClaude Opus 5.5 db452aa38a design v5: Homelab-Cockpit, Updates und Snapshots im neuen Look; Rückweg testen und Aufräumen per Klick
- Knöpfe in normaler Schreibung, Hauptknopf mit Verlauf und Schein, Rest Glas.
- Lagebild des Homelab-Cockpits: Statusblock mit Schein, Geräte als Kacheln
  mit Logo, Zustandspunkt und Version; Klick führt zu den Updates bzw. zur
  Zeile in der Geräte-Tabelle. Lage-Texte in normaler Schreibung.
- Geräte-, Update- und Snapshot-Liste mit Logos; Balken in Bereichsfarbe.
- Snapshots: je Gerät „Rückweg testen“ (Rückfrage vom Homelab-Teil) und oben
  „Alle Update-Rückwege löschen“ — nur Snapshots mit Herkunft „update“ und
  Sicherungen des Orchestrators, nacheinander, eine Meldung am Ende (nur per
  Klick des Users; per Knopf/von Hand angelegte bleiben).
- Test gegen Variablen namens „Symbol“ (React Compiler braucht Symbol.for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:40:17 +02:00
HitonabiandClaude Opus 5.5 d524e03c68 design v5: echte Logos der Dienste (selfh.st/icons, CC BY 4.0)
181 gängige Homelab-Dienste als SVG unter /logos (440 KB), geladen mit
frontend/scripts/logos_laden.py (User-Ja 25.09.2026). 33 davon in der hellen
Variante, weil das Standard-Logo auf dem dunklen Grund verschwände (gemessen:
jedes Logo auf den Kachelgrund gezeichnet). Das Gerätesymbol zeigt das Logo,
wo es eines gibt (Name, feste Zuordnung für NPMplus, PBS, PVE Scripts), sonst
das gezeichnete Symbol; mit Logo wird die Kachel neutral, nur der Rand trägt
den Bereich. KI-Box: Ubuntu, llama.cpp, Hermes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:32:00 +02:00
HitonabiandClaude Opus 5.5 9d56de457d Merge main in wartung/design-v5 (Rückweg-Probe, Aufräumen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:14:41 +02:00
HitonabiandClaude Opus 5.5 e443c52d8e skill pc-pfad-cache: eigenes Skript im Skill-Ordner aufrufen (die Kopie in ~/.hermes/scripts pflegte kein Deploy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:14:28 +02:00
HitonabiandClaude Opus 5.5 18fb446aa9 wartung: Dashboard-Token weg, alte Hermes-Umgebung ablösbar, Box-Units als Kopie
- Hermes-Dashboard: festes Session-Token der früheren Desktop-Anbindung wird
  nicht mehr gesichert oder zurückgespielt (liegt im Archiv auf der Box).
  Es bindet weiter an 0.0.0.0, weil Hermes seine Anmeldung auf 127.0.0.1
  abschaltet; ufw hält 9119 zu (Kommentar in config.py nachgezogen).
- Selbstreparatur und PC-Pfad-Skill nutzen das System-Python (hat PyYAML)
  statt der alten Hermes-venv; die steht jetzt samt Sicherung als Altrest
  im Aufräumen-Panel (Löschen per Klick, empfohlen nach dem Sonntagslauf).
- deploy/nur-box/: Kopien der Units, die nur auf der Box leben
  (Hermes-Dashboard, llama-swap samt Drop-ins) — werden nicht ausgespielt.
- Offene Fäden nachgezogen: Discard VM 106, Rückweg-Probe, AdGuard,
  Ausbauplan-Entscheide der Klärungsrunde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:12:31 +02:00
HitonabiandClaude Opus 5.5 23cecd3abc frontend: dist gebaut (Rückweg-Probe)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:58:34 +02:00
HitonabiandClaude Opus 5.5 77ecb35a6d homelab: „Rückweg testen“ — Snapshot anlegen, absichtlich rot werten, zurück, nachprüfen
Neuer Baustein „probe“ (POST /api/homelab/ziele/{id}/rueckweg-probe): legt den
Rückweg an wie vor einem Update, wertet die Prüfung absichtlich als rot, geht
zurück und prüft danach, ob das Gerät läuft und antwortet. Kein Update dabei;
Meldung grün bzw. dringend rot. Die Probe steht im Protokoll, nicht im
Update-Verlauf und nicht unter „Zuletzt eingespielt“. Der Snapshot bleibt
liegen (wie nach einem echten Rückweg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:58:34 +02:00
HitonabiandClaude Opus 5.5 948e55e4f2 design v5: Übersicht als Vorschau, Grundbausteine im neuen Look
Tiefblauer Grund mit Lichtflecken in den Bereichsfarben, Glasflächen mit
Lichtkante (flaeche), ruhigere Schrift (Überschriften normal geschrieben,
Großbuchstaben nur als kleines Etikett) und dezente Bewegung.

- Startseite neu: Geräte-Ring mit Gesamtlage, drei Eckwerte, Instrumente
  (8 Messringe), Bereichskarten mit Kacheln je Modell, Baustein und Gerät
  (Symbol, Zustandspunkt, Update direkt an der Kachel), Speicher/NAS,
  Zeitleisten „Als Nächstes“ und „Zuletzt eingespielt“.
- Panel, Schildchen, Kennzahl, Verlaufslinie, Seitenleiste und Kopf im
  neuen Look — wirkt schon auf allen Seiten.
- Symbole je Gerät (lib/geraetebild): Farbe des Bereichs, Zustände nur als
  Punkt. Vorbilder: Heimdall, Dashy, Homepage, umbrelOS, Pulse.

Noch nicht ausgerollt (kein dist): erst nach dem Ja des Users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:22:39 +02:00
HitonabiandClaude Opus 5.5 b816fe6d32 frontend: dist gebaut (Reiter Snapshots)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:49:03 +02:00
Hitonabi 9e588dc88c Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter 2026-09-25 13:47:33 +02:00
HitonabiandClaude Opus 5.5 450c782ed3 homelab: eigener Reiter „Snapshots“ mit Verwaltung (anlegen, löschen, zurücksetzen)
Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je
Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die
Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für
freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die
Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das
Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten.

Entscheide:
- Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und
  sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die
  kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und
  wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue
  Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen.
- Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer,
  SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in
  Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem
  anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran.
- Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und
  löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt
  eingespielt“.
- Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen).
- speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot.
- Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf);
  die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers);
  Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand).

Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren,
Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:46:28 +02:00
HitonabiandClaude Opus 5.5 8aa3f7defc frontend: dist gebaut (Modell-Nutzung neu, Dienste als Seite)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:33:07 +02:00
Hitonabi d0b3f92857 Merge branch 'worktree-agent-a872d2361a1cdaa60' into wartung/helfer-nutzung-dienste 2026-09-25 13:31:32 +02:00
Hitonabi 73ed6e1cc3 Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste 2026-09-25 13:31:28 +02:00
HitonabiandClaude Opus 5.5 cf01a5bb85 dienste: eigene Seite /box/dienste mit Tabelle wie die Geräte im Homelab
Die Schublade „Dienste und Protokolle" war eine schlichte Liste (Punkt, Name, „läuft",
zwei Knöpfe). Jetzt eine Seite im Bereich KI-Box, gebaut wie die Geräte-Tabelle im
Homelab-Cockpit: je Dienst Zustand in Worten (läuft, schläft, gestoppt, gescheitert,
antwortet nicht), CPU, Speicher, „Läuft seit" und Neustarts; Probleme oben. „Protokoll"
klappt unter der Zeile auf (das Neueste unten im Blick), Neu starten / Starten / Wecken
fragen vorher nach.

Entscheidungen:
- Seite statt Schublade: die Tabelle braucht die Breite, und der Eintrag steht jetzt wie
  die anderen Seiten in der Seitenleiste. Schublade samt onDienste-Weg entfernt.
- Speicher = MemoryCurrent als Zahl mit Einheit; der Balken zeigt den echten Anteil am
  Arbeitsspeicher der Box, keine erfundenen Prozente. Bei der Engine zählt der
  Grafikspeicher (GTT) mit — dort liegen die Modelle, und systemd rechnet ihn keinem
  Dienst zu (25.09.: 4,3 GB laut systemd, 27,6 GB Modelle).
- CPU = Anteil an allen Kernen aus CPUUsageNSec zwischen zwei Abfragen (die Seite fragt
  alle 10 s). Fehlt eine brauchbare Probe, misst die Box einmal 0,5 s nach. Ruhiges Blau
  ohne Warnfarben: ein Dienst hat keine Grenze, was zählt, zeigt der Zustand.
- Neustarts = NRestarts (automatische Neustarts nach Absturz), am Handy nur, wenn es
  welche gab; mehr als 0 rückt den Dienst nach oben.
- Backend: Liste und Kennzahlen in services/dienste.py (ein systemctl show je Bereich,
  auf Windows harmlos leer), Router dünn. Die Felder der MCP-Werkzeuge bleiben;
  waechter.dienst_zustand war nur für die alte Liste da und ist weg.
- Tabelle ab xl, Knöpfe ab 1400 px nebeneinander, darunter Karten mit zwei bzw. vier
  Spalten; bis 320 px keine waagrechte Scrollleiste.

Tests: backend/tests/test_dienste.py (echte systemctl-show-Ausgabe der Box),
frontend lib/dienste.test.ts und views/Dienste.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:28:57 +02:00
HitonabiandClaude Opus 5.5 b3bc1a399e probelauf-box: auch mcp/ mitnehmen (die Tests der Orchestrator-Werkzeuge laden es)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:28:06 +02:00
HitonabiandClaude Opus 5.5 deaf35e953 lucy: Werkzeuge für den Orchestrator (MCP homelab-orchestrator)
User 25.09.: „Lucy ist hier der komplette Systemadmin + Helfer“ – „ja, bau das
mit Lucy und dem Orchestrator“. Lucy bekommt die Knöpfe der Oberfläche als
MCP-Werkzeuge: lesen (lage, updates, speicher, protokoll, radar) und handeln
(update_starten, alle_aktualisieren, nach_updates_suchen, dienst_neustarten,
kernel_aufraeumen, host_neustarten, radar_testen, radar_suchen) – über dieselben
MC2-Routen mit Snapshot, Prüfung, Rückweg und Sperren. Jede Aktion braucht einen
Grund (die Bitte des Commanders) und wird protokolliert; die Beschreibungen
verbieten Aktionen auf eigene Initiative (Reparatur-Neustart ausgenommen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:27:35 +02:00
HitonabiandClaude Opus 5.5 dfe0971180 modelle: „Wer nutzt die Modelle“ neu — Kennzahlen, Tabelle je Absender, lesbare Säulendiagramme
Der User: „Viel zu dünn. Und den Graphen kann ja kein Mensch lesen.“ Vorher drei Balken und ein winziges
Stunden-Histogramm ohne lesbare Achsen, halb so breit neben dem Radar.

- Eigene Ansicht views/ModellNutzung.tsx in voller Breite (Radar darunter, ebenfalls volle Breite).
- Kennzahlen: Anfragen mit Schnitt je vollem Tag, heute/gestern, typische Antwort mit „9 von 10 bis“, Fehler
  (ab 1 % bernstein, ab 5 % rot, immer mit Wort dazu, nie nur Farbe).
- Tabelle je Absender: Anfragen mit Anteil-Balken, Antwortzeiten, Rechenzeit mit Anteil (NerdQuiz stellt
  92 % der Anfragen, aber nur 75 % der Rechenzeit), Fehler in Worten („Modell nicht erreichbar (502)“),
  letzte Anfrage. Schmal als Karten mit Beschriftung.
- Neuer Baustein components/diagramme/Saeulen.tsx für „je Tag (7 Tage)“ und „je Stunde (24 h)“: gestapelt je
  Absender, glatte y-Achse, Wochentag/Datum bzw. Uhrzeit an der x-Achse (Stunden ausgedünnt auf 3 bzw. 6 h),
  Legende, Summen auf den Tagessäulen (bei den Stunden nur die höchste), Anzeige per Zeiger, Finger und
  Pfeiltasten, darunter „Werte als Tabelle“. Auf dem Handy steht die Anzeige unter dem Diagramm, sonst
  verdeckte sie das halbe Bild. Säulen höchstens 24 px, 2 px Luft zwischen den Stücken statt Rändern.
- Farben: neue Tokens --reihe-3 (Gelb) und --reihe-weitere (Grau; ab der vierten Farbe fasst „Weitere“
  zusammen). Mit dem Paletten-Prüfer gegen #15181b gemessen: jedes Paar auch bei Rot-Grün-Schwäche getrennt.
  Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6), Violett gleicht der KI-Box-Farbe.
  Die Farbe folgt dem Absender (feste Reihe der Box), nicht dem Rang.
- „Wann welches Modell geladen wurde“ eingeklappt darunter: Anzahl, zuletzt, davor — mit Rolle (Hirn, Coder,
  Bild-Zwilling) statt nur Dateiname.
- useBreite aus Verlauf.tsx nach lib/breite.ts, damit beide Diagramme ihn teilen.
- Ältere Stände der Box ohne die neuen Felder zeigen weiter Anzahlen und Verläufe, Zeiten als „–“ — die
  Homelab-Instanz fragt über den Partner und kann einen anderen Stand haben.

frontend/dist ist bewusst nicht gebaut (kommt beim Zusammenführen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:26:45 +02:00
HitonabiandClaude Opus 5.5 7834de9592 nutzung: Antwortzeiten, Fehler, Verlauf je Stunde und Ladevorgänge aus dem llama-swap-Journal
„Wer nutzt die Modelle“ war zu dünn (nur Anzahl je Absender). Das Journal gibt je Anfrage auch Status und
Dauer her — daraus rechnet die Box jetzt mehr, alles nur ergänzt; die alten Felder bleiben für den Flugplan
(letzte_24h) und für ältere Oberflächen.

- Je Absender: typische Antwortzeit (Median — einzelne 30-s-Wartezeiten bei Neustarts verziehen sonst den
  Schnitt), „9 von 10 bis“ (90-%-Wert als nächster Rang, also eine echte Antwortzeit), Rechenzeit (Summe aller
  Antwortzeiten), Fehler (Status nicht 2xx, je Code) und die letzte Anfrage. Go-Dauern werden samt ms/µs/ns
  und „1m2.3s“ gelesen.
- Fenster = die letzten 7 Kalendertage bis jetzt statt 7×24 h rollend: je_tag hat genau 7 Tage, heute als
  letzten, auch leere — alle Zahlen beziehen sich auf dasselbe Fenster.
- „stunden“: die letzten 24 h chronologisch mit Beginn (in UTC gerechnet, damit die Zeitumstellung keine
  Stunde verschluckt). letzte_24h bleibt Stunde des Tages wie bisher.
- „ladevorgaenge“: je Modell Anzahl und die letzten Zeitpunkte („Health check passed“).
- „reihe“: feste Reihenfolge aus der Namensliste, damit die Farbe dem Absender folgt und nicht dem Rang.
  NerdQuiz als größter Verbraucher zuerst (ruhiges Blau).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:26:31 +02:00
HitonabiandClaude Opus 5.5 44f33c31d3 radar: „Jetzt testen“ – Kandidaten auf Knopfdruck prüfen, wenn der Speicher reicht
User 25.09.: „Warum kann ich Modell-Tests nicht selbst anstoßen, sondern muss
immer auf die Box warten?“ Nachts testet das Radar weiter selbst. Tagsüber jetzt
auch per Knopf – aber nur, wenn Kandidat, Warm-Set UND der heutige Coder
zusammen unter die 115-GB-Grenze passen (der Test-Wächter stoppt den Kandidaten
erst bis zu 10 s, nachdem der Coder zu laden beginnt), und nicht 00:00–03:30.
Sonst steht der Grund beim Kandidaten. Test als Auftrag (radar_lauf.py --test),
höchstens 2 h, dieselben Leitplanken wie nachts; jedes Ergebnis kommt als Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:16:39 +02:00
HitonabiandClaude Opus 5.5 c31ef827fc meldungen: Lucy spricht – jede Telegram-Meldung in ihrem Ton
User 25.09.: „Hermes' Persona ist Lucy – das muss sich in all ihren Meldungen
widerspiegeln“; das angehängte „(Diese Meldung kam auch an Lucy.)“ tat so, als
wäre sie jemand anderes als der Absender.
- notify.sh: Kopfzeile aus dem Betreff in Lucys Ton (SOUL.md: „Commander“, knapp,
  sachlich), bei Dringendem „Commander, das ist dringend.“; keine zweite Anrede,
  wenn der Text schon mit ihr beginnt. Morgenmeldung mit kurzen Marken statt
  Betreffen in Klammern; die des Homelabs sagt, woher sie kommt.
- Wächter: Zusatz „kam auch an Lucy“ entfernt (der Briefkasten bekommt die
  Meldung weiter).
- Melde-Log und Auswertungen unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:07:42 +02:00
HitonabiandClaude Opus 5.5 2784c0ea32 box-updates: was Ubuntu zurückhält, zählt nicht als Update
User 25.09.: 5 Pakete, die Ubuntu gestaffelt verteilt (phasing), standen als
Update da, obwohl apt-get upgrade sie gar nicht einspielt – das Betriebssystem
wurde nie grün. Gezählt wird jetzt, was die Simulation von apt-get upgrade
wirklich einspielen würde; Zurückgehaltenes steht nur noch als Hinweis da.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:57:56 +02:00
HitonabiandClaude Opus 5.5 54a3649d5e aufträge und „Alle aktualisieren“: beendete lassen sich ausblenden
User 25.09.: Die Karten waren „immer da“ und ließen sich weder bestätigen noch
wegklicken. Beendete Aufträge der KI-Box und das letzte „Alle aktualisieren“
im Homelab haben jetzt einen Knopf „Ausblenden“ (quittiert wird auf dem Server,
gilt also auf allen Geräten; das Protokoll behält alles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:54:21 +02:00
HitonabiandClaude Opus 5.5 0df593c05a homelab: nach dem Hochfahren des Hosts schon nach einer Minute wieder berichten
Der erste Bericht nach dem Neustart vom 25.09. kam 34 s nach dem Hochfahren –
Proxmox kannte die Gäste noch nicht („unknown“), und zehn Minuten lang zeigte die
Oberfläche jeden Gast als unbekannt. Enthält ein Bericht solche Gäste, folgt der
nächste nach 60 s. Der Grund steht jetzt auf Deutsch da.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:39:54 +02:00
HitonabiandClaude Opus 5.5 24bf88a679 speicher: Befehle in den Vorschlägen als Code (die Schrift zog „--ignore-mountpoints“ zu „—ignore-mountpoints“ zusammen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:23:51 +02:00
HitonabiandClaude Opus 5.5 3594de485c speicher: Kacheln 2×2, erst auf sehr breiten Bildschirmen nebeneinander (Überschriften brachen mitten im Wort)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:20:26 +02:00
HitonabiandClaude Opus 5.5 7a456c8efa homelab: alte Kernel per Knopf entfernen; Systemplatte des Proxmox-Hosts im Blick
User-Wunsch 25.09.: Auf dem Host lagen 25 Kernel (~23 GB), 20 davon hält apt für
entbehrlich (19,7 GB).
- Ausführer: host.kernel_alt/kernel_bleiben aus apt-get -s autoremove (nur
  Kernel, nie laufender, nächster oder festgepinnter), host.systemplatte;
  neue Aktion kernel_aufraeumen bestimmt die Liste selbst und purgt.
- Homelab-Teil: Lauf „kernel“ (Ergebnis aufgeraeumt), POST
  /api/homelab/ziele/pve/kernel-aufraeumen, Vorschlag mit Knopf, Wächter für die
  Systemplatte (80/90 %).
- Oberfläche: Kachel Systemplatte, Knopf „Alte Kernel entfernen“ mit Rückfrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:16:14 +02:00
HitonabiandClaude Opus 5.5 28888890ec wartung: Hermes-Update für den neuen Paketmanager von Hermes
Das Hermes-Update vom 25.09. scheiterte halb: neuer Code geholt, dann baute
python-olm (Matrix-Extra) nicht – CMake 4 und kein clang auf der Box. MC2 startete
das Gateway ohne daemon-reload neu, es lief weiter aus dem alten venv (neuer Code,
alte Pakete), und die Übersicht zeigte „v0.0.0 · Aktuell“.
- Update über Hermes' eigenen Starter (.hermes/bin/hermes), gebaut mit
  CC=gcc CXX=g++ CMAKE_POLICY_VERSION_MINIMUM=3.5, doctor-Hinweise nicht fatal,
  daemon-reload vor dem Neustart.
- deploy/hermes-plugin-deps.sh legt trafilatura (mc2-web-lesen) in Hermes'
  aktive Umgebung.
- Version: bei „0.0.0“ in der pyproject das Commit-Datum im Stil der neuen Tags.
- Ehrliche Meldung „UNVOLLSTÄNDIG“, wenn der Code schon neu ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:05:28 +02:00
HitonabiandClaude Opus 5.5 17d4ee3ea8 homelab: Images ohne Container sind kein Docker-Update; neuer Kernel heißt Neustart
Nach dem ersten echten „Alle aktualisieren“ (25.09., 8 Schritte grün):
- 9 veraltete Images, die kein Container benutzt (Basis-Images für Builds/CI),
  blieben für immer „neu“ – Arcanes Updater tauscht nur Container. Sie zählen
  jetzt nicht mehr, der Grund nennt sie als Aufräum-Hinweis.
- Proxmox legt bei Kernel-Updates kein /var/run/reboot-required an: 7.0.14-19
  war installiert, 7.0.14-12 lief, kein Neustart-Knopf. Der Ausführer vergleicht
  jetzt den neuesten installierten (oder festgepinnten) Kernel mit dem laufenden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:46:26 +02:00
HitonabiandClaude Opus 5.5 903fbbfbb9 homelab: Speicherpool, NAS und Sicherungen überwachen – mit Empfehlungen, was Platz bringt
User-Wunsch 25.09.: Speicherpool im Wächter (gelb ab 80 %, rot bei 10 % frei)
samt Empfehlungen, dazu das NAS (Backup-Ziel, per NFS auf dem Proxmox-Host).
- Ausführer: Thin-Pools mit echter Belegung je Volume (lvs), ungenutzte und
  verwaiste Platten, VM-Belegung über den Gast-Agenten, Discard je VM,
  Netzlaufwerke (df mit Zeitlimit, hängender harter NFS-Mount hält nichts auf),
  Sicherungsaufträge und jüngste Sicherung je Gast.
- Homelab-Teil: services/homelab/speicher.py (Auswertung, Empfehlungen,
  Wächter pruefe_speicher, NAS-Erreichbarkeit per TCP), GET /api/homelab/speicher;
  Gast-Plattenwächter nennt jetzt auch VMs richtig.
- Oberfläche: Karte „Speicher und Sicherungen“ im Homelab-Cockpit.
- deploy.sh und ausfuehrer-einrichten.sh warten, solange im Homelab ein Update
  oder „Alle aktualisieren“ läuft (der Neustart bräche den Lauf ab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:15:06 +02:00
HitonabiandClaude Opus 5.5 9867affc5a homelab: Snapshot der Arcane-VM nach grüner Prüfung gleich wieder löschen; doku: VM 106 freigegeben, feste IP .31, AdGuard-Aufbewahrung 14 Tage
Die VM schreibt laufend (Docker, Rippy; Thin-Platte zu 99,9 % belegt) – ein
stehender Snapshot wüchse im Thin-Pool bis zum nächsten Update mit. Bei den
Containern bleibt der letzte Snapshot wie bisher als Rückweg stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:50:04 +02:00
HitonabiandClaude Opus 5.5 5fcac05642 übersicht: bei Arcane die Zahl der Images nennen („11 Images“ statt „Images“); frontend: dist gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:35:19 +02:00
HitonabiandClaude Opus 5.5 67ed0458a9 homelab: Arcane-Docker ehrlich zum Rückweg – Snapshot der VM nur mit Etikett watcher
Die Update-Liste zeigte bei Arcane „Snapshot vorher, bei Rot zurück“, obwohl das
Docker-Update keinen Snapshot anlegte (und der Ausführer VM 106 ohne Etikett gar
nicht anfassen darf). Jetzt:
- Gäste ohne Freigabe haben Rückweg „keiner“, Rückfrage und Plan sagen es.
- Echte Docker-Updates legen vorher einen Snapshot der VM an, wenn sie freigegeben
  ist, prüfen danach (Fehler-Container, Arcane antwortet binnen 3 min) und gehen
  bei Rot zurück – wie bei den Containern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:29:07 +02:00
HitonabiandClaude Opus 5.5 c37c0dccbb monitoring: Voreinstellung 1 Stunde (zeigt sofort Werte, auch wenn der Verlauf noch jung ist)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:19:18 +02:00
HitonabiandClaude Opus 5.5 85400453c2 einstellungen: Arcane-Link erst, wenn die Adresse bekannt ist; frontend: dist gebaut (Oberflaeche v4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:13:29 +02:00
Hitonabi ff95f3f997 Merge branch 'worktree-agent-a8a38897ceeaf6694' into wartung/oberflaeche-v4 2026-09-25 10:11:34 +02:00
HitonabiandClaude Opus 5.5 5af3ca75db doku: „Alle aktualisieren“, Protokoll und Arcane-Schlüssel in ARCHITEKTUR, BETRIEB und OFFENE-FAEDEN
Nur die eigenen Abschnitte: neue Punkte im Homelab-Teil vor „Arcane und Docker“ bzw. vor „Wöchentliches
Suchen“, der Arcane-Punkt, eine Zeile in der Betreff-Tabelle und der offene Punkt Arcane-Schlüssel (jetzt in
der Oberfläche eintragbar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:09:21 +02:00
HitonabiandClaude Opus 5.5 8ec261b8b2 homelab: „Alle aktualisieren“, Protokoll und Arcane-Schlüssel über die Oberfläche
- Sammellauf (services/homelab/sammellauf.py): alle Updates mit Knopf nacheinander über updates.starten,
  Gäste nach VMID, dann NPMplus und AdGuard, zuletzt die Host-Pakete (nie der Neustart). Rot stoppt den Rest
  (übersprungen, abgebrochen, dringende Meldung); grün gibt eine Sammelmeldung, die Erfolgsmeldungen der
  Schritte bleiben so lange aus. Nur einer gleichzeitig, der einzelne Knopf lehnt solange ab (START_SPERRE).
  Ein Neustart mitten im Lauf macht ihn beim Start zu „abgebrochen“, „unterbrochen (Neustart)“.
- Protokoll (services/homelab/protokoll.py): Aufträge, Läufe, Sammelläufe, Wächter-Verlauf, Melde-Log und
  wöchentliches Suchen, neueste zuerst, ohne Geheimnisse. Der Kanal hebt 500 erledigte Aufträge auf und
  vermerkt eingehende Berichte; die Pflege führt eine kurze Ereignisliste, der Wächter-Verlauf die Stufe.
- Einstellungen (services/homelab/einstellungen.py): Arcane-Schlüssel erst gegen Arcane prüfen, dann in
  arcane.key (0600) speichern, nie zurückgeben; echte Docker-Updates per Schalter. Die Umgebung geht vor.
- Schnittstellen als eigener Block in routers/homelab.py vor den Ausführer-Endpunkten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:09:14 +02:00
Hitonabi 82598e0a46 Merge branch 'worktree-agent-a342596bba36026ed' into wartung/oberflaeche-v4 2026-09-25 10:02:47 +02:00
HitonabiandClaude Opus 5.5 f840995078 doku: Monitoring mit Verlauf in ARCHITEKTUR und BETRIEB
Aufbau (Speicher, Taktgeber, Ausfuehrer-Faden, Schnittstellen, Waechter),
Ablage und Groesse der Dateien, Aufbewahrung, Handgriffe zum Nachsehen und
der Hinweis, dass der Ausfuehrer mit ausfuehrer-einrichten.sh kommt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:41:53 +02:00
HitonabiandClaude Opus 5.5 704c21d839 monitoring: Messwerte mit Verlauf fuer KI-Box und Homelab
- kern/messreihen.py: Minutenwerte als JSON-Zeilen je Quelle und Tag unter
  <Datenordner>/mc2-messwerte/, Anhaengen unter flock, Aufraeumen nach 8 Tagen,
  Lesen fuer 1h/24h/7d (60 s, 5-min- und 30-min-Mittel), Luecken bleiben null,
  kaputte Zeilen werden uebersprungen, Zaehler-Raten ohne Spruenge
- KI-Box: Taktgeber im Steward (services/messwerte.py) schreibt jede Minute
  cpu, ram, gpu, Temperaturen, platte, Netz in Bytes/s und Tokens pro Minute;
  GET /api/messwerte (nur Rolle box)
- Homelab: Der Ausfuehrer schickt jede Minute in einem eigenen Faden Host-Werte
  aus /proc und die Gaeste aus einem pvesh-Aufruf an
  POST /api/homelab/ausfuehrer/messwerte; services/homelab/messwerte.py rechnet
  die Zaehler in Bytes/s um (Neustarts und Spruenge ergeben null),
  GET /api/homelab/messwerte liefert Host und Gaeste wie im Inventar
- Waechter (homelab): gelb, wenn der Host 10 Minuten ueber 95 % RAM oder 90 °C liegt
- Aufraeumen der Modell-Platte bietet mc2-messwerte nie zum Loeschen an

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:41:53 +02:00
HitonabiandClaude Opus 5.5 bceb5247ef doku: Bedienung fuer Uebersicht, Monitoring, Protokoll, Alle aktualisieren und die Einstellungsseite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:34:45 +02:00
HitonabiandClaude Opus 5.5 7f1bc6055e modelle: Aufraeumen-Listen eingeklappt mit Zusammenfassung, ruhigere Loeschknoepfe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:33:38 +02:00
HitonabiandClaude Opus 5.5 8cb6f6f878 uebersicht: Zuletzt eingespielt am Handy zweizeilig, kuerzere Ortszeile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:31:34 +02:00
HitonabiandClaude Opus 5.5 1104200a30 oberflaeche v4: Uebersicht als Startseite, KI-Box und Homelab mit Monitoring, Homelab-Updates je Geraet mit Alle aktualisieren, Protokoll, Einstellungsseite, neues Tab-Symbol
- Startseite "Alles auf einen Blick": Gesamtlage, je Bereich Messwerte mit Verlauf, wer welche Updates hat
  (Kurzform, Klick fuehrt in den Bereich), Hinweise, als Naechstes und zuletzt eingespielt
- Menue: Uebersicht, KI-Box (Cockpit, Updates, Modelle, Monitoring), Homelab (Cockpit, Updates, Monitoring,
  Protokoll), Einstellungen; Bereichsfarben Indigo und Violett; alte Adressen /updates und /modelle leiten weiter
- Homelab-Updates je Geraet: App-Version laeuft -> neu (mit Grund, wenn sie wartet), Pakete, Rueckweg, Knoepfe;
  Alle aktualisieren mit Plan in der Rueckfrage und Fortschritt; Cockpit zeigt die Gesundheit der Geraete
- Einstellungen als Seite: KI-Box, Homelab (Arcane-Schluessel, Docker echt/Probelauf, Ausfuehrer), Meldungen,
  Software-Stand
- Diagramme: Verlauf mit Fadenkreuz, Legende und Tabellenansicht, Kennzahlen mit Linie; Farben geprueft
- Tab-Symbol: favicon.ico/.svg, apple-touch-icon, Manifest-Icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:18:56 +02:00
HitonabiandClaude Opus 5.5 bf1153cb66 sicherung: PBS-Sicherung der Box wieder an (User-Ja 24.09.), Deploy haelt den Timer an; AdGuard-Punkt nachgezogen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:23:48 +02:00
HitonabiandClaude Opus 5.5 6a5134ef79 homelab: Plattenschwellen 80/90 % statt 85/95 % (ext4 haelt 5 % fuer root zurueck; AdGuard war bei 94 % innen voll)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:04:05 +02:00
HitonabiandClaude Opus 5.5 c08a144006 homelab: Wächter und Geräteliste sehen volle Gastplatten (AdGuard stand bei 100 %)
Der Ausfuehrer schickt die Belegung der rootfs laufender Container mit; ab 85 % gelb, ab 95 % rot, in der
Geraeteliste als eigene Spalte und auf der Lampe des Geraets. Anlass: AdGuard (2 GB) war voll, das
Abfrageprotokoll schrieb nicht mehr und die Paketsuche scheiterte, ohne dass es eine Anzeige sagte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:00:44 +02:00
HitonabiandClaude Opus 5.5 eed36e9764 frontend: dist gebaut (Seitenleiste, Homelab-Cockpit und -Updates, Einstellungen, Altreste, Radar-Auftrag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:51:03 +02:00
HitonabiandClaude Opus 5.5 bcf878c242 tests: Probe-Timer startet mit den uebrigen Timern, der Radar getrennt mit Stempel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:50:51 +02:00
HitonabiandClaude Opus 5.5 d2068da97f doku: Bedienung mit Menüfuehrung, getrennten Bereichen und den Homelab-Seiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:48:39 +02:00
HitonabiandClaude Opus 5.5 1b9eb38dc3 updates: Satz zum woechentlichen Lauf folgt dem eingestellten Zeitfenster
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:47:54 +02:00
Hitonabi 954a5e74bb Merge branch 'wartung/einstellungen-stand-altreste' into wartung/restarbeiten
# Conflicts:
#	deploy/deploy.sh
2026-09-24 20:47:53 +02:00
Hitonabi b615835a73 Merge branch 'worktree-agent-a1c799645a0ade657' into wartung/restarbeiten 2026-09-24 20:45:51 +02:00
HitonabiandClaude Opus 5.5 9341b6cb37 einstellungen: Timer-Hinweise nur, wo es systemd gibt
Ohne systemd (Entwicklung am PC) meldete die Schublade „Der Timer läuft noch
nach einem anderen Plan“, obwohl dort niemand weiß, wonach er läuft. Die
Hinweise zu Zeitfenster und Radar erscheinen jetzt nur, wenn die Box schalten
kann. Gesehen in der Vorschau gegen ein lokales Backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:43:27 +02:00
HitonabiandClaude Opus 5.5 43c058d7cc doku: Sicherung (PBS als zweite Schicht, Einschalten), Probe-Wiederherstellung, Waechter, offene Faeden
- BETRIEB.md: PBS-Sicherung (was, wohin, warum seit 21.08. aus, Zugang nur auf der Box) mit
  Anleitung zum Einschalten samt Probelauf; neuer Abschnitt Probe-Wiederherstellung (Takt,
  Pruefungen, Ergebnisdatei, Meldung); Waechter-Tabelle und Hirn-Pruefung; Betreff [Sicherung].
- OFFENE-FAEDEN.md: erledigt und raus: ungepinnte Abhaengigkeiten, Hirn-Pruefung, synchrone
  Radar-Suche und Laden ohne Ergebnis, Kleinkram im Code. Neu offen: PBS einschalten (Lead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:42:06 +02:00
HitonabiandClaude Opus 5.5 3f05263b63 aufraeumen: tote MCP-Route, Patch-Traeger-Warnung, Parakeet-Text, .aiexclude, Abhaengigkeiten gepinnt
- mcp/mcp_mc.py: Werkzeug set_route entfernt. Es rief PUT /api/routing/route, eine Route, die
  es nie gab; die Routing-Policy hat seit dem Box-Wart-Umbau keine Schreib-Route mehr (lesen
  bleibt: routing_overview). Die Datei bleibt, weil die Hermes-Config auf der Box den Server
  mission-control-stack noch eingetragen hat (enabled: false).
- deploy/hermes-postcheck.sh: Block fuer den Patch-Traeger (deploy/hermes-patches/apply.py)
  entfernt; der Traeger ist seit 1e68f62 weg, die Pruefung warnte nur noch bei jedem Lauf.
- voice_service/app.py: Kopftext nennt Parakeet-TDT (onnx-asr) als Standard und faster-whisper
  als Rueckfall, wie der Code es tut; den Verweis auf den abgebauten Mem0-Sidecar gestrichen.
- .aiexclude entfernt (galt nur dem abgeloesten Antigravity).
- requirements: gepinnt auf pip freeze der Box (Python 3.14) und des Containers 107
  (Python 3.13): gleiche Version "==", sonst ein Bereich ueber beide. Ausnahme mcp==1.28.1:
  mcp 2.x hat FastMCP entfernt (from mcp.server.fastmcp scheitert), der Container hat 2.2.0,
  nutzt mcp aber nicht. Auf der Box aendert pip damit nichts; im Container zieht einrichten.sh
  mcp beim naechsten Ausrollen auf 1.28.1 zurueck. pytest==9.1.1 (nur Box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:41:50 +02:00
HitonabiandClaude Opus 5.5 2e0ad70a85 sicherung: PBS-Sicherung zurueck ins Repo (ohne mem0), monatliche Probe-Wiederherstellung
- PBS: deploy/pbs-backup.sh, pbs-backup.service und .timer (User-Units, 03:50) aus der Box
  uebernommen. Die Sicherung lief vom 09.07. bis 20.08. taeglich gruen und brach am 21.08. ab,
  weil ihr erster Quellordner /srv/models/mem0 (altes Gedaechtnis) fehlte; danach im
  KISS-Umbau abgeschaltet. Das neue Skript sichert ~/.hermes und /etc/llama-swap (Pflicht)
  und ~/wissens-vault (optional), prueft Zugangsdatei und Client, und liest den Zugang erst
  direkt vor dem Aufruf ein. deploy.sh spielt die Units aus, schaltet sie aber nicht ein
  (Einschalten von Hand, docs/BETRIEB.md). Der Waechter zeigt einen gescheiterten Lauf gelb.
- Probe-Wiederherstellung: services/probe_wiederherstellung.py packt die juengste Sicherung
  in /var/tmp/mc2-probe-* aus (ohne Geheimnisse und Verknuepfungen), prueft Alter (<= 48 h),
  Lesbarkeit und Pflichtinhalte (Gedaechtnis und SOUL.md auch gegen den lebenden Stand,
  Skills, Cron-Skripte und -Jobs, Hermes-Config, .env, llama-swap-Config, Box-Wart-Zustand,
  Units) und loescht den Ordner wieder. Ergebnis in /srv/models/mc2-probe-wiederherstellung.json,
  bei Rot Meldung "[Sicherung]" ueber notify.sh in normaler Dringlichkeit.
  Timer mc2-probe-wiederherstellung.timer: erster Montag im Monat 05:15 (nie sonntags, lange
  nach 03:30), Persistent=true; deploy.sh aktiviert ihn.
- Waechter: gelb, wenn die letzte Probe rot war, aelter als 40 Tage ist oder (mit Timer) noch
  nie lief; Knoepfe "Jetzt pruefen" und "Protokoll" (Allowlist in services.maintenance).
- Tests: Probe gruen/rot je Pflichtinhalt, Abgleich, kaputte und alte Sicherung, Meldung,
  Waechter-Hinweis, Unit-Listen in deploy.sh, Takt, LF, pbs-backup.sh mit Client-Attrappe.
  Trockenlauf gegen die echte Sicherung der Box (nur in /tmp): alle Pflichtinhalte gruen, 0,4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:41:33 +02:00
HitonabiandClaude Opus 5.5 93f6613146 box-wart: Hirn gezielt pruefen, Laden meldet das echte Ergebnis, Radar-Suche als Auftrag
- Waechter (pruefe_kern): prueft jetzt gezielt das Modell mit der Rolle hermes und seinen
  Zustand in llama-swap (/running, v257 listet auch ladende Modelle), statt das Hirn fuer
  bereit zu halten, sobald irgendein Modell laeuft. Ein Ladevorgang ist kein Ausfall, aber
  nur bis 10 Minuten (MC_WAECHTER_HIRN_LADEN_S), sonst bliebe ein immer neu startendes Hirn
  unbemerkt. Sonst die ueblichen Takte (FAIL_AFTER); Knopf "Protokoll des Motors".
- llamaswap: modell_zustaende() und hirn_zustand(); lade_modell() meldet ok false mit
  deutschem Grund und der Meldung der Engine, "laedt noch" nach der Wartezeit ist kein Fehler,
  ein geladenes Modell ohne Chat (embed) zaehlt als geladen. POST /api/models/{id}/load
  reicht das Ergebnis durch (Router duenn).
- Radar "Jetzt suchen": startet die Suche als Auftrag (Job-Engine, Gruppe radar, hoechstens
  einer, radar_lauf.py --nur-suche) und antwortet sofort mit der Auftrags-ID. Die Ansicht
  zeigt "Sucht ..." bis der Auftrag fertig ist, liest danach das Radar neu und nennt eine
  gescheiterte Suche; die Auftragskarte zeigt den Stand.
- Tests: Hirn-Zustaende und Frist, Laden gegen eine llama-swap-Attrappe, Radar-Auftrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:39:01 +02:00
HitonabiandClaude Opus 5.5 d835d41610 doku: Architektur beschreibt die getrennten Bereiche der Oberflaeche und den Homelab-Teil als live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:37:57 +02:00
HitonabiandClaude Opus 5.5 bff8478203 doku: Einstellungen, Stand-Datei, Zeitfenster und Radar-Schalter
- BEDIENUNG.md: Meldungen mit Telegram-Test, Update-Zeitfenster, Modell-Radar,
  Software-Stand, Altreste im Aufräumen, Telegram-Tabelle.
- BETRIEB.md: Einstellungsdatei, Drop-in und Stempel gegen das Nachholen,
  Neustart-Fenster, Radar-Schalter im Deploy, Stand-Datei, FALLBACK mit Grund.
- OFFENE-FAEDEN.md: Altreste stehen jetzt im Aufräumen; was bewusst nicht in
  der Liste steht; tote crontab-Zeile (curator-idle-watchdog.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:37:30 +02:00
HitonabiandClaude Opus 5.5 3d8df1c6fd einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen):
- Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in
  mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von
  Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf
  jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines
  Update-Laufs abgelehnt.
- Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei
  Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59).
- Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen).
  deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit
  Stempel; das Zeitfenster-Drop-in fasst er nie an.
- Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag
  kein Befund mehr (der Dienst selbst ist static und schläft nie).
- Telegram-Test je Instanz (/api/einstellungen/telegram-test und
  /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und
  Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des
  gescheiterten Zweitwegs in den FALLBACK-Eintrag.
- Einstellungsdatei mc2-einstellungen.json im Datenordner.

Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem
Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert
den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung.
Ohne Datei aus git, sonst unbekannt.

Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften
Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per
Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units
mit daemon-reload, /opt/llamacpp notfalls mit sudo -n.

Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh
(Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des
Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:37:30 +02:00
HitonabiandClaude Opus 5.5 d2f699ac69 homelab: Rueckweg auch als Kurzform (snapshot/sicherung/keiner) fuer die Update-Liste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:26:32 +02:00
Hitonabi 37179b8864 Merge branch 'worktree-agent-abdb83e97b099a62d' into wartung/restarbeiten 2026-09-24 20:25:22 +02:00
HitonabiandClaude Opus 5.5 6afec70640 oberflaeche: Seitenleiste mit zwei getrennten Bereichen KI-Box und Homelab
Statt der flachen Leiste oben (Start, Updates, Modelle, Homelab) steht links die Menuefuehrung: je Bereich ein
Block mit eigener Verbindungsanzeige und seinen Seiten, Updates mit Zahl. Das Homelab bekommt wie die KI-Box
ein Cockpit (Lage, Hinweise, Geraete) und eine Updates-Seite (/homelab/updates, offene Updates und Verlauf);
der Hinweis auf Box-Updates im Homelab faellt weg. Am Handy dieselbe Leiste als Schublade hinter dem
Menue-Knopf, oben Bereich und Seitentitel; die Leiste unten und das Mehr-Menue entfallen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:24:24 +02:00
HitonabiandClaude Opus 5.5 8e3fecbe42 doku: Homelab-Teil mit Wartezeit, PBS-Sicherung und woechentlichem Suchen; offener Punkt "Paketlisten alt" erledigt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:22:45 +02:00
HitonabiandClaude Opus 5.5 f2733f44fe homelab: Wartezeit nach Skriptaenderung, PBS mit Sicherung statt Snapshot, woechentliches Suchen
- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf,
  updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die
  Rueckfrage sagt es.
- Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen
  Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher
  Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht;
  Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit
  host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden
  beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM.
- updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen
  weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet).
- pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen),
  einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es
  zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py.
- kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt.
- inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der
  Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:22:39 +02:00
HitonabiandClaude Opus 5.5 a32f55645c homelab: Seite neu geordnet — Lage auf einen Blick, Updates, Geraete, Verlauf getrennt
Vorher stand alles auf Geraetekarten durcheinander (KI-Box zwischen den Containern, Paketstand, App-Version,
Knoepfe und Rueckweg je Karte). Jetzt: oben die Lage wie das Warnpanel der Startseite (grosse Leuchte + eine
Lampe je Geraet), darunter nur die offenen Updates als Tabelle mit Rueckweg und Knopf, dann die Geraeteliste
(App, Pakete, Netz) und der Verlauf. Die KI-Box steht nur noch als Hinweis auf ihre eigene Updates-Seite.
Solange der Homelab-Teil laedt, sagt die Seite nicht mehr voreilig "aktuell".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:15:49 +02:00
HitonabiandClaude Opus 5.5 40d2840b16 doku: Phase 4 ehrlich: Rueckweg bei Rot bisher nur im Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:36:31 +02:00
HitonabiandClaude Opus 5.5 37133fd5ed doku: erster echter Homelab-Update-Lauf (Gitea) gruen, Phase 4 erledigt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:33:06 +02:00
HitonabiandClaude Opus 5.5 4a2b1723f4 homelab: Ausfuehrer erkennt die App auch am neuen /usr/bin/update (SCRIPT_SLUG), das Community-Scripts nach jedem Update schreibt
Nach dem ersten echten Update (Gitea 1.27.2 -> 1.27.3) stand Gitea nur noch als "gitea" ohne App-Baustein da.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:29:55 +02:00
HitonabiandClaude Opus 5.5 528198de60 homelab: Update-Weg je App (Knopf nur, wo das Community-Script die App wirklich aktualisiert), Version von PVE Scripts Local aus der App selbst
Nachgelesen in ct/<app>.sh: AdGuard und PVE Scripts Local verweisen auf ihren eingebauten Updater,
Gitea/NetBird/NPMplus aktualisieren wirklich, PBS ueber die Pakete. /root/.proxmoxve-local stand noch
auf 0.5.8, die App selbst schon auf der neuesten Veroeffentlichung (v1.2.1, untagged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:20:04 +02:00
HitonabiandClaude Opus 5.5 56df849455 doku: Phase 0-4 live, offene Homelab-Punkte (Arcane-Schluessel, feste IP, alte Paketlisten), Verdikte Nachtmeldungen und Homelab-Instanz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:12:32 +02:00
HitonabiandClaude Opus 5.5 160381d8b3 homelab: eigener Live-Strom (keine Getrennt-Anzeige mehr), Hinweise des Homelab-Waechters auf der Seite, Verbindungsanzeige je Instanz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:10:02 +02:00
HitonabiandClaude Opus 5.5 83c33584e4 letzte Kanten: Hermes-Abruf 60 s statt 25 s, kein Pfeil auf dieselbe Version, Homelab-Container mit Sicherheitsupdates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:05:56 +02:00
HitonabiandClaude Opus 5.5 39320b446d homelab: eigener Container nicht in der Geraeteliste, Geheimnis beim Einrichten richtig anstossen, Deploy zieht den Homelab-Teil mit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:01:20 +02:00
HitonabiandClaude Opus 5.5 b452b5e672 ausfuehrer: Nur-Lesen-Modus (nur Bericht, jeder andere Auftrag abgelehnt), wahlweise bei der Einrichtung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:52:36 +02:00
HitonabiandClaude Opus 5.5 209549f0f3 homelab: Arcane und Docker (eigene Version oeffentlich, Images mit Schluessel, Updates zuerst als Probelauf); Datei-Sperren gegen Windows-Wettlauf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:48:28 +02:00
HitonabiandClaude Opus 5.5 5e9227c4a3 phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete
- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:39:59 +02:00
HitonabiandClaude Opus 5.5 a47489fa85 phase2e: Modelltausch schreibt die llama-swap-Config einmal statt bis zu siebenmal
- llamaswap.sammeln(): Aenderungen lesen dieselbe Config aus dem Speicher, geschrieben wird
  einmal am Ende, bei einem Fehler gar nicht
- Radar-Tausch und Hirn-Umstellung nutzen es; Hermes wird erst nach dem Schreiben umgestellt
- Test-Attrappe fuer update_brain_model: der Radar-Test faesst auf der Box nie die echte
  Hermes-Config an
- Doku: Ziel-Modell, strukturierter Verlauf, Sammel-Schreiben

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:18:47 +02:00
HitonabiandClaude Opus 5.5 589c14d5e9 phase2d: gemeinsames Ziel-Modell, KI-Box als erster Adapter, strukturierter Update-Verlauf
- kern/ziele.py: Ziel -> Bausteine mit Stand (neu/aktuell/unbekannt/festgehalten/wird-geprueft),
  Versionen und dem Knopf, der das Update anstoesst; gleiches Modell fuer Box und Homelab
- services/box_updates.py: Update-Zwischenspeicher aus dem Router geholt, ki_box_ziel() als
  Box-Adapter; GET /api/ziele
- Update-Verlauf strukturiert: autoupdate.sh und die Update-Knoepfe schreiben je Baustein eine
  JSON-Zeile (mc2-update-verlauf.jsonl); die Meldungstexte bleiben gleich, aeltere Laeufe kommen
  weiter aus dem Meldeprotokoll. Updates per Knopf erscheinen jetzt auch im Verlauf.
- jobengine: Abschluss-Haken fuer jedes Ende (neben der Nacharbeit fuer den Erfolg)
- Sonntags-Lauf setzt seinen Anlass; Hermes-Job aus dem Lauf schreibt nicht doppelt
- Vertragstest Bash-Schreiber gegen Python-Leser (laeuft auf der Box mit jq)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:14:42 +02:00
HitonabiandClaude Opus 5.5 22bb8c672b phase2c: Oberflaeche heisst Homelab Orchestrator, Bereich Homelab, Anfragen gehen an die richtige Instanz
- /api/instanz: Rolle der ausliefernden Instanz, ohne Netzabfragen
- lib/instanz.ts: /api/homelab/... an die Homelab-Instanz, alles andere an die Box,
  ueber /api/partner/..., wenn die andere Instanz die Seite ausliefert
- neue Seite Homelab: Stand der zweiten Instanz (noch nicht eingerichtet / verbunden /
  antwortet nicht) und was dazukommt
- Kopfzeile, Titel und Web-Manifest mit neuem Namen; fuenfter Menuepunkt, 320 px geprueft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:59:23 +02:00
HitonabiandClaude Opus 5.5 ee2bb9d03c wartung: Groesse je Quantisierung, GitHub-Antworten 15 min gemerkt, Hermes-Version auch bei haengendem Abruf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:52:37 +02:00
HitonabiandClaude Opus 5.5 0e961f112b dist: Oberflaeche Box-Wart 1.0 gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:49:35 +02:00
HitonabiandClaude Opus 5.5 d1ddafe721 Merge wartung/phase1d-frontend: Oberflaeche Box-Wart 1.0 (Updates, Modelle, Start, Strom, 320 px, Zustaende)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:47:25 +02:00
HitonabiandClaude Opus 5.5 97ba6420e2 doku: Phase 2b nachgezogen, tote Doku-Verweise im Code, Morgenmeldung verweist auf den echten Stapel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:47:16 +02:00
HitonabiandClaude Opus 5.5 8eb2fec2ea Merge doku/phase1e-neuordnung: Doku auf den Stand des Homelab Orchestrators
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:45:25 +02:00
HitonabiandClaude Opus 5.5 bc1106ddc8 durchsicht: Rueckfragen passen aufs kleine Handy, ehrlichere Texte, Abbrechen nur wo es stoppt
Befunde der Schlussdurchsicht (Attrappe des Backends, 320 x 568 bis 1440 px):
- Rueckfragen: Bei 320 x 568 lagen "Installieren"/"Abbrechen" unter dem Bildschirmrand
  und liessen sich nicht erreichen (Dialog 791 px hoch, kein Scrollen). Jetzt hoechstens
  Bildschirmhoehe, der Text scrollt, die Knoepfe bleiben unten stehen. Gilt fuer alle.
- Installieren: Die Rueckfrage sagt jetzt, dass llama-swap beim Eintragen sofort neu laedt
  (--watch-config): laufende Antworten von Lucy/OpenChamber brechen ab, mit Rolle nach dem
  Download noch einmal. Der Ersatz-Hinweis nennt, dass das bisherige Modell "heavy" behaelt.
- Abbrechen nur noch bei Downloads (eigener Prozess). Updates und die Suche laufen als
  Shell-Kette, ein Abbruch stoppt dort nicht sicher alles.
- Auftragstitel in den Worten der Oberflaeche ("Update Motor (llama.cpp)" statt
  "Engine-Update (llama.cpp Vulkan)"); "Laeuft seit ... min" zaehlt weiter, auch wenn der
  Auftrag nichts schreibt; Ueberschriftenebene passend zum Panel.
- Updates: "Alles jetzt aktualisieren" richtet sich wie die Zeilen nach den frischen
  Details (vorher nur nach dem bis zu 10 min alten Start-Stand); "Nach Neuem suchen"
  sperrt, solange eine Suche laeuft (zwei apt-get update stolpern ueber die Sperre);
  Rueckfragetext sagt genau, was gesichert wird.
- Nach Auftragsende nur nachladen, was betroffen ist (Download: Modelle/Platte; sonst
  Versionen/Verlauf/Sicherungen) - die Update-Pruefung fragt GitHub, apt und git.
- Radar "Uebernehmen"/"Verwerfen" und "Endgueltig loeschen": Ja-Knopf gesperrt, solange
  die Aktion laeuft, und ein zweiter Klick waehrend des Schliessens loest nichts mehr aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:44:40 +02:00
HitonabiandClaude Opus 5.5 1389b46846 pruefen: auch ausserhalb eines Git-Checkouts; probelauf-box.sh prueft HEAD auf der Box
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:43:43 +02:00
HitonabiandClaude Opus 5.5 45048c60b7 doku: Stand main 75611be nachgezogen (Phase 2a, Waechter-Fixes)
- STACK.md: Instanz-Rolle box, Partner noch nicht eingerichtet; 62 /api-Routen; Waechter prueft
  die Partner-Instanz, sobald es sie gibt.
- OFFENE-FAEDEN.md: Phase 2 begonnen (2a auf main: backend/kern/, /api/partner,
  Partner-Pruefung, Telegram-Zweitweg); die Homelab-Instanz selbst in Phase 3 (User-OK).
  Punkt "Waechter prueft die schlafende Spracherkennung" gestrichen - auf main behoben (99ba6a7).
- VERDIKTE.md: eine Codebasis, zwei Rollen (MC_ROLLE); zweiter Weg zu Telegram direkt ueber die
  Bot-API.
- FALLEN.md: schlafgelegte Dienste auch in den HTTP-Proben ausnehmen (Fehlalarm 24.09. 14:56).
- ARBEITSWEISE.md, README.md: Homelab-Instanz ab Phase 3, Partner-Pruefung "sobald eingerichtet".

Pruefungen: git grep auf Altbegriffe ausserhalb docs/archiv trifft nur noch die Tabelle
"Ueberholt (mit Datum)" in VERDIKTE.md; 0 tote Links in 15 Dateien; keine zerrissenen Tabellen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:43:25 +02:00
HitonabiandClaude Opus 5.5 b1bc9395cf jobengine: Nacharbeit laeuft vor dem Endzustand (Wettlauf, auf der Box sichtbar)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:42:42 +02:00
HitonabiandClaude Opus 5.5 e1d7d499f8 phase2b: Auftraege ueberleben einen Neustart von MC2
- jobengine: jeder Auftrag laeuft als eigene systemd-Einheit mc2-job-<id> (systemd-run,
  RuntimeMaxSec als Zeitlimit); Akte, Protokoll und Exit-Code liegen unter
  <Datenordner>/mc2-jobs; MC2 nimmt laufende Auftraege beim Start wieder auf
- Geheimnisse (HF_TOKEN) ueber eine nur fuer den Nutzer lesbare Umgebungsdatei, die der
  Auftrag beim Start liest und loescht; nichts davon in Befehlszeile oder Einheit
- benannte Nacharbeiten (wartung:nach_update, modell:rolle) statt Closures, laufen auch
  nach einem Neustart
- ohne systemd (PC, Tests) weiter als Kindprozess
- deploy.sh wartet nur noch auf Update-Auftraege; Downloads laufen weiter
- 14 neue Tests; systemd-Weg auf der Box echt geprueft (Neustart, Abbruch, Zeitlimit,
  Geheimnis nicht sichtbar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:41:26 +02:00
HitonabiandClaude Opus 5.5 424aaada27 doku: Einstieg neu (README, docs/README, BEDIENUNG), Jobs-Uebersicht, AGENTS korrigiert
- README.md: eine Seite zum Homelab Orchestrator - Bereiche Box-Wart und Homelab, zwei
  Instanzen, Seiten der Oberflaeche, was von allein laeuft, Dienste und Ports, Entwickeln mit
  Prueftor, Links. Ersetzt die alte MC2-Seite (Mem0, Ideen-Queue, entfernte Routen, alte
  Modelltabelle, kaputte Tabellen).
- docs/README.md (loest docs/wissen/README.md ab): Index, Lesereihenfolge, Archiv-Uebersicht,
  Pflegeregeln.
- docs/BEDIENUNG.md: fuer den User neu geschrieben - Start, Updates, Modelle, Dienste,
  Einstellungen, jede Telegram-Nachricht mit Bedeutung und Handgriff, "wenn etwas hakt".
- deploy/jobs/README.md: alle Jobs und Timer (Modell-Radar, NerdQuiz-Nachtlauf 03:00,
  Updates wieder per Timer), Ausbringen macht deploy.sh, Daily-News-Kette auf dem Stand vom
  23./24.09.
- AGENTS.md: Coding-Oberflaeche heisst OpenChamber; der Verweis auf .agents/mcp_config.json
  (am 24.09. geloescht) ersetzt durch den tatsaechlichen Weg ueber :9001/v1; Titel mit dem
  neuen Produktnamen.
- Archivdatei der Referenzaufgabe umbenannt (…-gedaechtnis-ausbau.md), damit der Index keine
  Altnamen traegt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:41:25 +02:00
HitonabiandClaude Opus 5.5 f4bbdad311 doku: Betriebsdoku neu (ARCHITEKTUR, BETRIEB, UPDATES, RADAR, WIEDERAUFBAU)
- ARCHITEKTUR.md (neu): Prozesse und Units, Rollen box/homelab und Partner-Instanz aus
  Phase 2a (kern/einstellungen.py, kern/zeit.py, kern/partner.py, /api/partner), Modell-Rollen,
  Bild-Weiche, wer welche Datei schreibt (inkl. mc2-quittiert.json), Meldeweg mit
  Telegram-Zweitweg, alle 62 /api-Routen, Herkunftspruefung, kurzer Ausblick Homelab-Teil.
- BETRIEB.md (neu, loest RUNBOOK.md und BACKUP.md ab): Handgriffe, Meldungen und Betreffzeilen,
  Waechter-Regeln (Partner nach 5 Takten, Spracherkennung nur wenn wach, Ausblenden-Knopf),
  Dienste schlafen/wecken, zweistufiger Deploy mit Prueftor, Sicherung und Zurueckspielen,
  Notfall, Pfade auf der Box.
- UPDATES.md (neu): Sonntags-Kette per Timer, Postchecks, Festhalten und Freigeben, Handbetrieb,
  Update-Verlauf, Fallen.
- RADAR.md (neu): Modell-Radar (Leitplanken, Nachtablauf, Pruefstand, Uebernehmen/Verwerfen,
  Merkliste) und Stack-Radar.
- WIEDERAUFBAU.md (neu, loest DISASTER_RECOVERY.md ab): entschlackte Schrittfolge, Anhang A
  (llama-swap-Unit und Drop-ins) behalten.

Stand der Aussagen: Code in main 75611be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:37:09 +02:00
HitonabiandClaude Opus 5.5 83941847ab auftraege: Karten am PC zweispaltig
Vier Auftraege (zwei laufende, zwei frisch beendete) nahmen auf der Modelle-Seite fast
eine Bildschirmhoehe ein. Ab 1024 px stehen die Karten nebeneinander.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:43:23 +02:00
HitonabiandClaude Opus 5.5 29ec6f2ccf aufraeumen: shadcn nur noch zum Bauen, tote Exporte und Test-Attrappen raus, Lint-Regeln scharf
- shadcn liefert zur Laufzeit nichts: gebraucht wird nur shadcn/tailwind.css beim Bauen
  (am PC und im CI, beide mit npm ci inkl. devDependencies). Also devDependency; 325
  Pakete im Lockfile haengen ausschliesslich daran und sind jetzt "dev".
- @testing-library/user-event war nirgends benutzt: entfernt.
- Dialog/Sheet: ungenutzte Trigger/Close/Footer und "use client" raus.
- test/setup.ts: ResizeObserver-Attrappe fuer Recharts und Kraftgraph entfernt - beide
  gibt es nicht mehr, und Radix' Dialog misst nichts.
- eslint.config.js: Die vier React-Compiler-Regeln sollten laut Kommentar auf "error",
  sobald ihr Zaehler 0 ist. Er ist 0 - jetzt error. Veraltete Zahlen und "Recharts" im
  Kommentar korrigiert.
- lib/utils.ts bleibt (Ziel von components.json fuer die shadcn-CLI), jetzt mit Kommentar.
- Lockfile: npm 11.17 traegt sechs optionale wasm32-Unterpakete zusaetzlich ein, auch
  ohne diese Aenderung; die sind wieder raus, damit der Diff nur das Gewollte zeigt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:42:13 +02:00
HitonabiandClaude Opus 5.5 75611be9a9 phase2a: Kern fuer zwei Instanzen (Rolle, Partner, Telegram-Zweitweg)
- kern/einstellungen.py: MC_ROLLE (box|homelab), Instanzname, Datenordner, Partner-URL
- kern/zeit.py: eine Zeitzone statt sechs Kopien
- app.py/steward.py: Box-Router und Warm-Set nur in der Rolle box; der Homelab-Teil
  laedt nichts von der Box
- /api/health nennt Rolle und Instanz; Engine/Gateway/Hirn nur auf der Box
- /api/partner (Lebenszeichen) und /api/partner/<pfad> (Durchreiche, ohne Schleifen und SSE)
- Waechter prueft die andere Instanz (rot erst nach 5 Takten, ein Neustart ist kein Alarm)
- notify.sh: Zweitweg direkt an die Telegram-Bot-API, wenn hermes send scheitert oder fehlt;
  Token nicht in der Prozessliste, Tests greifen nie auf die echte .env
- update_verlauf erkennt "OK telegram direkt"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:41:08 +02:00
HitonabiandClaude Opus 5.5 bd12667231 zustaende: Dienste und Einstellungen sagen, was los ist
- Dienste: Lade-, Fehler- und Leerzustand; das Protokoll erscheint direkt unter dem
  angetippten Dienst (am Handy stand es vorher unter der ganzen Liste ausser Sicht) und
  meldet Lesefehler; "Wirklich neu starten?" hat ein "Nein", waehrend eines Neustarts
  sind die Knoepfe gesperrt, Meldungen nennen den Dienst beim Namen statt der Unit.
- Einstellungen: Solange der Stand laedt, steht "Wird gelesen ..." statt eines falschen
  "Fehlt."; Lesefehler und eine nicht beschreibbare Ablage werden gesagt; Speichern
  sperrt gegen Doppelklick, "Zugang loeschen" fragt vorher nach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:38:17 +02:00
HitonabiandClaude Opus 5.5 24e6dd81ba doku: Wissen auf den Box-Wart-Stand (STACK, VERDIKTE, FALLEN, ARBEITSWEISE, OFFENE-FAEDEN)
- STACK.md: Stand 24.09. - Dienste mit Zustand (Konsole und Spracherkennung schlafen), Units im
  Repo und nur auf der Box, Modelltabelle aus der Config vom 24.09. (Hirn, Coder, zwei
  Bild-Zwillinge, brains mit exclusive: false), Hermes v0.21.4 mit MCP-Stand nach der Diaet,
  vollstaendiger Automatik-Fahrplan (Timer, Hermes-Crons, NerdQuiz 03:00), Coding-Bahn
  OpenChamber, Sicherheit in einem Satz.
- VERDIKTE.md: Box-Wart-Verdikte vom 23./24.09. ergaenzt (Umbau statt Neubau, zwei Instanzen,
  Homelab-Rechte und -Updates, keine Anmeldung, Nachtruhe, Timer, Box-Diaet, Radar-Leitplanken,
  Bild-Zwillinge, exclusive: false); Ueberholtes in eine Tabelle "Ueberholt (mit Datum)";
  Formatfehler (Zeilen mit "|-") beseitigt.
- FALLEN.md: neue Fallen (Persistent=true, Abzug ueberschreibt lebende Config, Jobs sterben bei
  MC2-Neustart, exclusive, Draft+Bild=500, Hermes-Cron als Updater, write_file, web_extract,
  Gitea-SSH-Benutzer); Werkstatt-, Kritiker-, Delegations- und Desktop-Fallen gestrichen.
- ARBEITSWEISE.md mit GRENZEN.md zusammengelegt; Flaechen-Tabelle fuer Box-Wart, Homelab-Teil,
  Lucy, Hermes, Telegram, OpenChamber, Android-App.
- OFFENE-FAEDEN.md neu: Phasen 0-5 laut Plan (Phase 1 laeuft, Oberflaeche und Doku offen) und
  13 offene Einzelpunkte, alle am Code in main belegt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:38:10 +02:00
HitonabiandClaude Opus 5.5 a091ccb3b8 layout: 320 px ohne Ueberstand, Schubladen in voller Breite, Doppelklick-Sperre greift sofort
Befunde aus der Sichtpruefung (Attrappe des Backends, 320/768/1024/1440 px):
- Zaehlwerk: Das "H" ragte bei 320 px 30 px ueber sein Feld. Ziffern skalieren jetzt
  mit der Feldbreite (Container-Einheiten), am PC bleibt es bei 38 x 56 px.
- Flugplan und Update-Verlauf: Der Text bekam schmal nur ~50 px und lief in den Zustand.
  Schmales Feld: Zeit und Zustand oben, Text darunter (nach Feldbreite, nicht Bildschirm).
- Update-Zeilen am Handy: Name und Version stiessen aneinander ("BetriebssystemUbuntu"),
  die Version steht jetzt darunter; scheitert die Pruefung, heisst sie "unbekannt" statt "-".
- Schubladen (Modell-Suche, Dienste, Einstellungen, Protokoll) waren am Handy nur 75 %
  und am PC hoechstens 384 px breit - die shadcn-Grundklassen schlugen die eigenen.
- Doppelklick: Die Sperre beim Installieren haengt jetzt an einem Ref und greift sofort
  (vorher starteten zwei Klicks im selben Takt zwei Downloads); die Ja-Knoepfe von
  "Alles aktualisieren" und "Auftrag abbrechen" ignorieren den zweiten Klick.
- Balken eines abgebrochenen Downloads grau statt cyan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:36:03 +02:00
HitonabiandClaude Opus 5.5 634ad27302 strom: Live-Strom baut sich selbst neu auf, Verbindungsanzeige auf jeder Breite
- Gibt EventSource endgueltig auf (502/503 vom Proxy, waehrend MC2 neu startet), baut
  die App den Strom selbst neu auf: Pause 2 s, 4 s, 8 s ... bis 30 s, sofort wenn Netz
  oder Tab zurueck sind.
- Stille-Waechter: Der Strom schickt jede Sekunde einen Messpunkt. Kommt 20 s nichts,
  gilt die Leitung als tot (Handy im Standby, WLAN-Wechsel, Proxy haelt offen) und wird
  neu aufgebaut. Im Test mit einer Attrappe: vorher blieb "Box online" stehen.
- "Box online / Getrennt" steht jetzt auf jeder Breite in der Kopfzeile (am Handy
  kleiner, getrennt in Bernstein), nicht mehr nur im Mehr-Menue.
- Obere Navigation erst ab 1024 px, darunter die Leiste unten wie am Handy: Zwischen
  768 und ~880 px lief die Kopfzeile schon vorher seitlich ueber, mit der Anzeige bis 1023.
- metrikEinspeisen (nur fuer Tests gedacht, nie benutzt) entfaellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:35:52 +02:00
HitonabiandClaude Opus 5.5 59631601b9 doku: Historisches ins Archiv, Ueberfluessiges geloescht
- 19 Dateien nach docs/archiv/ mit Datumspraefix (JJJJ-MM-TT-): SAVEPOINT, Autonomie-,
  Optimierungs- und TTS-Plan, Review 02.07., ZeroClaw-Auftrag und -Ergebnis, Hermes-Setup,
  Gemini-Briefing, Antigravity-Review-Prompt, Zielbild, Uebergabe Drei Welten,
  Umbauplan Abloesung, Hermes-Werkzeuge, Raphael, die drei Dateien aus docs/aufgaben
  und der Skill-Text gitea-workflow.
- Die alte Liste OFFENE-FAEDEN (Stand 04.09.) ebenfalls ins Archiv; sie enthaelt die
  Lucy-Faeden, die sonst verloren gingen. Die neue Liste folgt im naechsten Schritt.
- Geloescht (kein eigenes Wissen, Git-Historie reicht): docs/memory/* (4 Kopien der
  Claude-Notizen vom Juni), STATUS, CUTOVER, AUDIT_KICKOFF, CLAUDE_CODE_BRIEF,
  UPGRADE und skills/orchestrator.md (Quelle war der Skill selbst).
- Formatfehler im Archiv behoben: uebrig gebliebene </content>-Tags im Optimierungsplan
  und im ZeroClaw-Auftrag; toter Link auf GEDAECHTNIS-BEREICHE.md zeigt jetzt auf die
  Git-Historie (geloescht am 07.08., c3851f8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:31:00 +02:00
HitonabiandClaude Opus 5.5 99ba6a78ab waechter: schlafende Spracherkennung ist kein Kernbefund mehr
Nach dem Schlafenlegen des voice-service (disable --now) meldete die Kern-Probe
"Der Hoer-Dienst antwortet nicht" als roten Hinweis samt Telegram (24.09. 14:56, Fehlalarm).
Die Probe laeuft jetzt nur, wenn der Dienst nicht schlaeft. Dazu ein Lint-Nachtrag im Test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:19:55 +02:00
HitonabiandClaude Opus 5.5 739f27a7e5 start: Radar-Kasten springt wirklich zum Radar, Checkliste fragt vor Neustart und Freigeben
- "Ansehen" im Radar-Kasten fuehrt nach /modelle#radar. Der Router sprang beim Rendern
  zum Anker, als dort noch "wird gelesen" stand; jetzt springt die Modelle-Seite, sobald
  alles ueber dem Radar geladen ist.
- Knoepfe der Checkliste, die einen Dienst (neu) starten, einen Job erneut laufen lassen
  oder ein Update freigeben, fragen vorher nach. Das Protokoll oeffnet weiter sofort; der
  Dialog wird erst bei Bedarf geladen und bleibt aus dem Start-Buendel.
- Scheitert nur ein spaeteres Nachladen, bleibt der letzte Stand der Startseite stehen
  statt einer Fehlerflaeche.
- Test fuer die Rueckfrage-Logik (wann gefragt wird, Checkliste erst nach "Ja",
  gesperrter Ja-Knopf, Ersatz-Hinweis beim Installieren).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:19:03 +02:00
HitonabiandClaude Opus 5.5 3c1d8bf88f modelle: Installieren mit Rueckfrage, Doppelklick-Sperre und Rollen-Hinweis, Auftraege mit Fortschritt
- "Installieren" fragt vorher nach (Modell, Quantisierung, Groesse falls bekannt) und
  sperrt, solange der Start unterwegs ist - ein Doppelklick startet keinen zweiten Download.
- Mit Rolle "Coder" steht deutlich da, welches Modell ersetzt wird (unter der Auswahl und
  in der Rueckfrage), dass es auf der Platte bleibt und der Bild-Zwilling vorerst beim
  bisherigen Modell liest.
- Die Modelle-Seite zeigt laufende Downloads als Auftraege mit Fortschritt und Abbrechen.
- Suche und Quantisierungen haben Lade-, Fehler- und Leerzustaende; Nutzung, Radar und
  Aufraeumen melden Fehler statt still zu verschwinden. Ein 404 heisst "Radar nicht
  eingerichtet", alles andere ist ein Fehler. Scheitert nur ein spaeteres Nachladen,
  bleibt der letzte Stand der Modell-Liste stehen.
- Anzeige-Logik der Wartungsseiten liegt jetzt in lib/wartung.ts statt in anzeige.ts,
  die im Start-Buendel steckt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:18:43 +02:00
HitonabiandClaude Opus 5.5 b0c9549a29 waechter: Werkzeugfehler eines Jobs bis zum naechsten Lauf ausblenden
Heute stand ein schon behobener web_extract-Fehler des News-Jobs bis zum naechsten Lauf
(morgen 07:00) im Cockpit. Neuer Knopf "Ausblenden bis zum naechsten Lauf": MC2 merkt sich
den Lauf in /srv/models/mc2-quittiert.json, der Waechter blendet genau diesen Lauf aus. Hat
der naechste Lauf wieder Fehler, erscheint der Hinweis erneut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:17:36 +02:00
HitonabiandClaude Opus 5.5 4cd856bd34 phase1c: Deploy mit Rueckweg und Prueftor, tote Deploy-Dateien raus
- deploy.sh zweistufig: Sperre, laufende Update-Jobs verschieben den Deploy, fast-forward,
  dann laeuft die NEUE Fassung als Stufe 2 (Aenderungen am Skript wirken sofort).
  Stufe 2: Prueftor, Abhaengigkeiten, llama-swap-Config nur bei Aenderung des Abzugs in
  diesem Deploy (ohne Motor-Neustart, -watch-config reicht; 5 Sicherungen), alle Repo-Units
  und Drop-ins, Cron-Skripte nach ~/.hermes/scripts, Skills/Plugins, Neustart, Nachpruefung.
  Scheitert etwas: zurueck auf den alten Stand (inkl. llama-swap-Config), Dienste neu,
  dringende Meldung, Eintrag in /srv/models/mc2-deploy.log.
- deploy/pruefen.sh ersetzt die tote CI-Ampel: Shell-/Python-Syntax, ruff, Importe, pytest.
  Laeuft am PC vor dem Push und auf der Box vor dem Umschalten (pytest via requirements-dev.txt).
- Units, die nur auf der Box lagen, jetzt im Repo: projekte-sync.*, lucy-stimme.service,
  mission-control-2-Override.
- Tot und entfernt: Werkstatt-/Projektstart-/Betrieb-SOULs, worker.sh, Agent-Hooks, Ampel-CI
  (samt .gitea-Workflow und Saat in gitea-repo-create.sh), gitea-pr, Governor-Plugin, Specs,
  Selbst-Inventur, Self-Smoke, venv-Audit, setup_autonomous_crons.sh (haette alte Jobs neu
  angelegt), Einmal-Skripte, alte Bench-Skripte, .agents/mcp_config.json, client/ide-skills,
  mcp/requirements.txt. Gitea-Host-Notizen nach docs/archiv/gitea-host.
- morgenmeldung.sh begrenzt das Melde-Log (ueber 3 MB bleiben die letzten 2 MB).
- AGENTS.md: Prueftor und neuer Deploy beschrieben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:13:50 +02:00
HitonabiandClaude Opus 5.5 5ddc952ecd updates: Urteil und Paketliste sichtbar, ehrliche Lade- und Fehlerzustaende, Rueckfragen, Auftraege aller Gruppen
- Update-Details zeigen jetzt das Urteil des Hirns ("Nichts zu tun" / "Du musst
  etwas tun" samt Text), die Zusammenfassung ist aufklappbar statt auf drei Zeilen
  gekappt, das Betriebssystem zeigt seine Paketliste (und was Ubuntu zurueckhaelt).
- Scheitert eine Pruefung (z. B. git fetch bei Hermes), steht "Konnte nicht pruefen: ..."
  statt "- -> neu"; waehrend des Ladens ein Platzhalter statt "..." und kein voreiliges "neu".
- Einzel-Update und Freigeben fragen vorher nach; der Ja-Knopf sperrt gegen Doppelklick.
- Neue Job-Karte (Fortschritt, Abbrechen mit Rueckfrage, letzte Meldung, Protokoll) fuer
  alle laufenden Auftraege, egal welche Gruppe. Updates lassen sich bewusst nicht abbrechen.
- Job-Typen an jobengine.py angeglichen ("canceled", group, started_at/finished_at):
  abgebrochene Auftraege erschienen bisher als "Laeuft".
- Fehler aus "Jetzt sichern" (Feld error) und "Es laeuft schon ein Update" (status busy)
  werden gemeldet; ist ein Auftrag fertig, lesen Versionen, Modelle und Sicherungen neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:13:27 +02:00
HitonabiandClaude Opus 5.5 e9f488b56c phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ampel / ampel (push) Successful in 26s
Ballast raus:
- 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy,
  system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan,
  voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60.
- Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt
  10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/.
- Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext-
  Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...).

Robuster:
- Jobs in eigener Prozessgruppe (Abbrechen beendet wirklich alles), Zeitlimit je Job-Art,
  start_job_exklusiv: zwei Klicks starten kein doppeltes Update mehr; alte Jobs raeumen sich auf.
- Update-Pruefung meldet Fehler (pruef_fehler, Lampe "Pruefung unklar") statt "aktuell".
- Nach jedem Update sofort neu pruefen (update_stand) statt 10 Minuten alten Stand zeigen.
- llama-swap-Config: Sperre (RLock + flock) fuer UI, Radar, Aufraeumen und Hirn-Umstellung.
- Hermes-Config: bei Lesefehler nichts schreiben, atomar, mit Sicherung.
- Live-Strom und Gateway-Warnung blockieren den Event-Loop nicht mehr (Lucy, OpenChamber).
- Gateway antwortet bei Engine-Ausfall im OpenAI-Fehlerformat (502) statt nacktem 500.
- Abgestuerzte Waechter-Pruefung wird ein gelber Hinweis statt still zu verschwinden.
- Download laedt nur den gewuenschten Quant (vorher bei Fehlen alle Teile aller Varianten),
  Download-Jobs in Gruppe "download"; HF-Suche kodiert den Suchbegriff.
- Herkunftspruefung: schreibende /api-Aufrufe fremder Webseiten werden abgelehnt (keine
  Anmeldung, User-Entscheid); Skripte, Desktop-Lucy und /v1 unveraendert.
- Modellpfade: Eintragen und Loeschen nur innerhalb von MODELS_DIR.
- Dienste-Liste fragt keine abgebauten Dienste mehr ab (PC-Ausfuehrer haette 3 s gekostet).
- SSE-Fehlerzeilen von /api/voice/chat als gueltiges JSON.
- mission-control-2.service: --timeout-graceful-shutdown 3 (Neustart ohne 10-s-Haenger).

Tests: 92 gruen (neu: Herkunft, Quant-Auswahl, abgestuerzte Pruefung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:08:44 +02:00
HitonabiandClaude Opus 5.5 c8718fcde0 autoupdate: Neustart nur noch im Wartungsfenster (So 04:00-06:59)
Ampel / ampel (push) Successful in 26s
Beim ersten Einschalten von mc2-autoupdate.timer holte systemd (Persistent=true) den seit
August verpassten Sonntagslauf sofort nach. Der Lauf spielte nichts ein, startete die Box
wegen des wartenden Kernel-Updates aber am Donnerstag um 14:51 neu. Laeufe ausserhalb des
Fensters melden einen noetigen Neustart jetzt nur an; von Hand erzwingen mit
MC_AUTOUPDATE_REBOOT_JETZT=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:54:52 +02:00
HitonabiandClaude Opus 5.5 45b5bf275c phase1a: Box-Diaet - schlafende Dienste, Sonntags-Update als Timer, tote Skills raus, Warm-Set nur Hirn
Ampel / ampel (push) Successful in 26s
- Waechter und Dienste-Liste kennen "schlaeft": abgeschaltete Units (disable --now) sind kein
  Befund mehr; die Oberflaeche zeigt sie grau mit Knopf "Wecken" (fuer die Android-App spaeter).
- Updates am Sonntag laufen wieder ueber mc2-autoupdate.timer (jobs/sonntags-update.sh) statt als
  Hermes-Cron, der sich beim Hermes-Update selbst neu startete (20.09.: 180 s, Fehlschlag).
  Flugplan und Waechter kennen den Timer.
- 10 abgeloeste Skills (Werkstatt, Kanban, Orchestrator, Trend-Radar ...) aus dem Repo; deploy.sh
  verschiebt sie in ~/.hermes/skills-archiv statt sie weiter zu Lucy zu kopieren.
- Embedding und Reranker schlafen: raus aus brains und Warm-Set, ttl 300, warmup.sh waermt sie
  nicht mehr vor. deploy.sh spielt Timer und Warm-Set-Drop-in selbst aus.
- Dienste-Namen: "Box-Wart (MC2)", "Hermes-Dashboard", "Spracherkennung (Desktop-Lucy)".
- Frontend neu gebaut (npm ci, lokale Pakete waren vom 28.08.).

Tests: 87 gruen (neu: schlafende Dienste), Frontend Lint/Tests/Build gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:50:37 +02:00
HitonabiandClaude Opus 5.5 648be33d21 phase0: Nachtmeldungen kommen wieder an, Sicherung mit Lucys Gedaechtnis, Token-Datei raus
Ampel / ampel (push) Successful in 26s
- notify.sh sammelt nachts (00-07 Uhr) wie bisher, aber jetzt liefert mc2-morgenmeldung.timer
  um 07:00 alles als eine Telegram-Nachricht aus. Seit dem 21.08. las niemand die
  Warteschlange, alle Nachtmeldungen (auch die Sonntags-Updates) gingen verloren.
  Dringendes (-d, Betreff Alarm/Notfall, Text beginnt mit KRITISCH) geht sofort raus;
  autoupdate.sh markiert seine KRITISCH-Meldungen ausdruecklich.
- backup.sh sichert zusaetzlich ~/.hermes/memories, SOUL.md, skills/, scripts/, den
  Box-Wart-Zustand (/srv/models/mc2-*.json), User-Units, llama-swap-Drop-ins und Lucys
  Stimmreferenz. restore.sh spielt Gedaechtnis und Zustand nur zurueck, wenn sie fehlen
  oder --mit-gedaechtnis gesetzt ist.
- Update-Verlauf zaehlt die Morgenmeldung nicht als eigenen Lauf.
- Waechter und Flugplan kennen den neuen Timer; deploy.sh installiert ihn.
- .claude/settings.local.json aus dem Repo genommen (lokal behalten), .gitignore ergaenzt.
- ruff wieder gruen (4x RUF100), AGENTS.md: Deploy macht git pull, Remote ist SSH.

Tests: 86 gruen (neu: Nachtruhe, Dringendes, Morgenmeldung inkl. Telegram-Ausfall).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:42:19 +02:00
HitonabiandClaude Opus 5.5 4a05bc2a05 radar: Kandidaten messen mit der schnellsten Draft-Variante, Bild-Probe und Betrieb wie heute mit Zwilling
Ampel / ampel (push) Failing after 22s
Das heutige Modell misst mit seinem Draft (Hirn 106 t/s, ohne 68), Kandidaten liefen ohne -
Ornith fiel am 24.09. deshalb durch, obwohl es dieselbe Architektur hat. Jetzt probiert das Radar
kurz: ohne Draft, eingebauten MTP-Kopf und den Draft des heutigen Modells (gleiche Architektur,
Speicher reicht) und testet mit der schnellsten. Spekulatives Dekodieren aendert die Antworten
nicht, nur das Tempo.

Mit Draft laeuft die Bild-Probe auf einem Zwilling ohne Draft (llama.cpp: Draft und Bild = HTTP
500). Uebernehmen baut wie der Betrieb seit 24.09.: Hauptmodell mit dem gewaehlten Draft ohne
Projektor, dazu ein Bild-Zwilling; der alte Zwilling fliegt raus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:26:08 +02:00
HitonabiandClaude Opus 5.5 42363229d4 modelle: Aufraeumen auf Knopfdruck, geteilte Gewichte bleiben beim Loeschen liegen
Ampel / ampel (push) Failing after 21s
Neuer Bereich "Aufraeumen" auf der Modelle-Seite: Eintraege ohne heutige Rolle und Ordner, auf
die kein Eintrag zeigt, mit Groesse und Erklaerung (Rueckweg, Reserve, alte Drafts). Geloescht
wird nur auf Knopfdruck mit Rueckfrage. delete_model loescht keine Dateien mehr, die ein anderer
Eintrag nennt - seit den Bild-Zwillingen haette sonst das Loeschen eines Zwillings den Coder
mitgerissen. Der Projektor des Hirn-Zwillings liegt jetzt neben den Hirn-Gewichten, damit der
Original-Ordner loeschbar bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:17:11 +02:00
HitonabiandClaude Opus 5.5 133a491aca llama-swap: brains verdraengt nichts mehr (exclusive: false) - Lucy-Anfragen warfen den Coder raus
Ampel / ampel (push) Failing after 20s
Ohne den Key ist eine llama-swap-Gruppe exklusiv: JEDE Anfrage ans Hirn entlud den Coder und
die Bild-Zwillinge (live gemessen 24.09.). Fuer OpenChamber hiess das nach jeder Lucy-, NerdQuiz-
oder Job-Anfrage: Coder neu laden und den ganzen Vorlauf nachrechnen. set_group setzt den Key fuer
immer-warme Gruppen jetzt selbst, damit ein Hirn-Tausch ihn nicht wieder verliert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:05:25 +02:00
HitonabiandClaude Opus 5.5 91aa16eee1 bilder: Hirn und Coder sehen selbst - ueber Bild-Zwillinge, Text bleibt mit Draft schnell
Ampel / ampel (push) Failing after 21s
llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 "failed to process
speculative batch", b11057 und b11157 geprueft; speculative.n_max=0 je Anfrage hilft nicht).
Darum bekommen Hirn und Coder je einen Bild-Zwilling: gleiche Gewichte plus Projektor, ohne
Draft (vision, coder-bild), in einer eigenen llama-swap-Gruppe, die den Coder nicht verdraengt.
Probe 24.09.: beide 8/8 Bildmerkmale; Hirn-Zwilling 68 t/s, Coder-Zwilling 12,5 t/s.

Bild-Weiche v3 im Gateway: Bild im aktuellen Schritt geht an den Zwilling der Rolle, aeltere
Bilder werden einmal beschrieben (gemerkt) und als Text mitgeschickt, damit der Rest einer
Agenten-Aufgabe wieder beim schnellen Modell laeuft. Qwen3-VL gibt "vision" ab, der Coder
verliert den Projektor, der mit Draft nur HTTP 500 lieferte.

Radar misst die Bildfaehigkeit des heutigen Modells ueber dessen Zwilling (sonst gewaenne
jeder bildfaehige Kandidat mit "versteht Bilder"). Pruefstand: Coder darf vor dem Aendern
lesen (Version 3). Modelle-Seite zeigt "Bilder: ja" ueber den Zwilling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:02:06 +02:00
HitonabiandClaude Opus 5.5 2cc1e1cc47 waechter: gelesene Seiten zaehlen nicht als Werkzeugfehler
Ampel / ampel (push) Failing after 22s
Hermes haengt an jedes web_extract-Ergebnis ein leeres "error"-Feld und haelt ein Ergebnis
fuer gescheitert, sobald dieses Feld in den ersten 500 Zeichen steht - bei kurzen Seiten also
auch Erfolge. Der Waechter zaehlt solche Ergebnisse (mehrzeilig, mit "results") nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:44:55 +02:00
HitonabiandClaude Opus 5.5 292a9d2b6b hermes: web_extract liest Seiten lokal (Plugin mc2-web-lesen) statt jeden Morgen zu scheitern
Ampel / ampel (push) Failing after 21s
Die Box hat als Web-Anbieter nur DuckDuckGo, der kann nicht lesen. Hermes bot web_extract
trotzdem an, jeder Aufruf scheiterte, und der Waechter meldete den Nachrichten-Job jeden
Morgen gelb. Das Plugin nutzt Hermes' offizielle Anbieter-Schnittstelle (kein Eingriff in
den Hermes-Code) und liest wie MC2s fetch_url mit httpx und trafilatura, ohne fremden Dienst.
deploy.sh kopiert Hermes-Plugins aus dem Repo; aktiviert wird einmalig von Hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:41:52 +02:00
HitonabiandClaude Opus 5.5 8d46adfd86 radar: mehrdeutigen Ablenker im Pruefstand entschaerft, Baseline-Cache kennt den Probenstand
Ampel / ampel (push) Failing after 21s
Der Ablenker "system_speicher" passte zur Platz-Frage besser als die erwartete Antwort
(das Live-Hirn waehlte ihn am 23.09.). Weil schon ein Vorteil fuer "bestanden" reicht,
haette dieses Rauschen allein einen Kandidaten durchbringen koennen. Aendern sich die
Proben, misst baseline() jetzt neu statt alte Werte zu vergleichen. Dazu: uebersprungene
Radar-Eintraege zeigen kein "passt nicht" mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:33:27 +02:00
HitonabiandClaude Opus 5.5 177c9a311c boxwart: Modell-Radar sucht, testet nachts selbst und empfiehlt (Hirn und Coder)
Ampel / ampel (push) Failing after 21s
Suche aus Merkliste (deploy/radar-watchlist.json) und Hugging-Face-Entdeckung; nur
Kandidaten mit Bild-Projektor, die neben das Warm-Set passen (<= 115 GB inkl. KV-Cache).
Nachtlauf mc2-radar.timer 00:30, Tests nur bis 02:30 (um 03:00 kommt NerdQuiz),
hoechstens ein neuer Kandidat pro Woche, Notbremse 02:35, RuntimeMaxSec als letzte
Sicherung. Pruefstand als Modul (deploy/bench/pruefstand.py): Tempo, Werkzeuge,
Deutsch/JSON bzw. Programmieraufgaben und Bild-Probe gegen das heutige Modell der Rolle.
Durchgefallene werden geloescht, Bestandene gemeldet und erst nach "Uebernehmen" getauscht.

Beim Uebernehmen wandern die Zweitrollen mit (fast beim Hirn, heavy beim Coder), und das
Warm-Set des Stewards wird selbst umgestellt statt als Handgriff zu bleiben. Modell-Code
im Pruefstand darf keine Prozesse starten (RLIMIT_NPROC=0). Radar steht im Flugplan und
unter Waechter-Aufsicht; deploy.sh spielt seine Units ein. Oberflaeche: Vergleichswerte
je Kandidat, Rueckfrage vor Uebernehmen und Verwerfen, Status auf Deutsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:29:38 +02:00
HitonabiandClaude Opus 5.5 4b226d5d22 boxwart: Start wartet nie auf die Update-Suche im Netz, Verlauf am Handy kompakter
Ampel / ampel (push) Failing after 20s
Nach einem MC2-Neustart hing /api/start, bis GitHub, apt und Hugging Face
geantwortet hatten. Jetzt kommt der letzte Stand sofort, frisch geholt wird im
Hintergrund; bis dahin steht "wird geprueft" da.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:04:50 +02:00
HitonabiandClaude Opus 5.5 ed6948429a boxwart: Updates-Tabelle am Handy als Bloecke, Knoepfe ohne seitliches Scrollen erreichbar
Ampel / ampel (push) Failing after 20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:01:51 +02:00
HitonabiandClaude Opus 5.5 7135042752 boxwart: Update-Verlauf zeigt je Meldung nur den ersten Satz, Einzahl bei einer Aenderung
Ampel / ampel (push) Failing after 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:58:44 +02:00
HitonabiandClaude Opus 5.5 790de19f1a boxwart: Update-Verlauf zeigt das echte Ergebnis je Baustein, festgehaltene Bausteine sichtbar
Ampel / ampel (push) Failing after 22s
Der Verlauf las bisher nur den Hermes-Cron-Status ("Skript lief") und zeigte die
Laeufe vom 06. und 13.09. als "durchgelaufen", obwohl dort zurueckgerollt und
festgehalten wurde; ein abgebrochener Lauf stand als "laeuft" da. Jetzt kommt der
Verlauf aus dem Meldeprotokoll (~/mc2-notify.log), also aus dem, was autoupdate.sh
selbst je Baustein meldet.

Festgehaltene Bausteine (Pin-Register) werden zum Waechter-Hinweis mit Knopf
"Freigeben", auf der Updates-Seite markiert und in der Updates-Lampe gezaehlt.
Hermes zeigt seine laufende Version und die Zahl der neuen Aenderungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:56:19 +02:00
HitonabiandClaude Opus 5.5 f3af2adec3 fix(boxwart): unbekannte /api-Pfade sind 404, Waechter nennt die echte Fehlerursache
Ampel / ampel (push) Failing after 21s
- Der SPA-Rueckfall lieferte fuer unbekannte /api-Pfade die Startseite (HTML, 200). Im
  ersten Probelauf stuerzte der Start daran ab (/api/radar gab es noch nicht). Jetzt 404;
  api() behandelt Nicht-JSON als Fehler, der Router zeigt eine deutsche Fehleranzeige.
- Waechter: Skriptmeldungen ("! ...", fehlgeschlagen) gehen vor systemd-Rahmenzeilen wie
  "Failed to start ..." - die sagen nur, dass es scheiterte, nicht warum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:44:11 +02:00
HitonabiandClaude Opus 5.5 bfb07a92c3 umbau(boxwart): Schalter fuer den Probelauf neben dem echten Betrieb
Ampel / ampel (push) Failing after 21s
MC_WAECHTER_TROCKEN=1: Waechter prueft und fuehrt Hinweise, meldet aber nichts an
Telegram/Lucy und repariert nichts selbst. MC_PROBELAUF=1: die Probe-Instanz laesst
Erinnerungs-Schleife und Messverlauf aus (sonst zwei Schreiber, doppelte Erinnerungen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:40:31 +02:00
HitonabiandClaude Opus 5.5 907289d7dc umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben
Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:39:07 +02:00
HitonabiandClaude Opus 5.5 3d2c9549fb fix(boxwart): Nachrichten-Job raeumt nach dem Versand auf, Hermes-Dashboard-Link repariert
- news-melden.sh verschiebt Text- und Sprechfassung nach dem Versand nach *.gesendet.*.
  Hermes' write_file ueberschreibt keine vorhandene Datei; der liegengebliebene Bericht
  vom Vortag liess jeden Morgenlauf 4 von 5 Schreibversuchen scheitern. Ein zweiter
  Aufruf desselben Laufs erkennt den schon verschickten Bericht weiterhin.
- hermes_ui-Proxy: Seit Hermes v0.21 leitet das Dashboard auf /login um und die
  Anmeldeseite schickt an /auth/password-login — beides ohne /hermes-ui-Praefix. Der
  Knopf „Hermes-GUI oeffnen“ landete deshalb auf MC2s „Diese Seite gibt es nicht“.
  Umleitungen und Anmelde-Pfade werden jetzt umgeschrieben, mehrere Set-Cookie bleiben.
- Tests fuer die Pfad-Umschreibung (12 Backend-Tests gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:18:43 +02:00
HitonabiandClaude Opus 5.5 12dadfe6ef umbau(boxwart): Backend auf Box-Wart umgestellt – Waechter, Modell-Nutzung, Zeitplan
MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026).

- Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe,
  Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste
  startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy.
  Laeuft im mc2-steward; waehrend eines Updates haelt er still.
- Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen),
  /api/modelle/nutzung, /api/zeitplan.
- Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde).
- Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events;
  Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise.
- Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot);
  Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan.
- Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:16:18 +02:00
HitonabiandClaude Opus 5 3669a6e7dc llama-swap: --reasoning-budget 2048 fuers Hirn (Deckel gegen Denk-Ausreisser der abliterierten Variante)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:23:17 +02:00
HitonabiandClaude Opus 5 cd54fe4a0f modelle: Hirn und Coder auf Uncensored-Varianten (Pruefstand-gestuetzt), Pruefstand-Skript ins Repo
Ampel / ampel (push) Failing after 24s
Pruefstand 17.09. (deploy/bench/pruefstand-hirn.py, Kandidat auf :5899, Hermes-Tool-Smoke per
--provider pruefstand durch den echten Agenten):
- Qwen3.6-35B-A3B-Uncensored (HauhauCS Aggressive, Q4_K_M) + giocom-DFlash: 100,8 t/s @13k
  (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreisser), Hermes-Smoke gruen -> hermes/fast.
- Qwen3.8-27B-Uncensored (Heretic, Q4_K_M, eigene mmproj) + DFlash2: 30,4 t/s @13k (Original 26,2),
  Tools 6/6, Coding 3/3 ausgefuehrt (= Original), Hermes-Smoke gruen -> coder/heavy.
- Nemotron 3.5 Lightning 30B-A3B (Q4_0 + MTP): Hirn-Klasse (10,5 s Prefill @13k, 91,8 t/s, Tools 3/3,
  Hermes gruen) - liegt als Reserve-Kandidat auf der Box, nicht besetzt.
Keys/Aliase/Flags unveraendert, nur Pfade; Originale bleiben liegen (Rueckweg = Pfad zurueck).
Nebenbefund: Bild + DFlash2 beim Coder = HTTP 500 "failed to process speculative batch" - Vorbestand
seit 04.09., unabhaengig vom Modell (ohne Draft antworten beide "Rot"); Bilder gehen ueber die
Gateway-Bildweiche zu vision. Live seit 17.09. ~22:10, Stack- und Gehirn-Check gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:10:54 +02:00
HitonabiandClaude Opus 5 1e7a6d974f doku: approvals bleiben aus (User-Entscheid 17.09.), sudo-Stand und Config-Schema v45 nachgezogen
Ampel / ampel (push) Failing after 24s
Live-Wahrheit statt Juli-Stand: approvals.mode 'off' / cron_mode auto (seit spaetestens
KISS-Umbau 21.08., am 17.09. vom User bestaetigt), Box-sudo NOPASSWD: ALL, Hermes-Config
v45 mit bewusst deaktiviertem connections-Toolset und Curator 14/30 Tage. Das alte Verdikt
"cron_mode deny bleibt" ist in VERDIKTE.md als ueberholt markiert, FALLEN.md-Cron-Notiz angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:52:32 +02:00
HitonabiandClaude Opus 5 61f226e86d wartung: Engine b11026 braucht --load-mode none (statt --no-mmap); hermes update ohne eigenen Gateway-Neustart
Ampel / ampel (push) Failing after 23s
Befund 17.09.: Sonntags-Update hatte beide Ebenen gepinnt (Hermes 06.09., Engine 13.09.),
die Box stand zwei Wochen still. Live nachgestellt und behoben:

- llama.cpp hat --no-mmap zwischen b10819 und b10936 gestrichen ("invalid argument"):
  jedes Modell starb 2 s nach dem Start, der Stack-Check sah nur "rot". Ersatz
  --load-mode none in llama-swap-Config, CMD_TEMPLATE und Bench-Skripten. Gemessen auf
  b11026: Coder+DFlash2 32,0 t/s (vorher 30,0), Hirn 85 t/s, alle sieben Rollen laden.
- hermes update endet mit Exit 1, wenn sein Fleet-Check nach dem eigenen Gateway-Neustart
  keine Zeilen sieht (#93406) - unter systemd bei uns der Normalfall. Die &&-Kette des
  MC2-Jobs brach ab, autoupdate.sh rollte zurueck und pinnte, obwohl der Gehirn-Check gruen
  war. Jetzt --no-gateway-restart: Neustart und Urteil gehoeren dem Job.
- Box live: Engine b11026, Hermes v0.21.3 (main @ dd13b475), UI mit /hermes-ui/-Basis neu
  gebaut, Pins geloest. STACK.md/FALLEN.md nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:25:34 +02:00
HitonabiandClaude Fable 5.1 6809d357c0 doku: Einstieg fuer den Qwen3-TTS/audio.cpp-Pruefstand (Binaries auf der Box, Modelle, CLI, Messlatte)
Ampel / ampel (push) Failing after 24s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:13:16 +02:00
HitonabiandClaude Fable 5.1 502a6010f1 doku(raphael): Ausroll-Stand 04.09. 19:10 festgehalten (MC2 f9f2e11 deployt, Lucy be686c4, Stimmserver Artoria v2 / 8 Threads / 2 Worker, Box kann nicht klonen, ZONOS2 verworfen, Ampel-Runner tot)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:10:24 +02:00
HitonabiandClaude Fable 5.1 f9f2e116e0 doku(raphael): Box-Handschritte erledigt + erste E2E-Messung (TTFB 1,45 s, RTF 0,97, workers=0)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:29:34 +02:00
HitonabiandClaude Fable 5.1 479fecd2c6 Auftragsbuch ausgebaut: Karten-Annahme, Bagatell-Annahme, Lucy-Annahme raus — Gate ist Ampel + User-Merge
Ampel / ampel (push) Failing after 24s
Die Vorschlags-Inbox (routers/services auftragsbuch, AuftragsbuchView, /auftraege, Cockpit-
Kachel + Handlungsbedarf-Eintrag, deploy/auftrag-annehmen.sh, lucy-annahme.sh, bagatell-
annahme.sh, docs/AUFTRAGSBUCH.md) war seit 02.08.2026 ungenutzt: Kanban-Tools seit 21.08. aus,
v3-Umbau lief ueber Branch -> Ampel -> Merge -> deploy.sh, PC-Executor-IP in Box und Config
veraltet. Doku behauptete den Karten-Weg trotzdem als Standard.

Jetzt:
- Cockpit: Kachel "Ideen" (offen/haengend) statt "Auftragsbuch"; Handlungsbedarf zeigt haengende
  Ideen und springt in die Ideen-Ansicht.
- Guide/Schaubild: Kreislauf Idee -> Ideen-Queue -> IDE am PC -> Ampel -> DEIN Merge -> Live.
- events.py: kein Auftragsbuch-Fingerprint mehr; config.py: PC_EXECUTOR_URL-Default auf .22.
- Skills/Skripte: selbst-inventur ohne Karten-Abschnitt, morning-report nennt offene Branches,
  konzept-fliessband verweist auf die Ideen-Ansicht.
- Doku: STACK "Deploy & Pipeline" = Branch -> Ampel -> User-Merge -> deploy.sh (einziger Weg),
  GRENZEN/ARBEITSWEISE/FALLEN/README/BEDIENUNG/RUNBOOK/GEMINI_BRIEFING/gitea-workflow angepasst,
  ZIELBILD Punkt 9, OFFENE-FAEDEN + RAPHAEL.md: erledigt.
- Bewusst geblieben: werkstatt-SOUL/projektstart-SOUL (Werkstatt-Persona, eigener Faden),
  Chronik-Kategorie "auftragsbuch" fuer historische Eintraege.
Auf der Box bereits erledigt (Hand): mc2-bagatell.timer deaktiviert + Units archiviert,
PC-Executor-URL in ~/.hermes/config.yaml auf .22.

Baut auf wartung/lucy-stimme-proxy-raphael auf (Proxy /api/lucy/stimme/*).
Gates: eslint 0 Fehler, vitest 59/59, tsc + vite build gruen, frontend/dist committet,
py_compile gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:25:05 +02:00
HitonabiandClaude Fable 5.1 df8d088fac doku(raphael): Ausroll-Weg korrigiert — Branch -> Ampel -> User-Merge -> deploy.sh, nicht Auftragsbuch
Ampel / ampel (push) Failing after 23s
Der vorige Commit sprach von Karten annehmen. Befund: die Auftragsbuch-Logik liegt noch im Code
und die Box listet Branches als Karten, ist aber seit 02.08. ungenutzt (Kanban-Tools seit 21.08.
aus, v3-Umbau lief ueber Branch/Ampel/Merge/deploy.sh, PC-Executor-IP in der Box veraltet).
RAPHAEL.md beschreibt jetzt den echten Weg mit Befehlen; OFFENE-FAEDEN traegt die Leiche als
Entscheid-Punkt ein. Kein Code geaendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:07:03 +02:00
HitonabiandClaude Fable 5.1 b570e9410d feat(voice): Lucys Stimme ins LAN (/api/lucy/stimme) + Raphael-Zielbild
Ampel / ampel (push) Failing after 24s
Die Desktop-Lucy spricht nach dem Raphael-Umbau (Lucy-Repo, feature/raphael-innere-stimme)
nicht mehr mit einem eigenen pocket_server am PC, sondern mit lucy-stimme.service (:8021,
pocket-tts german_24l) auf der Box — dieselbe Stimme wie die Telegram-Sprachnachrichten.
Der Dienst bindet nur Loopback, darum reicht MC2 ihn jetzt duenn durch:
  GET  /api/lucy/stimme/health       -> pocket /health (ok|loading)
  POST /api/lucy/stimme/tts          -> WAV (Warm-up, Jobs)
  POST /api/lucy/stimme/tts/stream   -> PCM16-Stream, X-Sample-Rate durchgereicht,
                                        Upstream schliesst bei Client-Abbruch (Barge-in)
config: LUCY_STIMME_URL (Env MC_LUCY_STIMME_URL, Default http://127.0.0.1:8021).
Kein Frontend-Build noetig (nur Backend + Doku).

Doku: docs/wissen/RAPHAEL.md (Entscheid, Annahme-Reihenfolge — DIESE Karte zuerst —,
Box-Handschritte fuer die OpenAI-Fassade + Hermes-TTS auf openai/base_url :8021, Mobil via
Telegram, SOUL.md-Vorschlag im Raphael-Ton), ZIELBILD Punkt 8, OFFENE-FAEDEN, wissen/README.
VERDIKTE.md bewusst unveraendert — Ersatz erst nach Ohr-Test.
Gates: py_compile + ruff gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:02:18 +02:00
HitonabiandClaude Fable 5.1 e9c2599542 fix(radar): nur Aenderungen ans Modell, und kein Fehlalarm nach dem Neustart
Ampel / ampel (push) Failing after 23s
Erster Lauf des Upstream-Blicks: das Modell machte aus "unveraendert"-Zeilen
naechste Schritte und aus "Timer seit Neustart noch nicht gelaufen" ein hohes
Risiko. Beides filtert jetzt das Skript: nur ACHTUNG-Zeilen (plus Hinweis)
gehen in den Prompt, und ein Timer mit naechstem Termin auf einer Box, die
juenger als einen Tag ist, gilt als wartend, nicht als kaputt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:53:55 +02:00
HitonabiandClaude Fable 5.1 a8a6ce3b29 feat(radar): Blick nach draussen als Skript — und heavy zieht auf Qwen3.8-27B
Warum: DFlash2 fuer den Coder lag vom 19.08. bis 04.09. ungenutzt auf der
Platte, weil der KISS-Radar nur nach innen sah. stack-upstream.py fragt die
Watchlist als Fakten ab (GitHub-Issues/PRs/Releases/Branches, PyPI, neue
Repos eines HF-Autors, Zeilen einer URL), vergleicht mit dem letzten Lauf und
meldet nur Aenderungen als ACHTUNG NEU. stack-radar.sh haengt das an den
IST-Zustand; faellt es aus, bleibt der Innen-Bericht vollstaendig.
Erster Lauf fand sofort: MMQ-Issue 21284 ist geschlossen, pocket-tts 3.1.0,
Electron 44 — alles Dinge, die der Radar seit Juli haette melden sollen.

heavy: gpt-oss-120b (AA-Index 24, 60 GB) gibt die Rolle an Qwen3.8-27B ab
(Index 52, 17 GB, 31 t/s mit DFlash2) — ein Modell, zwei Rollen. gpt-oss
bleibt ohne Alias als Rollback. Live gemessen ueber :9010 mit Reasoning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:52:50 +02:00
HitonabiandClaude Fable 5.1 0944b1d93e feat(llama-swap): DFlash2-Draft für den Coder — gemessen 12,6 → 31 t/s
Ampel / ampel (push) Successful in 35s
Qwen3.8-27B lief seit 19.08. ohne sein Entwurfsmodell, obwohl es auf der Box
lag: Mainline-llama.cpp konnte DFlash2 erst seit 27.08. (PR 27342) plus
Vulkan-Fix 28.08. (PR 27812); die Engine vom 1.9. (b10733) kann beides.
Auf der Box gemessen (Vulkan, 32k, Code-Prompt): ohne Draft 12,6 t/s,
mit Q4_K_M-Draft 31 t/s bei Akzeptanz 0,78. n-max 5, f16-KV und der
Q8-Draft bringen nichts. Live-Config und Repo-Kopie sind identisch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:45:54 +02:00
HitonabiandClaude Opus 5 1c8f285151 Merge: v3-Umbau P0-P6 — Frontend-Architektur, Sicherheit, Werkzeug-Verlauf
Ampel / ampel (push) Successful in 35s
Zehn Commits vom Zweig umbau/v3-p0-ballast, Ampel dort gruen.

  P0  Ballast raus            dist 27,4 MB -> 1,65 MB, Startbuendel halbiert
  P1  Sicherheit + Tests      Geheimnisse aus dem Browser, Vitest/ESLint ins Gate
  P2  Router                  Unterzustand in der URL, Fehlergrenze pro Route
  P3  Store + Statusleiste    ein Client-Speicher, aria-live, Ultrawide-Deckel
  P4  Stream                  Messwerte gepusht statt gepollt
  P5  Ideen-Bereich           1031-Zeilen-Monolith zerlegt
  P6  Werkzeug-Verlauf        Hermes' Log lesen statt patchen — fand einen vier
                              Tage alten stillen Fehler (web_extract)
      Palette 2.0             vier Kategorien, Rueckfrage-Regel per Test verdrahtet
      React 19                Budget bewusst 125 -> 140 kB
      Chronik-Dichteleiste    zeigt WANN, bevor man liest WAS
  +   Hermes-Probe            720 Auth-Fehler/Tag weniger in Hermes' Log
  +   Zeitzone                ruff DTZ007 — die Ampel hatte recht

59 Tests (vorher 0) · ESLint 0 Fehler · 20 von 22 Befunden erledigt.
Rueckweg: git reset --hard 3d1881f && bash deploy/deploy.sh

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:28:22 +02:00
HitonabiandClaude Opus 5 041ede7f8d fix(agent): Zeitstempel mit Zeitzone — die Ampel hatte recht
Ampel / ampel (push) Successful in 36s
Die Ampel wurde ROT: ruff DTZ007, `datetime.strptime()` ohne Offset in
services/agent_aktivitaet.py. Lokal war alles gruen, weil ich ruff nicht laufen
liess — nur py_compile, tsc, vitest und den Build. Mein Fehler, nicht der der Regel.

Und die Regel ist keine Schikane: AGENTS.md haelt fest, dass naive Zeiten ueber
MC_LOCAL_TZ aufzuloesen sind, nie zu raten. Hermes schreibt Ortszeit ohne Offset;
das so durchzureichen waere bequem gewesen (der Klient zeigt sie nur an) — aber
sobald jemand spaeter damit RECHNET (Dauer ueber Mitternacht, Abgleich mit einem
Cron-Plan), waere es eine Falle, die erst zur Zeitumstellung zuschnappt.

  vorher:  2026-08-28T07:03:18
  jetzt:   2026-08-28T07:03:18+02:00

Gegengeprueft am echten Box-Log; die Anzeige schneidet weiterhin Stelle 11-19
heraus und zeigt unveraendert 07:03:18.

`ruff check .` laeuft jetzt ueber das ganze Repo sauber durch — ab hier gehoert
es vor jeden Commit, der Python anfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:04:08 +02:00
HitonabiandClaude Opus 5 33032f8fb0 feat(chronik): P6 — Dichteleiste zeigt, WANN die Box aktiv war (§4.5)
Ampel / ampel (push) Failing after 25s
Der Zugewinn gegenueber einer reinen Liste: Ein stiller Tag und eine durchgearbeitete
Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken. Die
Dichteleiste macht das Muster sichtbar, bevor man den ersten Eintrag liest.

48 Stufen ueber den gezeigten Zeitraum, Achsenbeschriftung an beiden Enden, und beim
Filtern zeigt sie nur die Treffer (dann in kraeftigerem Ton).

Bewusst eigenes SVG-freies Markup statt einer Diagramm-Bibliothek: Es sind Rechtecke
ueber einer Zeitachse. Recharts dafuer zu laden waere 95 kB fuer etwas, das in 30
Zeilen passt — genau der Reflex, der in P0 das Startbuendel aufgeblaeht hatte.

Eine Kleinigkeit mit Absicht: Ein einzelner Eintrag bekommt 12 % Mindesthoehe. Ohne
sie saehe ein ruhiger Tag aus wie gar keine Aktivitaet — genau die Verwechslung, die
die Leiste verhindern soll. Dafuer gibt es einen eigenen Test.

Verifiziert gegen die echte Box:
  150 Eintraege vom 02.08. bis 28.08., 25 von 48 Stufen belegt, Hoehen gestaffelt
  Filter "update" -> 30 von 150, Leiste zeigt nur noch die Treffer und wird
    hervorgehoben, Adresse /chronik?q=update

59 Tests gruen (5 neue) · ESLint 0 Fehler · Einstieg 133 400 B gzip / Budget 140 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:02:07 +02:00
HitonabiandClaude Opus 5 3249c8e942 build(react): P6 — Sprung auf React 19, und das Budget bewusst angehoben
React 18.3.1 -> 19.2.8, dazu @types/react(-dom) 19.

## Nichts brach

  tsc --noEmit sauber, 54/54 Tests gruen, ESLint 0 Fehler
  Im Browser gegen die ECHTE Box: Cockpit mit 2 Diagrammen und 4 Flaechen,
    Statusleiste live (40,8/122,7 GB, 38 °C, 532 748 115 Token), 8 Karten
  NULL Konsolen-Warnungen — keine veralteten Muster im Bestand

Das eigentliche Risiko war `react-force-graph-2d` (peer: react "*", also ungeprueft,
und es umhuellt eine Nicht-React-Bibliothek). Gegen den echten Vault der Box getestet:
Canvas 500x560, 4498 gezeichnete Pixel, keine Fehlerflaeche. Es laeuft.

## Das Budget hat angeschlagen — und ich habe es trotzdem angehoben

Gemessen kostet der Sprung 14 645 B gzip (118 762 -> 133 407) und riss damit das
in P0 gesetzte Budget von 125 000. Das Gate hat also getan, was es soll.

Beim Router (P2) habe ich in derselben Lage NICHT das Budget angefasst, sondern den
Platz zurueckgeholt (Schublade und Palette hinter lazy()). Hier geht das nicht: Der
Zuwachs IST die Plattform, es gibt nichts wegzulassen.

Also angehoben — einmalig, auf 140 000, mit der Begruendung IM Ampel-Skript, damit ein
spaeterer Leser sieht, dass es ein ueberlegter Schritt war und kein Nachgeben:
MC2 ist eine LAN-Appliance; 14 kB sind ueber Gigabit-Ethernet keine messbare Wartezeit.
Das Budget existiert gegen DRIFT — einen 24-MB-Avatar, eine 95-kB-Diagramm-Bibliothek
auf der Startseite — nicht gegen einen bewussten Plattform-Schritt. Der relative
Abstand zum Deckel bleibt derselbe wie vorher (~5 %).

## Ehrlich zum Nutzen

React 19 bringt diesem Projekt HEUTE wenig Konkretes: use(), Actions und
Server-Komponenten sind hier nicht im Spiel. Der Grund ist die Zukunft — 18 altert,
und der React Compiler ist der Pfad, auf dem die 18 offenen Warnungen aus
eslint-plugin-react-hooks (setState im Effekt, Ref-Zugriff im Render) irgendwann
nicht mehr nur Warnungen sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:59:58 +02:00
HitonabiandClaude Opus 5 24a0694b80 feat(palette): P6 — die Palette wird zum Bedienwerkzeug (§4.6)
Bisher listete Strg+K die elf Navigations-Eintraege: ein Sprungbrett. Jetzt tippt
man, WAS man will, nicht wo es liegt.

  Gehe zu   die 11 Ziele — direkt aus NAV, damit es nie zwei Listen gibt
  Tun       Wartung/Logs oeffnen, Updates suchen, Snapshot, Motor neu starten
  Finden    das Getippte in Chronik oder Wissens-Vault suchen
  An Lucy   das Getippte als Idee erfassen

## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben

Die Palette ist schnell genug, dass "Strg+K, mot, Enter" den Motor neu startet,
bevor man es zu Ende gedacht hat. Jeder eingreifende Befehl traegt darum
`rueckfrage`; die Oberflaeche haelt dann an und sagt erst, was passieren WIRD:

  "Alle geladenen Modelle fallen dabei aus dem Speicher und muessen neu aufwaermen —
   das dauert je nach Modell ueber 20 Sekunden. Laufende Antworten brechen ab."

Ein Test prueft die Regel generisch: Jeder Befehl, dessen Label auf "neu starten",
"loeschen", "deploy", "reboot" o. AE. passt, MUSS eine Rueckfrage haben. Wer kuenftig
einen eingreifenden Befehl ohne Rueckfrage ergaenzt, macht die Ampel rot.

"An Lucy" erfasst mit `art: "idee"` — durchdenken, nicht bauen. `"projekt"` wuerde
sofort ein Repo anlegen; das darf aus einem Tastendruck nie passieren. Auch das ist
getestet.

## Die "Finden"-Eintraege fuehren nicht ins Leere

Sie brauchten erst ein Ziel — sonst haette ich genau die leere Leitung gebaut, die ich
beim Werkzeug-Verlauf vermieden habe:

  ChronikView   neues Suchfeld, filtert ueber Betreff/Text/Quelle, Wort in der URL
                (/chronik?q=…), Trefferzahl in der Ueberschrift, "Filter aufheben"
                im Leer-Zustand
  WissenView    hatte das Suchfeld schon, aber nur lokal — jetzt aus /wissen?suche=
                gespeist und damit teilbar

## Verifiziert im Browser

  Strg+K oeffnet, "Gehe zu" (11) + "Tun" (5); leere Gruppen bleiben verborgen
  Text tippen -> "Finden" (2) und "An Lucy" (1) erscheinen
  "Motor neu starten" traegt den Chip "fragt nach"; Auswahl zeigt die Rueckfrage
    mit vollem Folgentext — es wird NICHTS gestartet
  "Abbrechen" fuehrt zurueck in die Liste, keine Meldung, kein Aufruf

54 Tests gruen (10 neue) · ESLint 0 Fehler · Einstieg 118 762 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:56:26 +02:00
HitonabiandClaude Opus 5 a494d320d7 feat(agent): P6 — Werkzeug-Verlauf, und der Blocker war nur eine halbe Wand
Der Blueprint sah eine "Live Agent Matrix" mit Denkstrom vor (§4.4). In P4 hatte ich
sie als blockiert gemeldet: Lucys Denkschritte entstehen im Hermes-Prozess, und
AGENTS.md verbietet es, dessen Quellcode zu patchen.

Beim Nachsehen zeigte sich, dass die HAELFTE davon offen daliegt.
`~/.hermes/logs/agent.log` protokolliert JEDEN Werkzeug-Ruf:

  INFO [cron_195e…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
  WARNING [cron_195e…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}

Eine Log-Datei zu LESEN ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug,
wie lange, mit welchem Ergebnis, in welchem Lauf — ist fuer "was tut sie gerade und wo
haengt es" oft nuetzlicher als der Fliesstext ihrer Gedanken.

## Es hat sich beim ersten Blick bezahlt gemacht

Der Parser lief gegen den echten Box-Log und meldete sofort:

  web_extract      2 Rufe   2 Fehler   <-- IMMER ROT
      DuckDuckGo (ddgs) is a search-only backend and cannot extract URL content.
  web_search      20 Rufe   0 Fehler   Schnitt 1,77 s

Nachgesehen: `web_extract` scheitert seit MINDESTENS dem 25.08. jeden Morgen um 07:00
mit derselben Meldung — im Daily-News-Cron, vier Tage lang, ohne dass es irgendwo
aufgefallen waere. Genau dafuer steht die Bilanz OBEN und die Zeitleiste darunter:
Ein Dauerfehler verschwindet in einer Ereignisliste, in der Zeile
"web_extract · 2 Rufe · 2 Fehler" nicht.

## Was die Ansicht NICHT verspricht

Denkstrom und Werkzeug-Argumente stehen nicht im Log und tauchen darum auch nicht auf.
Das steht so in der Ansicht selbst, nicht nur im Code — eine Oberflaeche, die mehr
andeutet als sie hat, ist schlimmer als eine, die ihre Grenze nennt.

## Umsetzung

  services/agent_aktivitaet.py   liest nur die letzten 512 kB (die Datei waechst auf
                                 MB und wird rotiert), vier gemessene Zeilenformen,
                                 Bilanz je Werkzeug + Gruppierung je Lauf
  GET /api/agent/aktivitaet      limit gedeckelt auf 300
  features/agent/Werkzeugverlauf.tsx   Bilanz -> Laeufe -> aufklappbare Zeitleiste

Fehlt das Log (Entwicklungsrechner ohne Hermes), verschwindet der Abschnitt still,
statt eine leere Karte zu zeigen — wie der Rest der Seite es haelt.

## Verifiziert

  Parser gegen den echten Box-Log: 26 Rufe, 4 Werkzeuge, 2 Laeufe erkannt,
    Rauschen (mem_trim, aiohttp) ignoriert
  Im Browser gegen dieselben Daten: "IMMER ROT"-Markierung sitzt, Grund im Klartext,
    Laufgruppen mit Zeitspanne, 26 Einzelrufe in der Zeitleiste
  7 neue Tests (44 gesamt) — sie pruefen gezielt die BILANZ-Karte, nicht irgendein
    Vorkommen des Werkzeugnamens; der steht auch in der Zeitleiste

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:51:14 +02:00
HitonabiandClaude Opus 5 7957cda438 fix(sentry): Hermes-Probe schickt ihren Schluessel mit — 720 Fehler/Tag weniger
Beim Untersuchen von Hermes' Log gefunden: MC2s eigene Erreichbarkeits-Probe rief
`GET /v1/models` ohne API-Schluessel und produzierte damit alle zwei Minuten ein
`API server rejected invalid API key` in ~/.hermes/logs/agent.log.

  Gemessen: 30 Vorfaelle/Stunde, seit dem 27.08. 15:05 durchgehend — 558 Eintraege
  im aktuellen Log, ~720/Tag.

Das URTEIL war nie falsch: `_reach` prueft `status_code < 500`, und ein 401 beweist
ja, dass jemand zuhoert. Falsch war der Laerm — und dass ein echter Auth-Fehler darin
untergegangen waere.

MC2 hat den Schluessel laengst in der Config (HERMES_API_KEY, aus ~/.hermes/.env).
Die Probe schickt ihn jetzt als Bearer mit. Die Nachsichtigkeit von `< 500` bleibt
absichtlich: Die Frage ist "laeuft der Dienst", nicht "darf ich rein".

Auf der Box gegengeprueft:
  ohne Schluessel: 401
  mit Schluessel:  200

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:44:45 +02:00
HitonabiandClaude Opus 5 dcee0096fe refactor(ideen): v3-Umbau P5 — der Monolith bekommt einen eigenen Bereich
Ampel / ampel (push) Successful in 34s
Sechste Etappe, erster Bereich. views/IdeenView.tsx hatte 1031 Zeilen, 26 useState
und sechs Komponenten in derselben Datei (Befund B-06) — der Haupteingang des
Systems, und niemand konnte ihn ohne Vollbild-Scrollen ueberblicken.

## Der Schnitt

Er folgt den Grenzen, die im Monolithen ohnehin schon lagen: Jede Top-Level-Funktion
wird eine Datei. Der INHALT ist unveraendert — nur die Datei ist neu, damit der Diff
pruefbar bleibt.

  features/ideen/zustaende.ts       55   Zustands-/Art-Tabellen + Sortier-Rang
  features/ideen/quote.ts           22   Nutzungs-Quote der Eingabe
  features/ideen/Wahl.tsx           27   eine Auswahlkachel
  features/ideen/ErgebnisPanel.tsx  55
  features/ideen/KonzeptPanel.tsx   73
  features/ideen/ProjektGruppe.tsx  86
  features/ideen/WeiterLeiste.tsx  113
  features/ideen/IdeenAnsicht.tsx  676   Verfasser + Liste + Zeilen-Renderer

views/IdeenView.tsx bleibt als vierzeiliger Wegweiser eine Fassung lang stehen,
damit ein uebersehener Import nicht stillschweigend bricht; der Router zeigt bereits
direkt auf features/ideen/.

## Was BEWUSST noch nicht geschnitten ist

Der Zeilen-Renderer `zeile()` (188 Zeilen) bleibt in IdeenAnsicht.tsx. Er greift auf
16 Werte des umgebenden Zustands zu (busy, openLog, openKonzept, antworten, logData,
steuer, archive, answer, …). Ihn "herauszuloesen" hiesse, eine Props-Liste mit 16
Eintraegen zu schreiben — dieselbe Verflechtung in anderer Schreibweise. Das ist eine
Frage der Zustands-Zugehoerigkeit, keine Verschiebe-Uebung, und verdient eine eigene
Runde statt eine Beifaenger-Aenderung. Steht so auch im Kopfkommentar der Datei.

## Nebenbefund beim Pruefen: die eigene Anzeige log

Gegen die noch nicht aktualisierte Box zeigte die Statusleiste "Getrennt" — obwohl
die Poller sauber lieferten und alle Zahlen stimmten. Grund: Dort gibt es /api/stream
noch nicht, der SPA-Catch-all beantwortet den Pfad mit index.html, und EventSource
scheitert daran.

Die Anzeige verwechselte damit zwei Dinge. Jetzt trennt sie sie:
  Getrennt  = die Zentrale antwortet nicht, die Zahlen sind wirklich alt
  Nachlauf  = kein Strom, aber die Zentrale antwortet — die Zahlen stimmen,
              sie kommen im Takt statt sofort
  Live      = Strom steht
Eine Anzeige, die vor korrekten Daten warnt, ist genauso falsch wie eine, die
veraltete verschweigt.

## Verifiziert

  Gegen das lokale Backend: /ideen rendert, keine Fehlerflaeche
  Gegen die ECHTE Box (altes Backend, kein /api/stream): Leiste sagt "Nachlauf",
    zeigt die echten Werte (40,7/122,7 GB Unified Memory, GPU 0 %, 37 °C,
    532 748 115 Token, 1023 € gespart) — und blendet die Betriebszeit AUS, weil das
    alte Backend sie nicht liefert, statt eine Zahl zu erfinden.
    Das neue Frontend degradiert also sauber gegen den alten Stand.

37/37 Tests gruen · ESLint 0 Fehler · Einstieg 118 606 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:22:45 +02:00
HitonabiandClaude Opus 5 b74e98ffbb feat(stream): v3-Umbau P4 — Messwerte kommen gepusht statt gepollt
Fuenfte Etappe. Der Ereignisstrom war bisher ein reiner Anstoss-Bus, und die
Messwerte holte sich das Frontend im 3-Sekunden-Takt selbst — zwei Dauer-Anfragen,
unabhaengig davon, ob sich etwas geaendert hatte (Befund B-12).

## Backend: /api/stream

routers/events.py bedient jetzt zwei Endpunkte aus EINEM Sammler:

  /api/stream   `invalidate` (bei Aenderung) + `metrik` (jede Sekunde)
  /api/events   nur `invalidate` — bleibt EINE Fassung lang stehen, weil ein
                Browser-Tab nach einem Deploy noch das vorige Buendel halten kann
                und dieses nur /api/events kennt

Dazu services/system.py → metrik_punkt(): ein bewusst LEICHTER Messpunkt.
`system_status()` waere hier falsch — es ruft `psutil.cpu_percent(interval=0.1)`
und blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt 10 % der Zeit),
plus den Versions-Check, den niemand sekuendlich braucht. Gemessen: 0,2 ms je
Punkt mit `interval=None`.

Token stehen als GESAMTZAEHLER im Ereignis, nicht als Rate. So bleibt der Server
zustandslos und ein verpasster Punkt verfaelscht nichts — der Klient rechnet die
Rate aus zwei Punkten.

Neu im Fingerabdruck: Jobs (Zustand + Fortschritt). Damit ist auch der 3-s-Poller
der System-Schublade nur noch Sicherheitsnetz.

## Was bewusst FEHLT

Kein `agent`-Thema fuer Lucys Denkschritte. MC2 kann Hermes' interne Schritte nicht
sehen, ohne dessen Quellcode zu patchen — per AGENTS.md verboten. Eine leere Leitung
zu bauen waere eine Zusage, die keiner einloest. Das betrifft die Agent-Matrix aus
§4.4 der Spezifikation; sie braucht zuerst eine Datenquelle.

## Frontend

lib/events.ts hoert auf /api/stream und schreibt `metrik` direkt in den
Metrik-Speicher. Der bleibt bewusst ein useSyncExternalStore AUSSERHALB von React
(nicht der Zustand-Store aus P3): Bei einem Wert pro Sekunde wuerde ein Store-Update
jede abonnierende Komponente neu rendern.

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

Der erste Wurf schaltete beide Poller bei stehendem Strom komplett ab (`false`).
Das waere falsch gewesen: Beide Antworten tragen mehr als Messwerte —
/api/system/status die Versions-Hashes fuer den Schienen-Fuss, /api/system/token-stats
die Gesamtsumme und die Cloud-Ersparnis, fuer die das Backend die Tarife aufloest
(die Preis-Logik ist dort die einzige Wahrheit; sie im Klienten nachzubauen waere
eine zweite). Beides waere eingefroren.

Jetzt 3 s → 60 s bei stehendem Strom: ein Zwanzigstel der Last, und die Randdaten
bleiben frisch. Die MESSWERTE selbst kommen aus dem Strom — useSystemHistory legt
den letzten Messpunkt ueber die Query-Antwort, damit Legende, Temperatur und
Betriebszeit nicht zwischen zwei Minuten-Abfragen stehen bleiben.

## Verifiziert

  Server: 12 `metrik`-Ereignisse in den ersten 4 kB des Stroms (1/s)
  Server-Log ueber die ganze Prozesslaufzeit: /api/system/status 3 Anfragen,
    /api/system/token-stats 3 Anfragen — vorher waere das eine je 3 Sekunden gewesen
  Browser: Statusleiste zaehlt live weiter (Speicher 14,6 → 12,9 GB, CPU 3 → 2 %,
    Betriebszeit 1:22 → 1:23) bei NULL fetch-Aufrufen im 49-s-Fenster
  Cockpit: beide Diagramme rendern (2 Container, 5 Flaechen)
  /api/events antwortet weiterhin (Alt-Tab im Log)

Einschraenkung, ehrlich: Die Browser-Pane war waehrend der Messung verborgen, und
TanStack Query pausiert Intervalle in Hintergrund-Tabs. Die Null im Klienten ist
daher KEIN sauberer Beleg fuer die Drosselung — der Server-Log ist es. Nebenbefund:
Der Strom laeuft auch im Hintergrund-Tab weiter, die Poller nicht.

37/37 Tests gruen (4 neue fuer pushMetrik: Ratenbildung, Zaehler-Ruecksprung,
letzter Messpunkt; MAX_POINTS ist jetzt exportiert, damit der Deckel-Test nicht
wieder gegen eine veraltete Kopie prueft) · ESLint 0 Fehler · Einstieg 118 582 B
gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:16:29 +02:00
HitonabiandClaude Opus 5 5aa5a484a7 feat(frontend): v3-Umbau P3 — ein Store, eine Statusleiste, endlich aria-live
Vierte Etappe. Der Client-Zustand hatte bisher keinen Ort: zwei handgebaute
useSyncExternalStore-Speicher, drei CustomEvent-Kanaele und ein localStorage-Griff
mitten in einem useState — verteilt ueber fuenf Dateien.

## Ein Store (Befund B-08/B-09)

app/store.ts (Zustand, 1,2 kB gzip) haelt genau vier Dinge: Bedienvorlieben
(Schiene, Expertenmodus, Palette), die Lage des Ereignisstroms und die
Meldungs-Warteschlange. Der Kopfkommentar nennt die Regel, nach der etwas dort
landen darf — und ein Test prueft sie: gespeichert werden AUSSCHLIESSLICH
Schienen- und Expertenmodus-Zustand, kein Strom, keine Meldungen, kein Geheimnis.

Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts): Er nimmt ab P4
jede Sekunde einen Messpunkt entgegen, ein Store-Update pro Sekunde wuerde jede
abonnierende Komponente neu rendern.

Die drei CustomEvent-Kanaele sind ERSATZLOS weg — `grep -r dispatchEvent src`
liefert nichts mehr. Wer die Schublade oeffnen will, setzt `?system=`; wer springen
will, navigiert. Auch der letzte Rest von P2 (`mc-palette-open`) ist aufgeloest.

## Statusleiste (Spezifikation §4.1)

Der Zustand der Box stand bisher verstreut: die Ampel unten in der Sidebar, die
Auslastung nur im Cockpit, das aktive Modell nur im Modell-Manager. Wer in der
Konsole arbeitete, sah gar nichts.

Jetzt eine feste Leiste ueber allen Ansichten: Motor/Hirn-Ampel (klickbar zum
Agenten) · aktive Rolle + Durchsatz mit Sparkline · geteilter Speicher ALS BALKEN
(nicht als Prozent — 128 GB Unified Memory ist die Kernzahl dieser Box) · CPU/GPU
· Temperatur (ab 85 °C bernstein) · Betriebszeit · Token-Summe · Live-Anzeige.

Die Sparklines sind eigenes SVG, ~20 Zeilen. Fuer eine 56-Pixel-Linie waere eine
Diagramm-Bibliothek Verschwendung.

Dafuer neu im Backend: `uptime_s` in /api/system/status (psutil.boot_time).
Gehoert dorthin, weil die Box sich woechentlich selbst neu startet — dann ist
"laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
Liefert psutil nichts, steht dort None und die Leiste blendet das Feld aus,
statt eine Zahl zu erfinden.

## aria-live (Befund B-11) — und diesmal mit Absender

Im ganzen Frontend gab es KEIN einziges aria-live. app/shell/Meldungen.tsx ist
jetzt die eine Stelle fuer beilaeufige Rueckmeldungen; Fehler bekommen
`assertive` und bleiben stehen, alles andere `polite` und raeumt sich weg.

Wichtig: Die Region ist keine Attrappe. Gespeist wird sie von den Uebergaengen des
Ereignisstroms und vom Konsolen-Neustart (mit dem echten Grund aus ApiError.detail).
Der erste Verbindungsaufbau wird bewusst NICHT gemeldet — sonst begruesst jede
Seite den Nutzer mit "Verbindung wieder da".

## Ehrliche Anzeige statt stillem Altern (Befund B-15/B-18)

lib/events.ts fuehrt die Stromlage jetzt im Store: live · nachlauf · getrennt.
Beim Wechsel wird EINMAL invalidiert — vorher las relax() den Zustand erst beim
naechsten Refetch, nach einem Riss blieb die UI bis zu 150 s im langsamen Modus.

## Bahnbreiten-Deckel (Befund B-10)

Die Arbeitsflaeche endet bei 1600 px. Ohne Deckel zog sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px, waehrend die Wurzel-Schriftgroesse mitwuchs
und die Zeilen GROESSER statt lesbarer machte.

## Nebenbei

Der Buendel-Budget-Check aus P0 war fragil: `ls dist/assets/index-*.js | head -1`
kann den falschen treffen, weil Rollup auch kleine geteilte Module "index-*.js"
nennt (gemessen: ein 67-Byte-Chunk neben dem 374-kB-Einstieg). Er waere dann still
immer gruen gewesen. Beide Ampel-Dateien lesen den Einstieg jetzt aus index.html.

## Verifiziert im Browser

  Statusleiste: alle Felder mit Live-Daten, "Laeuft seit 1 Std 10 min"
  Backend abgewuergt  -> Leiste springt auf "Nachlauf" UND die aria-live-Region
                         meldet "Verbindung zur Zentrale verloren" (polite)
  Backend zurueck     -> "Live", ohne Begruessungs-Meldung
  Cockpit-Kachel      -> /cockpit?system=logs, Schublade offen, Reiter "System-Logs"
  Arbeitsflaeche      -> max-width 1600 px

33/33 Tests gruen (7 neue fuer den Store) · ESLint 0 Fehler · tsc sauber ·
Einstieg 118 153 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:07:09 +02:00
HitonabiandClaude Opus 5 464b0d20b8 feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL
Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:58 +02:00
HitonabiandClaude Opus 5 ae69c5ce31 fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet
Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.

## B-01 — Box-Sudo-Passwort: ersatzlos entfernt

Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.

AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:

  frontend/src/lib/api.ts             kein localStorage-Zugriff mehr
  frontend/.../SettingsTab.tsx        Eingabefeld weg, dafuer die Erklaerung warum
  backend/routers/maintenance.py      SudoReq entfaellt, 8 Endpunkte entschlackt
  backend/services/maintenance.py     _run() nutzt immer `sudo -n`
  backend/services/jobengine.py       keine stdin-Pipe mehr (DEVNULL)

`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.

## HuggingFace-Token: liegt jetzt auf der Box

Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.

## B-14 — Fehlermeldungen sagen jetzt, was los ist

api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).

## B-07 — Tests und Linter, ehrlich eingeordnet

Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.

  Vitest + Testing Library, 17 Tests in 3 Dateien
  ESLint (flat config) + Prettier
  Beides jetzt Teil der Ampel

Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).

ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.

## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy

Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).

## Nachgemessen

  Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
  tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler

Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:44:48 +02:00
HitonabiandClaude Opus 5 8aa22e6591 perf(frontend): v3-Umbau P0 — Ballast raus, Startbuendel halbiert
Erste Etappe des v3-Umbaus. Bewusst ohne jede Architektur-Aenderung: nur
Entruempeln, damit der Rest des Umbaus auf einem messbar leichten Stand aufsetzt.

Gemessen (vorher -> nachher):
  dist gesamt      27 375 720 B -> 1 477 852 B   (-94,6 %)
  Dateien in dist         135   ->        35
  Start-Chunk gzip    220 278 B ->   110 571 B   (-49,8 %)
  CSS gzip             31 577 B ->    15 121 B   (-52,1 %)
  Schrift-Dateien         112   ->        11     (WOFF 1: 0)

Vier Eingriffe:

1) avatar.vrm (24,5 MB) entfernt. Lag in public/ und dist/, wurde von KEINER
   Zeile des Repos referenziert — der Renderer Avatar3D.tsx war schon vorher
   verschwunden. War 89,5 % der Nutzlast, die auf die Box ging. Damit fallen
   auch die .gitignore-Sonderregel und der Direkt-Deploy-Schritt weg.
   DISASTER_RECOVERY.md §3·D ehrlich auf "ausgebaut" gesetzt (7 Stellen).

2) Schriften: die 11 @fontsource-Sammelimporte zogen ALLE Subsets (latin-ext,
   griechisch, kyrillisch) und je eine WOFF-1-Fassung mit — 112 Dateien, 1,58 MB,
   davon 902 kB WOFF 1, das kein Browser dieser App je abruft. Jetzt stehen die
   @font-face-Regeln direkt in index.css: nur latin, nur WOFF 2, nur die Schnitte,
   die per grep ueber die font-*-Klassen wirklich belegt sind.
   Nebenbei behoben: JetBrains Mono 600/700 fehlten komplett — die 19 Stellen mit
   `font-mono font-bold/semibold` wurden vom Browser synthetisch fettgerechnet.
   Jetzt echte Schnitte; bei Monospace ist die Laufweite gleich, kein Layout-Versatz.

3) Recharts aus dem Startbuendel. Das Cockpit ist die Startseite und laedt daher
   NICHT lazy; ueber SystemStatusCard/TokenPerformanceCard zog es Recharts samt
   d3 in index-*.js. LiveAreaChart ist jetzt eine Lazy-Huelle (Suspense mit
   hoehengleichem Platzhalter, damit nichts springt), die Recharts-Umsetzung liegt
   in LiveAreaChartImpl.tsx und kommt als eigener Chunk nach (105 kB gzip).

4) index.css: height 100dvh mit 100% als Rueckfall. Auf Mobilbrowsern mit
   einfahrender Adressleiste ist 100 % nicht die sichtbare Hoehe.

Dazu ein Buendel-Budget in beiden Ampel-Dateien (.gitea/ = MC2-Fassung,
deploy/ = universelle Vorlage): Start-Chunk und dist-Gesamtgroesse werden am
FRISCHEN Build im Runner gemessen. Bewusst kein Vergleich mit dem committeten
dist — der waere ueber Node-Versionen hinweg flatterhaft und wuerde dauerhaft
rot leuchten, was das Signal zerstoert.

Verifiziert gegen die Box (Frontend-Dev mit MC_API_TARGET=192.168.178.151:9001):
Cockpit rendert mit Live-Daten, keine Konsolenfehler, alle 11 Schriftschnitte
registriert, LiveAreaChartImpl + recharts laden nachweislich als Nachlade-Chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:27:49 +02:00
HitonabiandClaude Opus 5 3d1881f4bc docs(stack): Rollen-Tabellen auf den gemessenen Stand - Kritiker war Fiktion
Ampel / ampel (push) Successful in 21s
Recherche zur offenen Frage "fehlt der kritiker?": Er fehlt nicht, er wurde
abgeschafft. Die Modell-Konsolidierung (9a35be9, 19.08.) warf Devstral aus
llama-swap, und fremdblick.sh - der EINZIGE Aufrufer der Rolle - fiel mit dem
MC2-Kahlschlag (1e68f62). Live gegengeprueft: kein Skill, keine Hermes-Config
und kein Profil nennt 'kritiker'. Gewollt so: ein Coder, ein Agent-Hirn.

Beim Abgleich zeigte sich, dass die Tabellen weit mehr erfanden als den
Kritiker. Gegen /etc/llama-swap/config.yaml geprueft sind es SIEBEN Rollen:
hermes/fast, coder, debugger, vision, heavy, reranker, embed. Korrigiert:

- `coder` stand als Qwen3-Coder-Next (80B MoE, 51,5 t/s) drin - real ist es
  Qwen3.8-27B mit 131k ctx und ~12,7 t/s. Der Eintrag `dense-planer` war
  dasselbe Modell ein zweites Mal: es ist kein Planer NEBEN dem Coder, es IST
  der Coder.
- `kritiker`, `scout` und `doctor` existieren nicht mehr - Zeilen raus, mit
  Notiz warum, damit niemand wieder danach sucht.
- Hermes v0.20.4 -> v0.20.6; die Behauptung eines Bot-Rosters ("Coder mit
  Qwen3-Coder-Next, Debugger mit Muse-Glimmer") ist frei erfunden: die
  bots-Sektion in ~/.hermes/config.yaml ist LEER, die Profile fahren hermes,
  fast und vision.
- README verlangte fuer die Gruppe `brains` ausdruecklich `persistent: false`
  - live steht `true`. Auf die gemessene Regel korrigiert: entscheidend ist
  nicht der Schalter, sondern dass alles gleichzeitig Warme in DIESELBE Gruppe
  gehoert (zwei Gruppen verdraengen sich, persistent schuetzt nicht davor).

Weiter geprueft und richtiggestellt:

- config.py behauptete, die Hermes-UI binde NUR auf Loopback. Tut sie nicht:
  ein systemd-Drop-in setzt --host 0.0.0.0 und dafuer ein Session-Token. Dass
  sie nicht im LAN haengt, liegt allein an ufw - von einem zweiten Rechner aus
  gegengeprueft (9119/8642/9010/7682 dicht, 9001/8080 offen wie gewollt). Der
  Kommentar sagt das jetzt, damit sich niemand auf die Loopback-Annahme verlaesst.
- AGENTS.md: die feingranularen sudoers.d-Regeln schraenken nichts ein, weil
  /etc/sudoers pauschal NOPASSWD: ALL gewaehrt. Steht jetzt dort, statt Sicherheit
  vorzutaeuschen. (Die doppelte Zeile wurde auf der Box entfernt, visudo -c ok,
  Sicherung /root/sudoers.bak-20260827-170315.)

Auf der Box ausserdem: Qwen3-Coder-Next-GGUF geloescht (46 GB frei, 240G -> 194G).
Der Ordner war in der Live-Config mit 0 Treffern nicht mehr eingebunden; die drei
verbliebenen Code-Referenzen sind ein Katalog-Eintrag zum Wiederinstallieren und
zwei Kommentare zur Groessen-Heuristik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:04:33 +02:00
HitonabiandClaude Opus 5 2935752613 feat(autoupdate): Box startet woechentlich neu, wenn das OS es verlangt
Ampel / ampel (push) Successful in 21s
Kernel- und libc-Updates werden erst nach einem Neustart wirksam. Das faellt
niemandem auf: am 27.08.2026 lief die Box seit 7 Wochen auf Kernel 7.0.0-27,
waehrend -28, -29 und -30 ungenutzt installiert dalagen.

Der Neustart haengt am BESTEHENDEN Sonntagslauf (Hermes-Cron 30 4 * * 0 ->
sonntags-update.sh -> autoupdate.sh), nicht an einem neuen Timer - ein Ort fuer
alle Automatik, genau die Lehre vom 20.08. Ausgeloest wird er nur, wenn Ubuntu
ihn selbst anfordert (/var/run/reboot-required).

Drei Sicherungen, alle im Trockenlauf geprueft:
  1. linger MUSS aktiv sein. Ohne linger startet systemd die User-Dienste nach
     einem Neustart nicht - die Box kaeme ohne Steuerpult, Gateway und Waechter
     hoch und waere aus der Ferne nicht mehr erreichbar. Das ist die wichtigste
     Zeile der Funktion.
  2. Kein laufender Job wird abgewuergt (Zaehlung ueber /api/jobs).
  3. mission-control-2, mc2-gateway und mc2-steward muessen enabled sein.
Greift eine Sicherung, meldet die Funktion den Grund per Telegram und Stimme
und startet NICHT neu.

Der Aufruf steht bewusst NACH der Abschlussmeldung - davor haette der Neustart
den Wochenbericht verschluckt. Abschaltbar mit MC_AUTOUPDATE_REBOOT=0.

Im Trockenlauf gefunden und mitbehoben: die Funktion las $USER, das in systemd-
und Cron-Umgebungen nicht gesetzt ist. Mit dem set -u des Skripts haette das den
GANZEN Update-Lauf abgebrochen, nicht nur den Neustart. Jetzt id -un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:07:49 +02:00
HitonabiandClaude Opus 5 a969898a16 fix(deploy): Skript gegen sich selbst absichern - alles in main()
Ampel / ampel (push) Successful in 21s
Beim Deploy am 27.08.2026 live erlebt: der `git pull` in Schritt 1 brachte eine
neue Fassung von deploy.sh mit, ausgefuehrt wurde danach trotzdem die alte
Logik (die Ausgabe zeigte den alten Text des Config-Sync-Schritts).

Ursache: bash liest ein Skript haeppchenweise WAEHREND es laeuft. Wird die Datei
mitten im Lauf ersetzt, liest bash am selben Byte-Offset im neuen Text weiter -
im harmlosen Fall greift die alte Logik, im schlimmen Fall fuehrt es eine
zerschnittene Zeile aus.

Fix: der komplette Ablauf liegt jetzt in main(), aufgerufen als letzte Zeile.
Eine Funktion wird vollstaendig geparst, bevor sie startet. Aenderungen an
deploy.sh greifen damit sauber ab dem naechsten Aufruf statt mittendrin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:51:48 +02:00
HitonabiandClaude Opus 5 57492d6759 docs(postcheck): WARN zum fehlenden Patch-Traeger praezisiert
Ampel / ampel (push) Successful in 21s
apply.py wurde mit dem MC2-Kahlschlag (1e68f62) entfernt, nicht versehentlich
verloren. Der Postcheck meldete nur "nicht gefunden" - das las sich wie ein
Defekt. Jetzt steht der Grund dabei; WARN statt FAIL bleibt, damit es sichtbar
ist, falls wieder Runtime-Patches gebraucht werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:42 +02:00
HitonabiandClaude Opus 5 ba9ea7743f fix(swap-config): Repo-Abzug auf den Live-Stand gezogen (Coder-Vollkontext)
Ampel / ampel (push) Successful in 22s
Der Abzug stand noch auf --parallel 2 / context 65536, die laufende Box seit
34a9862 auf --parallel 1 / 131072 (gesetzt ueber deploy/coder-vollkontext.sh,
aber nie in den Abzug uebernommen).

Ein Deploy haette den Coder damit zurueck auf den halben Slot gesetzt und den
Fix von 34a9862 rueckgaengig gemacht - genau der Datenverlust-Pfad, den der
vorige Commit in deploy.sh abgesichert hat.

Jetzt inhaltlich deckungsgleich mit /etc/llama-swap/config.yaml; es
unterscheiden sich nur noch Kommentare.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:02:28 +02:00
HitonabiandClaude Opus 5 84dc34c0a3 fix(stack): Mem0 restlos ausgebaut + vier stille Defekte behoben
Systemaudit vom 27.08.2026. Alle Befunde gemessen, nicht vermutet.

VIER STILLE DEFEKTE

1. mc2-steward startete seit Wochen nicht (live: 207.609 Neustarts).
   steward.py importierte services.memory, das beim Mem0-Ausbau geloescht
   wurde -> ImportError bei jedem Start. Re-Warm- und Health-Waechter
   waren damit tot.

2. Jedes Hermes-Update wurde automatisch zurueckgerollt.
   hermes-postcheck.sh prueft vier Dinge, die es seit dem 07.08. nicht mehr
   gibt (Sidecar :8765, /api/memory, memory.provider, mc2-memory-Plugin).
   Die Checks konnten nicht gruen werden -> autoupdate.sh wertete jedes
   Update als rot und rollte es zurueck. Checks ersatzlos entfernt; der
   Tool-Smoke laeuft ohnehin durch den echten Agenten.

3. 7 von 12 Skills waren per Knopfdruck nicht startbar.
   deploy.sh kopiert Skills mit tr '-' '_' nach ~/.hermes/skills/,
   routers/skills.py gab Hermes aber den Ordnernamen MIT Bindestrich.
   Der Knopf meldete Erfolg, ausgefuehrt wurde nichts. Neu: _hermes_name().

4. deploy.sh warf bei jedem Deploy die Live-Modellkonfiguration weg.
   MC2 schreibt /etc/llama-swap/config.yaml selbst; die Repo-Datei ist nur
   ein Abzug (ihm fehlt u.a. kritiker/Devstral). Jetzt: erst sichern, Diff
   zeigen, dann kopieren. MC_DEPLOY_SKIP_SWAP_CONFIG=1 ueberspringt.

MEM0-AUSBAU VOLLENDET (Kriterium 3: 17 -> 0 Dateien)
- mem0_service/, mcp/mcp_memory.py und hermes/plugins/mc2-memory entfernt;
  das Plugin schickte bei JEDEM Turn zwei 404-Requests an tote Routen.
- MEMORY_DB/MEM0_SERVICE_URL, _mem0_reachable(), MC_MEMORY_DB und
  MC_MEM_DEDUPE_ENABLED aus Config/Router/Unit entfernt.
- mem0_ms war strukturell tot (park("retrieve") wird nirgends mehr
  aufgerufen) -> aus Backend, API-Typ und Latenzkarte entfernt.
- Verbinden-Tab: tote Gedaechtnis-MCP-Leitung raus, Status-Kachel bleibt.
- AGENTS.md beschrieb Mem0 noch als aktiv - korrigiert.

GATEWAY-ROBUSTHEIT
- _proxy gab bei ungueltigen Payloads HTTP 500 (gemessen 5/5: Rohtext,
  leerer Body, JSON-Liste, JSON-String, null) -> jetzt 5/5 HTTP 400.
- Bild-Weiche ohne Deckel: 10 Bilder x 2 Versuche x 240 s hielten den
  Client bis zu 80 min. Neu: MC_CODER_IMAGE_MAX (4), Rueckfall auf die
  Vision-Umleitung.
- /v1/models: nicht-JSON von der Engine gab 500 -> jetzt 502.

UNITS UND DEPLOY
- mc2-steward.service, dessen warmset-Drop-in und voice-service.service
  fehlten im Repo, obwohl maintenance.py und stack-postcheck.sh sie
  voraussetzen. 1:1 von der laufenden Box uebernommen.
- deploy.sh startete mc2-steward nie neu; restore.sh liess mc2-gateway und
  mc2-steward mit alter Config weiterlaufen. Beide ergaenzt.

FRONTEND
- useEigenleben rief /api/eigenleben - existiert im Backend nicht und wurde
  nirgends genutzt. Samt Typen entfernt.
- Anleitung beschrieb einen Gedaechtnis-Tab, den es nicht gibt.
- Abgeglichen: alle uebrigen 63 Frontend-Aufrufe treffen echte Routen, alle
  5 SSE-Invalidation-Keys sind gemappt, keine ungefangenen Promises.

Gates: compileall gruen - ruff "All checks passed" - tsc gruen - vite build
gruen (dist aktualisiert) - Importe app/steward/gateway_app gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:58:42 +02:00
HitonabiandClaude Opus 5 34a9862591 fix(kontext): Coder bekommt den ganzen Slot - 65536 auf 131072
Ampel / ampel (push) Successful in 22s
Ursache der haeufigen Komprimierung war kein zu scharfer Automatismus, sondern
ein falscher Wert von mir: opencode.json deklarierte 131072, der Slot hatte aber
nur 65536 (-c 131072 --parallel 2). Beweis im Log: finish_reason=length ->
400 Bad Request -> Wiederholung 200 OK. Diese Wiederholung war die Komprimierung.

Jetzt: coder mit --parallel 1 = volle 131072 (er hat nur einen Verbraucher;
Lucy und explore nutzen hermes, review nutzt heavy). fast behaelt seine zwei
Slots und steht ehrlich auf 65536. Am laufenden Prozess gegengeprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:44:10 +02:00
HitonabiandClaude Opus 5 f3139d2b5d docs(governor): Plugin abgeschaltet - SAVEPOINT-Hook kollidierte mit OpenChambers Komprimierung
Ampel / ampel (push) Successful in 21s
Bei jeder Komprimierung schob der Governor einen zusaetzlichen Auftrag nach
(SAVEPOINT.md schreiben); der Agent verlor dadurch einen Zug an Buchhaltung.
Nachgewiesen im Referenzlauf 22.08. 14:47. Der Abbau war schon am 21.08.
angeordnet - ich hatte das Behalten empfohlen und Zustimmung angenommen,
statt sie einzuholen.

Quelle bleibt im Repo, Wiederherstellung ist ein Copy-Item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:33:14 +02:00
HitonabiandClaude Opus 5 6e573d5551 fix(modellwahl): coder statt fast als Standard - meine Empfehlung war unbelegt
Ampel / ampel (push) Successful in 21s
Bis heute stand fast als Standard mit der Begruendung, es schlage den coder in
Tempo UND Qualitaet. Das Tempo war gemessen, die Qualitaet nie ('keine
agentische Messung', Notiz vom 20.08.). Qwens Zahlen sagen das Gegenteil:
einzelschuss gleichauf (SWE-bench 73,4), agentisch zieht Qwen3.8-27B davon
(Terminal-Bench 73,0, DeepSWE 42,2 gegen 13,3, OSWorld 84,3).

Preis gemessen: gleiche Aufgabe 24,3 s mit fast, 89,3 s mit coder.
Aufteilung jetzt: coder baut, heavy plant, das warme hermes erkundet.
Konfiguration ausserdem ins Repo geholt - sie lag nur an einer Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:41:58 +02:00
HitonabiandClaude Opus 5 2210bf5c40 feat(radar): Curator-Pruefung in den Stack-Radar gefaltet
Ampel / ampel (push) Successful in 22s
Das Repo curator-staleness-check war ein eigener Wachhund mit eigenem
systemd-Timer und eigener CI-Ampel - und ueberwachte einen Cron-Job ueber eine
fest verdrahtete ID (a47910e2ef05), den es laengst nicht mehr gibt. Er haette
bei jedem Lauf Alarm geschlagen fuer etwas, das nicht existiert.

Dieselbe Frage steht jetzt in vier Zeilen im Radar. Repo archiviert.
Falle dabei: find -printf %T@ liefert einen Float, bash bricht damit ab - %Ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:06:25 +02:00
HitonabiandClaude Opus 5 dc78114102 fix(jobs): Doppelversand behoben - Werkzeug-Timeout loeste einen zweiten Aufruf aus
Ampel / ampel (push) Successful in 23s
Erster echter Lauf am 22.08. schickte den Bericht zweimal (07:01:42 und
07:02:14). Ursache: Hermes' terminal-Werkzeug bricht nach 30 s ab, die
Sprachsynthese dauert laenger, der Agent hielt den Aufruf fuer gescheitert und
wiederholte ihn - der Text war da laengst raus.

Jetzt: Text senden, Stimme per setsid abgekoppelt, Rueckkehr nach 1,1 s.
Dazu eine Doppelversand-Sperre ueber den Text-Hash, die jede Wiederholung
innerhalb von zwei Stunden abweist - egal aus welchem Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:59:45 +02:00
HitonabiandClaude Opus 5 259e1b2ca8 docs(coding-lane): Abschluss - SSH-Zugang zu Gitea, ein Schluessel statt acht Zugangsdaten
Ampel / ampel (push) Successful in 21s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:13:02 +02:00
HitonabiandClaude Opus 5 148a039545 fix(deploy): push-und-sync auf den SSH-Weg umgestellt
Ampel / ampel (push) Successful in 22s
Gitea-Zugang laeuft seit 21.08. ueber ssh://gitea@192.168.178.153:2222.
Fehlermeldung nennt jetzt die richtige Pruefung - inklusive der Falle, dass
der SSH-Benutzer 'gitea' heisst und nicht 'git'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:12:18 +02:00
HitonabiandClaude Opus 5 25dbfb2b61 feat(stimme): Lucys echte Stimme auf der Box - pocket-tts 2.1.0 als lucy-stimme.service
Ampel / ampel (push) Successful in 21s
:8650 lieferte ElevenLabs 'Artoria/Saber' = Hermes' Stimme, nicht Lucys (und
Cloud). Jetzt :8021 mit Kyutai pocket-tts, Modell german_24l, geklont aus
ref.mp3 - samt text_norm, Emotions-Presets und Pegel-Abstimmung. Sprachnachricht
als OGG/Opus via ffmpeg. Recherche: pocket ist weiter die richtige Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:01:23 +02:00
HitonabiandClaude Opus 5 fa69edc1aa fix(jobs): Persona zurueck - Emojis, Quellen-Links, Commander-Anrede, Stimme in Telegram
Mein erster Prompt verbot Emojis und Deko und ueberstimmte damit SOUL.md.
Formatierung kommt aus der Persona, nicht aus dem Job. Dazu news-melden.sh:
Text via notify.sh, Stimme via voice-service :8650 (Lucys echte deutsche
Stimme, nicht Hermes' englisches edge-TTS) und hermes send MEDIA:.
SOUL.md-Verweise auf die abgeschalteten Werkzeuge kanban/delegation behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:35:02 +02:00
HitonabiandClaude Opus 5 9cf04c427e docs(hermes): Werkzeuge nach Bedarf - tool_search lief schon, Totes abgeschaltet
Gemessen mit prompt-size: cli 99,4 -> 56,5 KB (-43%), cron 63,1 -> 16,4 KB (-74%).
Groesster Fund: der Zugangsweg 'cron' hatte keinen platform_toolsets-Eintrag und
bekam die volle Werkzeugkiste, obwohl nur ein Job ueberhaupt Werkzeuge braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:20:22 +02:00
HitonabiandClaude Opus 5 f446da8ae6 feat(jobs): KISS-Umbau der Automatik - 10 Mechanismen auf 3 Hermes-Crons
Fakten sammelt ein Skript, Prosa schreibt das Modell. stack-ist.sh prueft
Ergebnisse statt Lebenszeichen und fand beim ersten Lauf sofort zwei echte
Befunde. Abgeschaltet: 4 Crons + 4 Timer, davon einer nie gelaufen und einer
15 Naechte ohne Wirkung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:10:10 +02:00
HitonabiandClaude Opus 5 c2a54bd25d feat(governor): Plugin ins Repo geholt, git push freigegeben, force push bleibt gesperrt
Das Plugin existierte nur im Konfigordner des PCs - ohne Sicherung, ohne Historie.
Zaun-Regel 'git push' entfernt (jede Aenderung soll im git landen), dafuer
gezielte Sperre fuer --force/--mirror/--delete. Beides gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:06:50 +02:00
HitonabiandClaude Opus 5 56668ee43d feat(coding-bahn): Quelle ist der PC - OpenCode laeuft lokal, Modell kommt ueber MC2 :9001
Kurswechsel: Projekte liegen lokal, OpenChamber arbeitet an lokalen Dateien,
push nach Gitea, Box zieht via projekte-sync nach. Kein Box-Umbau noetig -
MC2 :9001/v1 reicht die Rollen-Aliase bereits durch. Box-Server auf :4096
zurueckgebaut, ufw-Regel entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:56:28 +02:00
HitonabiandClaude Opus 5 c8e5a7162b docs(openchamber): Falle dokumentiert - Ordner-Knopf reicht Windows-Pfade an den Box-Server durch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:40:01 +02:00
HitonabiandClaude Opus 5 00b64b9fdc docs(openchamber): Aufbau erledigt - OpenCode 1.18.20 auf Box, OpenChamber 1.19.0 am PC, Fernanbindung verifiziert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:27:03 +02:00
HitonabiandClaude Opus 5 7c23ebf382 docs: Umbauplan OpenChamber - getrennte Bahnen fuer Coding und Betrieb
Ampel / ampel (push) Successful in 22s
Entscheidung 21.08.: Coding zieht auf OpenChamber (ueber OpenCode), Lucy
bleibt der Hermes-Runtime mit voller SysAdmin-Rolle. Zwei Bahnen statt
einer eierlegenden Wollmilchsau - der Weg, der hier schon einmal ging.

Ausloeser: Hermes Desktops Gateway-Registry akzeptiert kein Benutzer/
Passwort (nur Session-Token oder OAuth), darum fiel der Sessions-Reiter
immer auf das lokale Geraet zurueck.

Rueckbau ist erledigt und in dieser Datei protokolliert (PC entkernt,
Box aufgeraeumt, ufw 9119 zu, Lucy nachweislich unversehrt). Der Aufbau
steht aus und ist hier Schritt fuer Schritt beschrieben - inklusive der
Fallen aus dem Juli-Audit und der heute gemessenen Modellwahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:10:46 +02:00
HitonabiandClaude Opus 5 e04ace242c docs(referenz): Lauf A ausgewertet - Arbeitsanweisung gegen die Analyse-Schleife
Ampel / ampel (push) Successful in 22s
Lauf A ueber Nacht: 6 Sitzungen, ~336 Nachrichten, ~360.000 Tokens,
19 Dateien gelesen - und NULL Dateien geschrieben. Das Werkzeug fuer
Dateioperationen war die ganze Zeit verfuegbar, wurde nie benutzt.

Der Agent lief fuenfmal in dasselbe Muster: "Ich habe jetzt das
vollstaendige Bild, jetzt lese ich nur noch die restlichen Dateien,
bevor ich anfange." Dann war max_turns erreicht. Sein eigenes Fazit:
"Iterationslimit erreicht, bevor ich die erste Zeile geaendert habe."

Wichtigste Erkenntnis: das ist KEIN Tempo-Problem. Mit einem schnelleren
Modell waere derselbe Lauf genauso gescheitert, nur frueher. Darum
kommen A2 (Arbeitsanweisung) und D (reasoning low) jetzt VOR den
Modellwechseln B und C.

Neu: Arbeitsanweisung ganz oben - hoechstens zwei Dateien lesen, bevor
die erste geaendert wird. Dazu agent.max_turns 40 -> 80 im coder-Profil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:14:25 +02:00
HitonabiandClaude Opus 5 d880a76b6a fix(referenz): Pruefbefehl aus dem Suchpfad genommen, Lauf D ergaenzt
Ampel / ampel (push) Successful in 22s
Zwei Fehler aus dem ersten Anlauf behoben:

1. referenz-check.sh lag unter deploy/ und enthielt selbst 7x das Wort
   "mem0" (es sucht ja danach) - damit zaehlte es sich in Kriterium 3 mit
   und das Kriterium war unerfuellbar. Jetzt unter docs/aufgaben/, wo
   Kriterium 3 nicht sucht. Strukturell geloest statt per grep-Ausnahme.

2. Der Lauf muss in einer FRISCHEN Sitzung starten - der Desktop hatte
   eine Sitzung vom Vortag fortgesetzt und deren Kontext mitgeschleppt.

Neu: Lauf D (reasoning_effort low). Der Fehlversuch zeigte, dass >95%
der Modell-Ausgabe unsichtbares Nachdenken war - 19.899 Tokens fuer
2.600 Zeichen sichtbaren Text. Das ist der billigste Hebel und wird
darum vor Modellwechseln geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:57:07 +02:00
HitonabiandClaude Opus 5 ff319ff291 feat(deploy): referenz-check.sh - objektiver Fertig-Test fuer die Messlatte
Ampel / ampel (push) Successful in 21s
Damit 'fertig' keine Auslegungssache ist: derselbe Befehl fuer den Agenten
zur Selbstpruefung und fuer die Auswertung. Prueft alle fuenf Kriterien
und gibt ein klares Urteil plus Exit-Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:07:45 +02:00
Hitonabi 6e3440379b docs: Referenzaufgabe auf gemessene Pfade korrigiert (ruff-venv, app statt main, Worktree)
Ampel / ampel (push) Successful in 22s
2026-08-20 21:06:40 +02:00
HitonabiandClaude Opus 5 5f35f0d580 docs: Referenzaufgabe fuer die Stack-Messlatte (Stufe 2)
Ampel / ampel (push) Successful in 21s
Mem0-Ausbau als wiederholbare Messaufgabe: mehrteilig genug um
aussagekraeftig zu sein, mit fuenf objektiv pruefbaren Fertig-Kriterien
und einer Ruecksetz-Anleitung fuer die Wiederholungslaeufe.

Gemessen wird nicht t/s, sondern: wird die Aufgabe fertig, wie lange
dauert es, wie viele Zuege braucht es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:04:24 +02:00
HitonabiandClaude Opus 5 43fa747bc3 feat(deploy): projekte-sync.sh - haelt ~/projekte mit Gitea deckungsgleich
Ampel / ampel (push) Successful in 22s
Der Hermes-Desktop laeuft gegen das Box-Backend, sein Ordner-Waehler zeigt
also das Dateisystem der Box. Damit dort alle Projekte auftauchen und aktuell
sind, holt dieses Skript sie regelmaessig.

Bewusst konservativ, weil es unbeaufsichtigt laeuft:
- nur --ff-only, kein merge/rebase/reset/force
- Repos mit lokalen Aenderungen werden uebersprungen statt ueberfahren
- mission-control-v2 wird NIE gezogen (Live-Deployment, pusht selbst),
  bekommt nur eine Verknuepfung fuer den Waehler
- bestehende Checkouts in ~ bleiben liegen, ~/projekte verknuepft sie
- interne Gitea-IP statt DDNS (die ist nachts durch Zwangstrennung tot)

Erster Lauf: 6 Repos neu geklont, lucy aktualisiert, rippy-windows korrekt
wegen lokaler Aenderungen uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:50:05 +02:00
HitonabiandClaude Opus 5 83c6ecc613 fix(skills): worker.sh und restliche Doku auf Rollen-Aliase umgestellt
Ampel / ampel (push) Successful in 21s
Zweite Fundwelle derselben Ursache: deploy/worker.sh setzte
WORKER_MODEL-Default auf Qwen3-Coder-Next - damit waere JEDE delegierte
Bau-/Refactor-Aufgabe des Orchestrators mit HTTP 404 gescheitert.

Ausserdem: konzept-fliessband nannte GLM-4.7-Flash als Skeptiker,
router_logic.py nannte Qwen3-Coder-Next hinter der coder-Rolle.
Modellnamen raus, Rollen rein - Namen veralten, Rollen nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:09:29 +02:00
HitonabiandClaude Opus 5 b316ad40ba fix(skills): Rollen-Aliase statt Modellnamen + Savepoint auf gemessenen Stand
Ampel / ampel (push) Successful in 21s
Der Kritiker-Gate war stumm kaputt: orchestrator/wartung-Skill und
fremdblick.sh riefen Qwen3-Coder-Next, GLM-4.6V-Flash und GLM-4.7-Flash
auf - alle drei von der Modell-Konsolidierung (9a35be9) entfernt. Im
llama-swap-Log schlug das als HTTP 404 auf.

Ursache war das Muster, Modelle beim Eigennamen zu nennen. Jetzt Aliase
(coder/debugger/fast/heavy), die llama-swap aufloest - damit ueberlebt
jede Faehigkeit den naechsten Modellwechsel.

SAVEPOINT.md nannte fuenf Modelle, die es nicht mehr gibt. Ersetzt durch
die heute gemessenen Werte inkl. Bandbreiten-Erklaerung fuers Coder-Tempo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:07:03 +02:00
HitonabiandClaude Opus 5 8c371b946b fix(lint): Ungenutzte MEM0_SERVICE_URL-Importe entfernt (Ampel war rot)
Ampel / ampel (push) Successful in 22s
Rueckstand aus dem Mem0-Rueckbau: 2286879 entfernte die Verwendung,
liess die Importe aber stehen. Ruff meldete F401, die Ampel stand
seit dem 19.08. auf Rot (Laeufe #73-#76).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:54:21 +02:00
Hitonabi 1a2051e988 Revert "perf: Warm-Set auf Lucy, Coder, Debugger, Embed und Reranker erweitert"
This reverts commit 5d93822a7e.
2026-08-20 15:52:44 +02:00
Hitonabi 5d93822a7e perf: Warm-Set auf Lucy, Coder, Debugger, Embed und Reranker erweitert 2026-08-19 19:27:54 +02:00
Hitonabi 22868795c9 fix(sentry): Entferne toten mem0 Check aus Telegram-Health-Wächter
Ampel / ampel (push) Failing after 21s
2026-08-19 18:21:28 +02:00
Hitonabi 9a35be91f9 fix(llamaswap): Konsolidiere Modelle & entferne redundante Alt-Aliase
Ampel / ampel (push) Failing after 21s
2026-08-19 17:57:51 +02:00
Hitonabi 241b1d54c6 fix(deploy): Veralteten release-dist restore aus deploy.sh entfernt
Ampel / ampel (push) Failing after 22s
2026-08-19 17:38:57 +02:00
Hitonabi eb4bcfc4b3 fix(mc2): Frontend & Backend umfassend bereinigt, Mem0-Reste entfernt & Hermes-Nativ verdrahtet
Ampel / ampel (push) Failing after 29s
2026-08-19 17:36:34 +02:00
Hitonabi d2a175b0ad fix(smoke): max_tokens in Bild-Weiche v2 fuer Reasoning-Modelle angepasst
Ampel / ampel (push) Successful in 22s
2026-08-19 17:31:03 +02:00
Hitonabi bf145a3f97 feat: Qwen3.8-27B als Haupt-Coder verdrahtet & Mem0 endgueltig abgeloest
Ampel / ampel (push) Successful in 22s
2026-08-19 17:23:54 +02:00
Hitonabi 5b0909089f refactor: Stack-Audit, Doku-Bereinigung und Governor-Cleanup (Stand 19.08.2026)
Ampel / ampel (push) Successful in 23s
2026-08-19 17:05:45 +02:00
Hitonabi 4b6c6ebfd5 feat(routing): Rollen-Aliase 'debugger' und 'doctor' für Muse-Glimmer-30B verdrahtet 2026-08-14 19:01:09 +02:00
Hitonabi 0a6b009cb6 feat(models): Muse-Glimmer-30B mit DFlash-Speculative-Drafting in Katalog und llama-swap integriert 2026-08-14 18:49:26 +02:00
Hitonabi adc73c817f fix(config): Qwen3.6-35B-A3B bleibt warmes Gehirn (90 t/s MoE), Qwen3.8-27B on-demand bereitgestellt 2026-08-14 18:16:20 +02:00
Hitonabi ff795ba026 feat(models): Upgrade auf Qwen 3.8 27B als neues Hermes-Gehirn und Fast-Modell 2026-08-14 18:07:31 +02:00
Hitonabi 46edbe50c1 chore: correct hardware specs to 128GB RAM in AGENTS.md and trend-radar
Ampel / ampel (push) Successful in 22s
2026-08-08 00:49:40 +02:00
Hitonabi cd7e1bc114 fix(trend-radar): enforce 96GB RAM hardware constraint
Ampel / ampel (push) Successful in 24s
2026-08-08 00:46:42 +02:00
Hitonabi 600d0caad6 fix: instruct trend-radar to use notify.sh and allow live-repo edits
Ampel / ampel (push) Successful in 23s
2026-08-08 00:38:13 +02:00
Hitonabi 733582900a fix: auto-sync skills to hermes skills directory during deployment
Ampel / ampel (push) Successful in 23s
2026-08-08 00:28:47 +02:00
Hitonabi 8b14dfdc14 feat: rewrite trend-radar skill for full autonomy and self-modification
Ampel / ampel (push) Successful in 22s
2026-08-08 00:18:06 +02:00
Hitonabi dced44548d fix: correctly pass 'chat' command to hermes CLI when starting skills
Ampel / ampel (push) Successful in 23s
2026-08-08 00:05:24 +02:00
Hitonabi 4c0af92b3c fix: restore Wissen tab but filter out traeume directory
Ampel / ampel (push) Successful in 23s
2026-08-08 00:00:54 +02:00
Hitonabi 5cc1e6bea1 fix: remove frontend/dist from main to fix CI build
Ampel / ampel (push) Successful in 25s
2026-08-07 23:42:13 +02:00
Hitonabi 102149a5d6 build: compile frontend and fix gitignore for dist
Ampel / ampel (push) Successful in 22s
2026-08-07 23:36:43 +02:00
Hitonabi 0c849f4dcd fix: use autonomous zero-intervention lane (-z) for skill runner 2026-08-07 23:33:41 +02:00
Hitonabi 94490f114d fix: correct hermes cli invocation for skills run
Ampel / ampel (push) Successful in 21s
2026-08-07 17:10:48 +02:00
Hitonabi c360bbe14e fix: ruff import sorting in skills.py
Ampel / ampel (push) Successful in 22s
2026-08-07 17:05:49 +02:00
Hitonabi 6cb29ee70e feat: show running state of skills in frontend using psutil
Ampel / ampel (push) Failing after 21s
2026-08-07 17:03:30 +02:00
Hitonabi a6e2f78c27 feat: add missing SKILL.md files for autonomous hermes crons
Ampel / ampel (push) Successful in 22s
2026-08-07 16:50:02 +02:00
Hitonabi a201c20bf2 fix: resolve correct path for skills directory relative to repo root
Ampel / ampel (push) Successful in 22s
2026-08-07 16:42:39 +02:00
Hitonabi 56676fe83c fix: remove outdated Wissen/Traeume functionality completely
Ampel / ampel (push) Successful in 23s
2026-08-07 16:32:34 +02:00
Hitonabi 77bead5ba4 fix: update release-dist tracking branch explicitly during deployment
Ampel / ampel (push) Successful in 24s
2026-08-07 16:28:38 +02:00
Hitonabi ee456de713 fix: remaining ruff formatting issues and robust gitea auth
Ampel / ampel (push) Successful in 23s
2026-08-07 16:25:27 +02:00
Hitonabi e16f0140f7 fix: ampel ci unbound variable GITHUB_SHA
Ampel / ampel (push) Failing after 22s
2026-08-07 16:19:35 +02:00
Hitonabi 637c21387a fix: ruff linter warnings
Ampel / ampel (push) Failing after 23s
2026-08-07 16:14:49 +02:00
Hitonabi da03de18cb feat: Skills Dashboard, CI Build Automation, Auto-Sync & CI-Ampel MCP
Ampel / ampel (push) Failing after 22s
2026-08-07 16:08:49 +02:00
Hitonabi 1b0184eed8 Llama-Swap Config Sync (Devstral hinzugefügt, GLM-4.7 entfernt, persistent=true)
Ampel / ampel (push) Failing after 23s
2026-08-07 15:58:46 +02:00
Hitonabi 4fb8387f79 IDE Line Cleanup & Agentic IDE Umbau
Ampel / ampel (push) Failing after 22s
2026-08-07 14:01:27 +02:00
Hitonabi cb884abdc8 fix: graph view node payload keys (content instead of label)
Ampel / ampel (push) Failing after 22s
2026-08-07 12:20:34 +02:00
Hitonabi 0a6b6fd18d chore: build frontend for wissens-vault graph view
Ampel / ampel (push) Failing after 24s
2026-08-07 12:16:05 +02:00
Hitonabi 8f359ef4c9 feat: obsidian-style graph view for wissens-vault 2026-08-07 12:16:01 +02:00
Hitonabi 327d5a495e feat: add llm_wiki cron, update hermes config docs for memory and browser
Ampel / ampel (push) Failing after 23s
2026-08-07 12:09:55 +02:00
Hitonabi 69925c6cde feat: autonome hermes skills für trend_radar und pruefstand
Ampel / ampel (push) Failing after 21s
2026-08-07 12:01:10 +02:00
Hitonabi a5410642fa feat: ide-lane hardening (ci ampel in auftragsbuch, hermes auto-bugfix, cleanup)
Ampel / ampel (push) Failing after 22s
2026-08-07 11:47:50 +02:00
Hitonabi c3851f8e25 refactor(docs): remove obsolete mem0 and governor references, update AGENTS.md for Hermes
Ampel / ampel (push) Failing after 21s
2026-08-07 11:33:41 +02:00
Hitonabi 29f8797fbf fix: resolve auftragsbuch list_proposals crash and enhance lucy prompt
Ampel / ampel (push) Failing after 21s
2026-08-07 11:23:35 +02:00
Hitonabi 9886d80b1c fix: add URLs to hacker news items and fix prompt for sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:21:10 +02:00
Hitonabi 5bc709221e feat: add news, discover models and auftragsbuch to sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:19:15 +02:00
Hitonabi 14f8a5f405 fix: change sysadmin report LLM endpoint to 9001 and truncate event texts
Ampel / ampel (push) Failing after 22s
2026-08-07 11:15:30 +02:00
Hitonabi 16d2a06d95 fix: make Sysadmin Telegram report much shorter and mobile-friendly
Ampel / ampel (push) Failing after 23s
2026-08-07 11:13:50 +02:00
Hitonabi 45f799c57d feat: implement daily Telegram sysadmin report
Ampel / ampel (push) Failing after 22s
2026-08-07 11:12:06 +02:00
Hitonabi e814122c73 feat: restore TokenPerformanceCard
Ampel / ampel (push) Successful in 21s
2026-08-07 11:11:00 +02:00
Hitonabi d45f2fc6cb fix: remove Memory (Gehirn) completely from frontend widgets
Ampel / ampel (push) Successful in 22s
2026-08-07 11:05:59 +02:00
Hitonabi c2319739e2 fix: remove unused EigenlebenView imports and rebuild frontend
Ampel / ampel (push) Successful in 22s
2026-08-07 11:02:49 +02:00
Hitonabi 1e68f62499 feat: complete MC2 Kahlschlag (remove governor, memory, eigenleben, obsolete deploy scripts and frontend widgets)
Ampel / ampel (push) Successful in 24s
2026-08-07 10:59:39 +02:00
Hitonabi 5504918a61 Merge branch 'main' of https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 2026-08-07 10:57:37 +02:00
Hitonabi 17a129038b chore: update claude ssh permissions 2026-08-07 10:57:37 +02:00
Auftragsbuch 93c17fe653 Revert "Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)"
Ampel / ampel (push) Failing after 22s
This reverts commit ae622dfa88, reversing
changes made to 76d2d7c74b.
2026-08-02 12:13:43 +02:00
Auftragsbuch ae622dfa88 Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)
Ampel / ampel (push) Failing after 22s
2026-08-02 12:13:40 +02:00
Hitonabi 6637332b3e mem0-venv: chromadb>=1.6.0, pip>=26.1.2 + update-skript
Ampel / ampel (push) Failing after 22s
- requirements.txt: chromadb==1.5.9 → chromadb>=1.6.0, pip>=26.1.2 hinzugefügt
- update-mem0-venv.sh: Idempotentes Update-Skript mit Backup und Smoke-Test
2026-08-02 06:45:26 +02:00
Hitonabi 76d2d7c74b deploy: neues Curator-Monitoring-Skript mit 7-Tage-Schwellenwert
Ampel / ampel (push) Failing after 22s
2026-08-02 04:04:21 +02:00
Hitonabi 7638f0244d fix(cron): correct implementation for cron spam prevention via wrapper script and deploy origin
Ampel / ampel (push) Failing after 21s
2026-07-27 17:12:39 +02:00
Hitonabi 4985977492 fix(cron): use --name flag for hermes cron create
Ampel / ampel (push) Failing after 22s
2026-07-27 17:10:43 +02:00
Hitonabi f65ff48599 fix(cron): pipe alle nächtlichen crons durch notify.sh zur verhinderung von telegram spam
Ampel / ampel (push) Failing after 22s
2026-07-27 17:09:26 +02:00
Hitonabi 87f446c5ae feat(pruefstand): Etappe E5 Autonomie für Auto-Adoption und täglichen Rhythmus
Ampel / ampel (push) Failing after 22s
2026-07-26 21:28:50 +02:00
Hitonabi f524ef4375 chore(prompt): Morgen-Digest detailreicher und gehaltvoller gemacht
Ampel / ampel (push) Successful in 22s
2026-07-26 21:16:02 +02:00
Hitonabi 605c9d7dd1 feat: Morgen-Digest für gebündelte nächtliche Telegram-Nachrichten
Ampel / ampel (push) Successful in 22s
2026-07-26 21:10:31 +02:00
Hitonabi 9791f44644 UI: GovernorCard aus dem Cockpit entfernt
Ampel / ampel (push) Successful in 22s
2026-07-26 21:03:47 +02:00
Hitonabi 1e3e0066af Governor: Hard ceiling respects tool chains (OpenCode fix)
Ampel / ampel (push) Successful in 23s
2026-07-26 20:50:45 +02:00
HitonabiandClaude Opus 5 2365f7aac6 Warm-Waechter lief 11 Tage im Leerlauf: Reranker war nie erreichbar
Ampel / ampel (push) Successful in 22s
Beim Nachsehen wegen einer haengenden Gitea-Karte im Steward-Log gefunden:
600 vergebliche warmup.sh-Laeufe in 24 h, alle 90 Sekunden. Aelteste Meldung
dieser Art: 15.07.2026 — also elf Tage, nicht erst seit dem Umbau.

Ursache: Der Reranker steckt in der ko-residenten Gruppe `brains` und gilt dem
Waechter damit als warm-pflichtig. Er antwortet aber NUR auf /v1/rerank — und
genau diesen Endpunkt kannte warmup.sh nicht. Das Modell konnte also nie warm
werden, der Waechter sah es ewig als "fehlend" und rief alle 90 s ein Skript auf,
das daran nichts aendern konnte. Mit Devstral in derselben Gruppe habe ich die
Falle vorgestern verdoppelt.

Zwei Korrekturen:
1. warmup.sh waermt jetzt auch Reranker (MC_WARMUP_RERANK, Default "reranker").
2. warmer.py bekommt MC_WARMSET als Override der Gruppen-Ableitung. Ko-Residenz
   ("duerfen gleichzeitig liegen") und Warm-Pflicht ("muessen immer liegen") sind
   zwei verschiedene Aussagen; die Gruppe kann nur die erste ausdruecken. Seit
   Coder (TTL 90 min) und Kritiker (TTL 30 min) in `brains` liegen, haette der
   Waechter sonst gegen ihre TTLs gearbeitet und nachts 62 GB wieder hochgeladen.

Steward bekommt MC_WARMSET = embed + reranker + hermes (Drop-in auf der Box).
Falle dabei, live erlebt: systemd trennt `Environment=` an Leerzeichen — ohne
Anfuehrungszeichen kam nur das erste Modell an und das Warm-Ziel war still zu
klein. Der erste Fix sah deshalb erfolgreich aus (Schleife weg), war aber nur
eine kaputte Messung. Jetzt gequotet und im Prozess-Environ geprueft.

Belegt: alle drei Ziel-Modelle warm, 0 vergebliche Ausloesungen des neuen
Prozesses, Coder geladen aber korrekt kein Warm-Ziel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:13:26 +02:00
HitonabiandClaude Opus 5 56b374a0aa Ideen: Auswahl war bei leerem Feld unsichtbar
Ampel / ampel (push) Successful in 22s
User: "dein Umbau ist immer noch nicht da. Oder denke ICH falsch?" — er dachte
nicht falsch. Der ganze Auswahl-Block hing an `text.trim().length >= 3`, also
sah man bei leerem Eingabefeld NICHTS davon. Kein Hinweis, dass es ueberhaupt
eine Wahl gibt, keine Moeglichkeit sie vorher einzustellen, und nach jedem
Neuladen war alles wieder weg.

Meine Begruendung war, die Auswahl sei eine Entscheidung ZU dem Text und habe
ohne Text nichts zu suchen. Das ist logisch sauber und praktisch falsch: ein
Mechanismus, den man nicht sieht, existiert fuer den Nutzer nicht.

Jetzt immer sichtbar. Ohne Einordnung steht davor "VOREINGESTELLT" — die Zeile
ist dann eine Einstellung, keine Aussage ueber einen Text, den es noch nicht
gibt. Sobald die Box gelesen hat, verschwindet das Wort und das Etikett
"gelesen" erscheint. Aendern kann man beides jederzeit, auch vor dem Tippen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:50:00 +02:00
HitonabiandClaude Opus 5 eef7fdb4df Ideen: Kopfzeile beschrieb eine Bedienung, die es nicht mehr gibt
Ampel / ampel (push) Successful in 23s
Zwei Fehler in der prominentesten Zeile der Seite, vom User gefunden:

1. "fuer Zed" statt "für Zed" — verschluckter Umlaut aus dem Anlegen der Seite.
2. "Je nach Modus baut sie es selbst, denkt es nur durch, oder legt dir ein
   startklares Repo an" — die Modi sind seit gestern weg, der Entscheidungsbaum
   ist seit heute zugeklappt. Der Satz erklaerte also eine Bedienung, die es
   nicht mehr gibt. Genau die Sorte Text-Leiche, die entsteht, wenn man die
   Mechanik umbaut und die Beschreibung stehen laesst.

Jetzt: "Schreib hin, was entstehen soll. Die Box liest mit und schlaegt vor, wie
es weitergeht — aendern kannst du das immer." Das ist, was tatsaechlich passiert.

Restliche Oberflaeche gegengeprueft: alle anderen "Modus"-Treffer sind
Variablennamen oder unverwandt (Dev-Modus, Experten-Modus), und ausser diesem
"fuer" gibt es keine weiteren ASCII-Ersatzschreibweisen in sichtbarem Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:43:15 +02:00
HitonabiandClaude Opus 5 cd60974ff9 Ideen: Entscheidungsbaum zugeklappt statt Assistent
Ampel / ampel (push) Successful in 24s
Der vorgeschlagene Wizard hatte den richtigen BAUM — Wer baut das?, und nur im
Zed-Ast: wie startest du? — aber den falschen Mechanismus fuer eine taegliche
Zehn-Sekunden-Handlung. Ein Assistent haette gefragt, was der Einordner in
~500 ms schon weiss.

Jetzt: derselbe Baum, zugeklappt.
  Normalfall  Textfeld + EINE Zeile ("Du baust selbst — ich arbeite erst ein
              Konzept aus.") + Enter. Nichts steht zwischen dir und der Idee.
  "anders"    klappt genau die Wizard-Reihenfolge auf: erst Wer baut das?, und
              NUR im Zed-Ast eingerueckt darunter Wie startest du?
              Bei "die Box" folgt keine Rueckfrage — dann geht es direkt los.

Das Etikett heisst "gelesen" und erscheint nur, wenn die Box den Text wirklich
eingeordnet hat. Ohne Einordnung steht dort die Voreinstellung, und die darf sich
nicht als Verstaendnis ausgeben.

Der Entwurf steht und faellt damit, dass der Einordner meistens richtig liegt —
5 von 5 im Test ist ein huebsches, aber winziges Ergebnis. Deshalb zaehlt die
Seite jetzt mit, wie oft "anders" geklickt wird (localStorage, zwei Zahlen, kein
Endpunkt) und zeigt die Quote beim Aufklappen. Bleibt sie unter etwa jedem
fuenften Mal, war der Weg richtig; liegt sie darueber, bauen wir den Assistenten
mit Daten statt mit Meinung.

Nebenbei: das jetzt tote `vorgeschlagen`-Prop aus <Wahl> entfernt (4 Aufrufer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:16:17 +02:00
HitonabiandClaude Opus 5 20d4f40ed9 Ideen: zwei Bereiche, drei Karten — Durchdenken nach vorn
Ampel / ampel (push) Successful in 24s
User-Befund, und er trifft einen echten Modellierungsfehler von gestern: "Eine
durchgedachte Idee wird ja sowieso mit meinem Ja zu einem IDE-Repo." Stimmt —
/ideen/weiterfuehren macht aus der durchdachten Idee GENAU die IDE-Projekt-Karte,
die der Repo-Weg direkt anlegt. Es sind zwei Stufen EINES Weges, keine zwei Achsen.

Der Haken von gestern war deshalb falsch: "Die Box, allein" + "nur denken" war
anklickbar, submit() setzte dann aber still welt:"ide" und schob einen in die
Zed-Welt, ohne das zu sagen. Genau die versteckte Umschaltung, die anderswo in
diesem Repo als Fassade gilt.

Jetzt GENAU EINE Auswahl aus drei, in zwei Bereichen:
  Du baust selbst, in Zed   [Erst durchdenken] [Direkt ein Repo]
  Oder die Box macht es     [Die Box baut allein]
Die falsche Kombination ist damit strukturell unmoeglich, nicht nur unwahrscheinlich.

Nebengewinn: beim Durchdenken entfallen Tiefe und Ziel am Eingang. Die
Weiter-Leiste fragt beides ohnehin beim "gefaellt mir" — und dann weiss man, was
das Ding ueberhaupt wird. Vorher entschied man LXC-oder-Docker, bevor ein Konzept
existierte.

Ausgewaehlt ist immer eine KARTE, nie ein Bereich — der Bereich gruppiert nur, was
denselben Weg geht. Voreinstellung ist "Erst durchdenken": die Wahl, die am
wenigsten anlegt. Der Einordner ueberschreibt sie ohnehin in ~500 ms.

API unveraendert; alle drei Abbildungen gegen den alten Vertrag geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:08:23 +02:00
HitonabiandClaude Opus 5 cb649aac34 Ideen 2.0: schreiben zuerst, die Box waehlt vor
Ampel / ampel (push) Successful in 24s
Die Seite verlangte eine technische Weichenstellung, BEVOR man gesagt hat was man
will — mit Begriffen aus dem Maschinenraum (BOX / IDE: SIMPEL / IDE: GRUENDLICH).
Deshalb klebten 116 Woerter Beipackzettel unter einem einzigen Eingabefeld.

Zwei unabhaengige Entscheidungen, vorher in vier sich ausschliessende Knoepfe gepresst:
  wer       — die Box allein, oder ein Repo fuer Zed
  nurDenken — erst nur ein Konzept (passt zu beidem, jetzt ein Haken)
Die Knoepfe nennen das ERGEBNIS statt des Werkzeugs, und der erklaerende Satz steht
an der Option statt in einer Legende darunter.

Neu: POST /api/ideen/einordnen — das dauerwarme Hirn liest den getippten Text und
waehlt vor (gemessen ~500 ms, 5 von 5 Testfaellen richtig, inkl. "Lohnt sich...?"
-> nur denken). Bewusst ein VORSCHLAG mit sichtbarem Etikett: er setzt nur die
Auswahl, die ohnehin dasteht. Faellt er aus, bleibt schlicht die Voreinstellung —
das Tippen wird nie blockiert, es gibt keinen Ladebalken und keine Fehlermeldung.

Tiefe und Ziel erscheinen nur noch beim Repo-Weg, weil sie nur dort wirken. Ein
Schalter der nichts tut ist schlimmer als keiner.

Leerer Zustand zeigt drei anklickbare Beispiele statt "wirf der Box eine Idee zu".
Kopfzeile nennt nur noch den Zustand — "Ideen" stand dreimal uebereinander.

Die API bleibt unveraendert; die Abbildung passiert in submit().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:58:51 +02:00
HitonabiandClaude Opus 5 d18aac0e1b Persoenliche Arbeitsregeln erreichen endlich den Coding-Agenten
Ampel / ampel (push) Successful in 25s
Befund beim Nachsehen wegen Zeds "Skills Support"-Ankuendigung: die 46 Zeilen
Arbeitsregeln in AppData\Roaming\Zed\AGENTS.md haben OpenCode NIE erreicht.
Zeds Doku ist eindeutig — AGENTS.md und Skills gelten nur fuer Zeds NATIVEN
Agenten, nicht fuer ACP-Agenten wie OpenCode. Hier wird ueber ACP gearbeitet.
Gute Regeln (Plan vor Code, kleine Schritte, beweisen statt behaupten,
Faulheits-Leiter, Kontext-Waechter) liefen also ins Leere.

Sie liegen jetzt in ~/.config/opencode/AGENTS.md — auf dem PC UND auf der Box,
also am Tag in Zed und nachts im Cron. Vorlage im Repo unter deploy/opencode/.

Beim Umzug korrigiert:
- Zeds eingebaute Commit-Vorlage entfernt. Sie war in dieselbe Datei gerutscht
  und sagt "Only return the commit message in your response" — in dauerhaft
  aktiven Regeln ein Fehlerherd.
- "Zum Bauen oben auf Coder umschalten" gestrichen: seit der Mannschafts-
  Umstellung laufen plan und build auf demselben Modell. Das war MEINE
  Regression aus Stufe 4, hier faellig geworden.
- Kontext-Waechter auf den Governor umgeschrieben (der kennt den Fuellstand
  wirklich, Zeds Anzeige war Heuristik).
- Neu: Pruef-Tor (VERIFY, inkl. "Test abschwaechen gilt als Betrug"),
  die Subagenten-Mannschaft, der Werkzeug-Zaun und ein Abschnitt fuer
  UNBEAUFSICHTIGTE Laeufe — nachts sagt niemand "los", dort darf der Agent
  nicht auf Freigabe warten.

Bewiesen: in einem leeren Ordner (nur globale Regeln wirksam) antwortet der
Agent wortgenau aus der neuen Datei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:38:08 +02:00
HitonabiandClaude Opus 5 6c14705798 Ideen: fertige Karten sammelweise wegraeumen
Ampel / ampel (push) Successful in 22s
Einzeln ging es schon (X je Karte). Bei fuenf abgeschlossenen Projekt-Ketten und
18 fertigen Karten waren das aber ~18 Klicks — praktisch raeumt dann niemand auf,
und die drei relevanten Karten gehen in der Liste unter.

Neu:
  POST /api/ideen/archivieren-alle {projekt?}  -> services.archive_done()
  Kopfzeile:      "18 fertige wegraeumen" (alle, auch die in Ketten)
  Je Projekt:     "wegraeumen" neben "N fertig"

Sicherheitseigenschaft, bewusst im Dienst statt in der UI: archive_done fasst
AUSSCHLIESSLICH status=done an — laufende, haengende, wartende und in Ausarbeitung
befindliche Karten nie, auch nicht ohne Projekt-Filter. Ein Sammel-Knopf darf
niemals versehentlich Arbeit abraeumen. Der Dialog sagt ausserdem ehrlich, dass
nichts geloescht wird: es ist Hermes' Kanban-Archiv, die Karten bleiben lesbar.

Getestet ohne Nebenwirkung: Filter auf ein nicht existierendes Projekt liefert
archiviert=0, die 19 Karten der Queue bleiben unangetastet. Das echte Wegraeumen
ist ein User-Klick, kein Deploy-Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:54:35 +02:00
HitonabiandClaude Opus 5 991c9a2f82 Ideen-Queue bekommt eine eigene Seite
Ampel / ampel (push) Successful in 22s
Die Queue war 703 der 1147 Zeilen in AuftragsbuchView — die MEHRHEIT einer Seite,
die nach etwas anderem benannt ist. Und sie laufen gegenlaeufig: Auftragsbuch =
die Box schlaegt DIR vor (Eingang), Ideen = DU beauftragst die Box (Ausgang).
Seit dem Umbau ist Idee -> Konzept -> Repo -> Zed der Haupteingang des Systems
und gehoert in die Seitenleiste, nicht in einen Unterabschnitt.

Umzug (reines Verschieben, kein Logik-Umbau):
  AuftragsbuchView  1147 -> 406 Zeilen   Bundle 51,0 -> 17,8 KB
  IdeenView         neu, 785 Zeilen      Bundle 32,7 KB, laedt nur bei Bedarf
  fmtWhen           -> lib/format.ts (dessen Kopf genau davor warnt: "vorher in
                      einzelnen Views dupliziert")
  SectionLabel      -> components/ui/ (beide Seiten brauchen sie jetzt)

Zwei inhaltliche Aenderungen:
1. Die flache Einzelkarten-Liste wird gruppiert — mit der Ordnung, die
   ProjektGruppe fuer Ketten LAENGST benutzt (AKTIV_RANG): aktiv zuerst,
   Wartende und Fertige eingeklappt. Bei 19 Karten, davon 16 fertig, gingen die
   drei relevanten in der Erstellzeit-Liste unter.
2. Die Kopfzeile sagt jetzt "1 braucht dich · 2 in Arbeit · 16 fertig" statt
   einer nackten Gesamtzahl.

Das Auftragsbuch zaehlte im gruenen "Nichts zu entscheiden" bisher haengende
Ideen mit, damit es nicht luegt, waehrend direkt darunter eine Karte haengt.
Nach der Trennung wird daraus ein anklickbarer Querverweis auf die Ideen-Seite —
dieselbe Ehrlichkeit, jetzt mit Weg dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:41:10 +02:00
HitonabiandClaude Opus 5 ad49dd65ca Update-Kette repariert: Boot-Warmliste zurueckgenommen + Tool-Smoke mit Retry
Ampel / ampel (push) Successful in 22s
MEINE REGRESSION. Das systemd-Drop-in vom Umbau setzte
MC_WARMUP_MODELS="fast coder kritiker". warmup.sh waermt aber erst ALLE Chat-
Modelle, dann Embeddings und ERST DANN Hermes' ~70-KB-Agenten-Prompt — genau das,
was der Tool-Smoke braucht. Coder (48 GB) + Kritiker davorzuschieben kostete ~35 s
und trieb den Tool-Smoke ueber sein 90-s-Limit: Job faf1137a262c FAILED mit
"Tool-Smoke ohne verwertbares Ergebnis" (leere Antwort = curl-Timeout), obwohl
Agent und Modelle in Ordnung waren. Warm gemessen dauert derselbe Aufruf 12 s.

Zwei Korrekturen:
1. Boot-Warmliste zurueck auf "fast" (Drop-in auf der Box). Lucys Hirn und der
   Agenten-Prompt haben Vorrang; der Coder waermt beim ersten Auftrag selbst
   (23 s einmalig, dann 90 min warm).
2. Tool-Smoke bekommt 3 Versuche + 120 s statt 1 Versuch + 90 s — dieselbe Lehre,
   die der Voice-Smoke zehn Zeilen tiefer laengst umgesetzt hatte ("erster Turn
   zahlt den Session-Kaltstart"). Ein Versuch war ein Fehlalarm-Generator.

Bewiesen: llama-swap kalt neu gestartet, Postcheck sofort danach -> GEHIRN OK,
Tool-Smoke im ersten Versuch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:19:30 +02:00
HitonabiandClaude Opus 5 c28aa4a6a2 README aktualisiert + Kritiker auf 16k gedeckelt (Prefill-Befund)
Ampel / ampel (push) Successful in 22s
README war auf dem Stand "Final-Version eingefroren" und kannte weder Governor,
Coding-Bahn, CI-Ampel noch den Weg einer Idee. Neu geschrieben mit den echten
Ports/Diensten, den gemessenen Modell-Rollen und den vier Qualitaets-Toren.
Alle Verweise gegen den Baum geprueft.

Dabei aufgefallen: das eigene VERDIKT vom 24.07. ("Devstral bricht beim 32K-Prefill
auf 63 t/s ein") widerlegt meine Begruendung "ein Kritiker liest viel und schreibt
wenig, also stoert die Langsamkeit nicht" — es ist genau das LESEN, das einbricht.
Nachgemessen: Prefill 265-318 t/s (Coder 754). Der Kritiker ist deshalb in
llama-swap UND in beiden opencode.json auf 16384 gedeckelt; schlimmster Fall je
Review jetzt unter einer Minute statt 8+ Minuten.

VERDIKTE um zwei Eintraege ergaenzt: die enge Kritiker-Rolle mit Deckel, und die
llama-swap-Gruppenregel (eine Gruppe resident, persistent:false, OOM-Grenze).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:04:44 +02:00
HitonabiandClaude Opus 5 8b76c0f749 Ampel-Rot auf main behoben: ungenutzter Import in curator_archive_logic.py
Ampel / ampel (push) Successful in 22s
Der Merge hat die Ampel zum ERSTEN MAL auf `main` laufen lassen — sie kam am
24.07. auf dem Branch dazu und war nie auf main. Sie hat sofort einen echten
Altfehler aus der autonomen Auftragsbuch-Arbeit gefunden: F401, `os` importiert
aber nirgends benutzt (deploy/curator_archive_logic.py:11).

Nicht von diesem Umbau verursacht — aber jetzt sichtbar, und deshalb behoben.
Alle drei Ampel-Gates lokal gruen: ruff · compileall · Frontend-Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:50 +02:00
Hermes Worker a934deee41 Doku: Qwen-AgentWorld-35B als abgelehnt für Vision-Rolle vermerken (t_9daf088f)
Ampel / ampel (push) Failing after 22s
2026-07-25 21:46:07 +02:00
Hitonabi 543eee95d0 Merge governor-phase0: Umbau Stufe 1-5 (Ampel #3 gruen) 2026-07-25 21:45:56 +02:00
HitonabiandClaude Opus 5 59d6be8c4a OpenCode-Vorlagen vollstaendig: PC-Seite + README
Ampel / ampel (push) Successful in 22s
Die Box-Seite lag im Repo, die PC-Seite nur auf dem PC — bei Verlust waere die
Haelfte des Setups unrekonstruierbar gewesen. Jetzt liegen beide Vorlagen
nebeneinander, plus ein README mit der Mannschaftsaufstellung (inkl. der auf der
Box gemessenen Tempi) und den vier Fallen, die beim Bauen Zeit gekostet haben:
kein _comment in opencode.json, Plugin muss Windows+Linux koennen, session.idle
ueberlebt `opencode run` nicht, plugin/ vs plugins/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:40:49 +02:00
HitonabiandClaude Opus 5 7fac17ed9a Umbau Stufe 1-5: Governor v2 + OpenCode-Plugin + Subagenten-Mannschaft
Die Intelligenz sitzt nicht mehr NEBEN dem Coden, sondern DRIN. Alles auf der
Box gemessen, nicht angenommen.

Stufe 1 — Speicher-Haushalt (llama-swap-Config, nicht im Repo):
  Coder dauerwarm statt ttl 600 (Kaltstart 23 s -> 491 ms), Devstral als
  'kritiker' verdrahtet, Swap 11 GB -> 0. Zwei Befunde: Kontext 131k->65k spart
  bei Qwen3-Next 0 GB (Hybrid-Attention), und llama-swap haelt nur EINE Gruppe
  resident -> alles Ko-Residente in dieselbe Gruppe. `persistent: true` verhindert
  dabei das Freiraeumen vor grossen Modellen -> ausgeloester Kernel-OOM, behoben
  durch persistent:false + TTLs (Vision laedt jetzt in 10 s statt 117 s + Absturz).

Stufe 2 — Governor v2 (deploy/governor/):
  Steht jetzt IM Pfad (:8100 -> MC2 :9001) statt daneben. Zaehlt den GANZEN
  Anfragekoerper inkl. tools/tool_calls und kalibriert sich aus den echten
  usage.prompt_tokens jeder Antwort nach: Schaetzfehler 200 % -> 0,3 %.
  Soft-Einschub nur noch, wenn die Nachrichtenkette es erlaubt (kein Dazwischen-
  funken in offene tool_calls). Neuer Status-Endpunkt + systemd-Unit.
  Lucys Alltagsmodelle sind vom Schnitt ausgenommen.

Stufe 3 — OpenCode-Plugin (deploy/opencode/plugin/mc2-governor.ts):
  Werkzeug-Zaun (git push, rm -rf, sudo, curl|sh — bewiesen), Pruef-Tor auf
  session.idle mit Selbstreparatur, Savepoint statt Kompression, Meldungen an
  Lucys Briefkasten mit eigenem Absender 'loop'.

Stufe 4 — Mannschaft (opencode.json):
  plan+build -> coder (51,5 t/s) · explore -> hermes (69,6 t/s, warm, gratis) ·
  review -> Devstral (15,0 t/s, FREMDE Modellfamilie gegen blinde Flecken).
  Der 63-GB-Planer faellt aus der Tagesrolle raus.

Stufe 5 — ein Regelwerk, zwei Ausloeser:
  deploy/opencode-lauf.sh faehrt dieselbe Bau-Pruef-Schleife unbeaufsichtigt
  (die Schleife liegt hier UND im Plugin: bei `opencode run` endet der Prozess,
  bevor session.idle fertig ist — gemessen). Bricht ab, wenn der Governor fehlt.
  gitea-repo-create.sh saet jetzt VERIFY neben der CI-Ampel: jedes neue Repo
  wird mit Innen- UND Aussen-Pruefung geboren.

Oberflaeche:
  Token-Waechter-Kachel im Cockpit (Fuellstand, Marken, Ehrlichkeits-Nachweis),
  /api/governor als gleichursprüngliches Fenster, Devstral im Modellkatalog,
  Rollen-Texte auf die neue Mannschaft aktualisiert.

.gitattributes: deploy/**/*.py auf LF (deploy/*.py greift nur eine Ebene tief).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:39:32 +02:00
Auftragsbuch 00f2b71a63 Auftragsbuch: 'doku/curator-autos' angenommen (Ein-Klick-Gate) 2026-07-25 04:10:55 +02:00
Hitonabi 2cecd06c5b doku: Curator Auto-Archivierung Cron dokumentieren 2026-07-25 03:53:02 +02:00
HitonabiandClaude Opus 4.8 775e862652 CI: MC2-Ampel auf sinnvolle Gates zugeschnitten (Lint+Import+Frontend-Build)
Ampel / ampel (push) Successful in 22s
Die universelle Vorlage will pip-install ALLER 5 requirements (inkl. schwerer ML-Deps:
Whisper/TTS) + pytest repo-weit in einem stateless Container — fuer dieses Multi-Service-
ML-Monorepo weder machbar noch aussagekraeftig (echte Tests = Pruefstand/Integration auf
der Box mit Live-Diensten). MC2-Ampel prueft stattdessen, was im Container ehrlich gruen
sein kann und echte Fehler faengt: ruff (Projekt-Politik) + compileall + Frontend-Build
inkl. tsc-Typecheck. Bewusste Abweichung von 'pytest = Pflicht' fuer dieses Repo, begruendet
im Workflow-Kopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:56:16 +02:00
HitonabiandClaude Opus 4.8 47f7a85510 Ruff-Cleanup: ganzes MC2-Repo lint-grün + projekt-passende ruff.toml
Die Ampel-Nachruestung deckte 317/337 vorbestehende ruff-Verstoesse im ganzen Repo
auf. Aufgeraeumt:
- ruff.toml: intentionale Muster als Projekt-Politik ausgenommen (BLE001 blind-except,
  S110/S112 try-except-pass/continue, PLW1510 subprocess-best-effort, B008 FastAPI-
  Depends/File-Idiom, EXE001 Shebang, + wenige Stil-Regeln). __init__.py-Re-Exports
  geschuetzt (F401).
- ruff --fix: 128 mechanische (Import-Sortierung, PEP585/604-Annotationen, tote Imports,
  ueberfluessige noqa) auto-behoben.
- 12 echte Reste von Hand: PERF402/102, PLC3002 (Lambda->walrus), ISC004 (String-Concat
  geklammert), F841/RUF059 (ungenutzte Vars), PIE810 (startswith-Tuple), UP031 (f-string),
  UP035 (veraltete typing-Imports).
Ergebnis: 'ruff check .' = 0, 'compileall' grün. Kein Verhaltenswechsel (nur Stil/Modernisierung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:53:36 +02:00
HitonabiandClaude Opus 4.8 e6502f676a CI: Ampel fuer MC2 verdrahtet (.gitea/workflows/ampel-ci.yml)
Ampel / ampel (push) Failing after 1m52s
Die universelle Ampel (aus deploy/ampel-ci.yml) war bisher nur Vorlage — MC2 hatte
keine aktive .gitea/workflows/-Datei, Pushes triggerten keinen CI-Lauf. Jetzt
nachgeruestet (offene Karte t_730a747a). Laeuft auf push+pull_request, erkennt
Projekt-Typ selbst (Python/Node) und prueft Ruff+compileall+pytest bzw. npm ci+build+test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:33:30 +02:00
HitonabiandClaude Opus 4.8 554c87aaee Governor: driver.py lint-sauber (Ruff gruen)
Imports nach oben (sortiert), Endpoint-Setzen in main() statt Modulebene ->
kein E402/unused-noqa mehr. governor.py war bereits sauber. 'ruff check' = 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:30:10 +02:00
HitonabiandClaude Opus 4.8 798de0be8f Governor Phase 0 fertig + Phase 2: Hart-Deckel default + Sprach-Signal an Lucy
Phase 0 abgeschlossen:
- Hart-Deckel jetzt Default AN (Soft+5000), da der weiche Schnitt allein bei
  Weiterarbeit ueber die Grenze eine Fassade erzeugt (P0-Befund). GOV_HARD_CEILING=0
  schaltet ihn aus. gov-ctl reicht Arg 2 nur bei Bedarf durch.
- README: Empfehlung/Doku auf Default-an aktualisiert, Commit-Msg-Restpunkt notiert
  (--no-auto-commits als saubere Option).

Phase 2 (Voice-Hook): beim Feuern (soft/hard) POSTet der Governor best-effort +
gedrosselt (Default 300 s) eine Meldung an Lucys vorhandene Announce-Pipeline
(POST :9001/api/voice/announce, source=governor, priority=normal). Lucy pollt,
dedupliziert und spricht sie via lokales TTS -- KEIN Lucy-Code noetig. E2E bewiesen:
Feuern -> ANNOUNCE status=200 -> Eintrag id 394 source=governor in der Queue.
GOV_ANNOUNCE_URL="" schaltet es ab. announce-test.sh beigelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:06:10 +02:00
HitonabiandClaude Opus 4.8 c13cfd2bd0 Governor Phase 0: Token-Waechter-Proxy + Aider Session-Hygiene (bewiesen)
Duenner, zustandsloser Proxy (nur stdlib) zwischen Aider und llama-swap :8080.
Schiebt an einer Token-Schwelle "SAVEPOINT.md finalisieren + stoppen" ein (weich)
bzw. antwortet oberhalb eines optionalen Hart-Deckels selbst. Beweist Session-
Hygiene per hartem Schnitt statt Auto-Compaction -- ohne eine Zeile Lucy-Code.

Alle 4 Akzeptanzkriterien gruen: feuert im Log; SAVEPOINT.md gepflegt; ehrlicher
Grenz-Savepoint am ersten Feuern; frische Sitzung liest Savepoint -> baut Tests,
9 unittest gruen, keine Fassade. CPT 3.5 kalibriert (est ~= echte prompt_tokens).
Hart-Deckel nachgeruestet (weicher Schnitt allein erzeugt Fassade bei Weiterarbeit
ueber die Grenze). Streaming-read1-Fix + 4 kleinere aus adversarialer Review.

Laeuft deployt auf der Box unter ~/governor-p0/ (gov-ctl.sh start <soft> <hart>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:17:36 +02:00
Auftragsbuch 080eaa5ff5 Auftragsbuch: 'doku/pruefstand-coder-backend-24-07' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:13 +02:00
Auftragsbuch 7dd44a3630 Auftragsbuch: 'feature/curator-auto-archiv' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:12 +02:00
Hitonabi 554db10a6f VERDIKTE: Pruefstand 24.07 — Vulkan bleibt (ROCm-Recheck erledigt), Coder-Next bleibt (Devstral 4-8x langsamer), Reliabilitaet=Harness 2026-07-24 14:44:22 +02:00
Hitonabi ce030d6817 feat: Curator Auto-Archivierung (14-Tage-Inaktivität) 2026-07-24 13:46:49 +02:00
Auftragsbuch 8996c0d012 Auftragsbuch: 'wartung/curator-idle-watchdog' angenommen (Ein-Klick-Gate) 2026-07-24 09:58:38 +02:00
Hitonabi a1de1ec2ac feat: Curator Idle Watchdog implementieren
- Skript deploy/curator-idle-watchdog.sh für 14-Tage-Idle-Erkennung
- Spezifikation deploy/specs/curator-idle-watchdog.md
- Wöchentliche Prüfung (Sonntag 00:00), Trigger bei Überschreitung
2026-07-24 08:53:19 +02:00
HitonabiandClaude Fable 5 8e735df32d Gitea-Weg der Worker: intern statt DDNS + gitea-pr-Helfer + Waechter-Scope-Fix
- werkstatt-SOUL Regel 3: Klonen IMMER ueber http://192.168.178.153:3000
  (DDNS-Domain nachts wegen Zwangstrennung tot -> Worker hielt Gitea am
  24.07. faelschlich fuer kaputt und blockte). Nie SSH-Remotes, nie nach
  Passwoertern fragen.
- deploy/gitea-pr (neu): PR per Gitea-REST-API mit Token aus
  ~/.git-credentials (Vorfall 23.07.: Worker bat um Web-Passwort).
- ampel-waechter: /repos/search statt /user/repos - Token hat nur
  write:repository, /user/repos gab 403 und der stille exit 0 versteckte
  das seit Inbetriebnahme (Merkliste blieb leer, kein Alarm ging je raus).
  API jetzt intern, Telegram-Links bleiben auf der Domain.
- tabu-pfade-guard: Klon-Empfehlung in der Blockmeldung auf interne URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:40:40 +02:00
Auftragsbuch 057cc2bb47 Auftragsbuch: 'wartung/traum-duplikatscheck-v2' angenommen (Ein-Klick-Gate) 2026-07-23 08:28:59 +02:00
Hitonabi e686f95545 Traum: Duplikats-Check für Skill-Kandidaten 2026-07-23 08:03:37 +02:00
HitonabiandClaude Opus 4.8 8a90b7507e Ampel-Waechter: Status-Luegen-Wache (Job-Log = Wahrheit)
Alter Runner (0.2.13) meldete Abschluesse nicht -> Gitea zombie-markierte
gruene Jobs als failure. Waechter prueft jetzt vor Alarm das Job-Log auf
"Job succeeded". Runner auf 0.6.1 gehoben (eigentlicher Fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:27:05 +02:00
HitonabiandClaude Fable 5 59b09e4a9a Watchlist: LM Studio Bionic aufgenommen (Linux-Port-Issue als Anker)
Preview seit 16.07., Win+Mac, gratis, closed source. Keine Custom-
Endpoints (Box :9001 nicht anbindbar), keine CLI/API. Radar meldet,
wenn Issue #2185 (Linux-Port) zugeht; Changelog gegenlesen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:03:45 +02:00
HitonabiandClaude Opus 4.8 5894c24703 Ampel-Waechter: Telegram bei ROTEN CI-Laeufen (no-agent-Cron, 30 min)
Meldet nur NEUE Fehlschlaege (Merkliste), running zaehlt nicht als
gesehen. Teil 3 der Wasserdicht-Runde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:33:17 +02:00
HitonabiandClaude Opus 4.8 3f279fc7f2 Ampel-Vorlage v3: set +e (Runner-bash -e schnitt das Urteil ab)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:19:16 +02:00
HitonabiandClaude Opus 4.8 af28a75338 Ampel-Vorlage: klare Lockfile-Pruefung statt kryptischem npm-ci-Fehler
Erster rippy-Lauf bewies: fehlendes package-lock.json = EUSAGE-Wand.
Jetzt: verstaendliche rote Meldung mit Fix-Anleitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:11:31 +02:00
HitonabiandClaude Opus 4.8 a121ca7dc0 Wasserdicht-Runde: universelle CI-Ampel fuer ALLE Lucy/MC2-Repos
- deploy/ampel-ci.yml: sprachneutrale Actions-Ampel (erkennt Python/Node
  selbst; Doku-Repo=gruen, Code ohne Tests=ROT)
- gitea-repo-create.sh: pflanzt die Ampel bei JEDER Repo-Anlage ein
  (Contents-API, Push-Token, defensiv)
- projektstart-SOUL: drei harte AGENTS-Regeln (Ampel-Pflicht,
  Spec-Abweichung=STOPP, Deploy nur via deploy.sh) — rippy-Lehren

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:59:50 +02:00
HitonabiandClaude Opus 4.8 cce32810fc Watchlist: Electron-Notiz korrigiert (Fehlalarm-Wurzel)
Der '33'-Stand in der warum-Prosa war veraltet (real: 43.2.0 stable)
und liess den Rundumschlag ein 11-Major-Loch alarmieren. Notiz sagt
jetzt: Versionsstand immer live pruefen, nie der Notiz glauben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:35:42 +02:00
HitonabiandClaude Opus 4.8 682be9b63b VERDIKTE: Hirn auf DFlash (live 22.07.), DFlash-je-Rolle-Messung
hermes: DFlash schlaegt MTP (86-90 vs 81-83 t/s, live 89-94).
coder: DFlash verliert trotz guter Akzeptanz. heavy: wartet auf
Engine-Support (Tensor-Mismatch b10087). vision/scout: keine Drafts.
Inkl. Eigenbau-Rezept + Quantize-Falle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:24:37 +02:00
HitonabiandClaude Opus 4.8 96c4e417ec Rundumschlag-Prompt: 100%-lokal-Praemisse hart verankern
Probelauf empfahl GLM-5.2 per Cloud-API — widerspricht dem
Abloesungs-Zielbild. Regel 6: keine API-Empfehlungen, >122 GB
Unified = kein Kandidat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:56 +02:00
HitonabiandClaude Opus 4.8 90ab873924 Stack-Rundumschlag: monatlicher freier Rundumblick (Cron am 4., 06:40)
Ergaenzt die mechanischen Waechter um tagesaktuelle Web-Recherche —
Lehrbeispiel Gemma-4-Stealth-Update 15./16.07. ohne Versionssprung,
das kein Radar fing. Feed sammelt IST-Stand (Rollen, Engine, Puls,
Watch, Pruefstand, Ressourcen, Fehlerbild), Agent recherchiert und
empfiehlt max. 3 Schritte; umgesetzt wird per Karten-Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:35:26 +02:00
HitonabiandClaude Opus 4.8 6d3a0bf3a6 IDE-Skill 'projekt-uebernehmen': vorbereitetes Projekt in Zed starten
Der Weg von der Box in die IDE endete bisher am Repo: der Commander klont es,
oeffnet Zed/OpenCode — und der Agent weiss nicht, dass hier bereits ein
gehaertetes Konzept liegt. Ohne Anleitung plant er entweder neu (projekt-start
springt an) oder fragt ins Blaue.

- client/ide-skills/projekt-uebernehmen/SKILL.md (Quelle im Repo, installiert
  nach ~/.agents/skills/ — dort liegen die uebrigen PC-Skills, OpenCode 1.18
  laedt von dort). Ablauf: einlesen (Konzept/Roadmap/Savepoint + git-Stand) ->
  3-6 blockierende Fragen MIT Empfehlung -> Etappe-1-Plan -> "los" -> bauen,
  Savepoints, live testen. Zwei Abbruch-Faelle: schon Code da (dann Fortsetzung
  nach SAVEPOINT) und nur KONZEPT.md ohne ROADMAP (das ist ein geprueftes
  Ideen-Repo -> Hinweis auf "Gefaellt mir - Projekt daraus machen").
  Modus-Wechsel ist Teil des Skills: lesen/fragen im Planer, bauen im Coder.
- deploy/projektstart-SOUL.md: die von der Box erzeugte AGENTS.md nennt den
  Skill jetzt woertlich. AGENTS.md wird automatisch als Kontext geladen — das
  ist der zuverlaessige Kanal; auf spontane Skill-Wahl ist bei den lokalen
  Modellen kein Verlass (nachgemessen: heavy und coder greifen bei einem vagen
  "ich moechte hier loslegen" NICHT von selbst zum Skill).
- ~/.agents/skills/projekt-start/SKILL.md (ausserhalb des Repos) bekam eine
  Abgrenzungszeile: nicht nutzen, wenn KONZEPT.md + ROADMAP.md schon liegen.
  Sonst plant er ein vorbereitetes Projekt neu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:41:59 +02:00
HitonabiandClaude Opus 4.8 c3fe6dbe8d No-Progress-Bremse: Schauen ist keine Schleife + Wiederaufnahme in alle SOULs
‼️ Die Bremse hat den Schaden angerichtet, den sie verhindern soll. Sie signiert
den ERGEBNISTEXT von terminal-Aufrufen; die Bestandsaufnahme in Schritt 0 besteht
aber aus vielen kurzen Schau-Befehlen (ls, cat, git log), deren Ausgaben sich stark
aehneln. Nach dreien blockte sie den naechsten Blick — ausgerechnet `ls /tmp/konzept-*`.
Der Worker las die Block-Meldung als "Verzeichnis existiert nicht", schloss daraus,
das fertige Konzept sei nicht verifizierbar, und warf eine halbe Stunde Arbeit weg
(Karte t_d26c3203, Log: "kein /tmp/konzept-* Verzeichnis gefunden (wegen
No-Progress-Bremse)"). Die Bremse verlangt in ihrem eigenen Text "Diagnose statt
Variation" — und verhinderte genau die.

- _nur_lesend(): rein lesende Befehle (ls/cat/head/find/grep/git status|log|diff|
  ls-remote …, keine Umleitung) werden weder gezaehlt noch geblockt. Strukturell
  nach Befehls-ART, nicht per Fehlertext-Liste — die Bremse bleibt generisch.
- Block-Meldung sagt jetzt ausdruecklich: "Dein Befehl wurde NICHT ausgefuehrt, das
  ist eine Bremse, KEIN Ergebnis — schliesse daraus nichts ueber Existenz oder
  Zustand." Ohne diesen Satz liest ein Agent den Block als Befund.
- Schritt 0 (Wiederaufnahme) + FORTSCHRITT.md jetzt auch in werkstatt-SOUL
  (Klon/Branch pruefen: liegt der Branch schon auf Gitea -> verifizieren und
  abschliessen statt neu bauen) und betrieb-SOUL (vorhandene Messwerte NICHT neu
  messen — ein wiederholter Bench laedt 70-GB-Modelle und gefaehrdet Lucys Warm-Set).
- projektstart: FORTSCHRITT.md gehoert ins Workspace-Wurzelverzeichnis, nicht in den
  Repo-Klon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:37:17 +02:00
HitonabiandClaude Opus 4.8 584f257853 SOUL: Wiederaufnahme statt Neuanfang nach Kontext-Tod
Live-Fall 21.07. (Karte t_d26c3203 "Rippy"): Lauf 1 war praktisch fertig —
Fliessband komplett (Recherche, 3 Rollen x 2 Runden, Haertetest, konzept.md
14,7 KB), Repo Hitonabi/rippy angelegt und gepusht, KONZEPT.md im Klon
geschrieben. Dann Kontext voll, Exit ohne kanban_complete -> "protocol
violation". Es fehlten git add/commit/push und die Uebergabe: zwei Minuten.

Lauf 2 fing komplett von vorne an — neuer Rollen-Cast, neue Kaskade, neuer
/tmp/konzept-*-Ordner —, obwohl Hermes den Abschnitt "Prior attempts on this
task" in den Auftrag schreibt UND der Workspace derselbe ist. Die SOUL hatte
dafuer keinen Platz: sie begann mit "Der Ablauf (genau diese vier Schritte)".

- Neuer Schritt 0 WIEDERAUFNAHME: bei "Prior attempts" erst inventarisieren
  (FORTSCHRITT.md, Workspace-Repo inkl. `git status` — uncommittete Dateien
  sind fertige Arbeit —, /tmp/konzept-*, `git ls-remote`), dann an der ERSTEN
  LUECKE ansetzen. Ein zweiter kompletter Denk-Durchlauf gilt als Fehlschlag.
- Neuer Abschnitt "Fortschritt hinterlassen": eine Zeile je Stufe in
  FORTSCHRITT.md im Workspace, SOFORT geschrieben, dazu als Heartbeat-Notiz.
  Das unterscheidet einen Kontext-Tod von einem Totalverlust.
- Schritt 2: den vom Vorgaenger vergebenen Repo-Namen weiterbenutzen, sonst
  entsteht fuer dieselbe Idee ein zweites Repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:27:41 +02:00
HitonabiandClaude Opus 4.8 73513f5e5b SOUL: Push-Beleg bleibt, falsche Anekdote raus
Die Begruendung zur ls-remote-Regel war eine Fehlannahme von mir: das Repo
`Hitonabi/arm-ui` fehlte nicht wegen eines verpufften Pushes, sondern weil der
Commander es selbst geloescht hat (Konzept ging in die falsche Richtung). Der
Push war echt.

Die Regel selbst bleibt — der Worker hatte seine eigene Pruefung tatsaechlich
uebersprungen und dabei gegen die No-Progress-Bremse argumentiert. Statt der
erfundenen Folge steht jetzt der eigentliche Grund da: nicht wegargumentieren,
der Commander soll "Repo liegt bereit" ohne Nachpruefen glauben koennen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:21:37 +02:00
HitonabiandClaude Opus 4.8 18f5522612 Konzept-Raterei enger: keine fremden Konzepte an laufende Karten haengen
Der Datei-Fallback darf den Dateinamen ueber den Kartentitel raten. Live gesehen:
eine gerade LAUFENDE neue ARM-Karte zeigte das alte arm-ui-Konzept, als waere es
ihr Ergebnis — ein einzelnes gemeinsames Wort ("arm") reichte.

- Titel-Raterei nur noch bei FERTIGEN Karten; laeuft eine noch, zaehlt allein ein
  im Text genannter Pfad.
- Und sie braucht jetzt zwei gemeinsame Woerter oder ein langes (>=6 Zeichen),
  damit nicht irgendeine Datei auf irgendeine Karte passt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:08:46 +02:00
HitonabiandClaude Opus 4.8 0cea01c699 Konzept nachschaerfen: Rueckmeldung statt nur annehmen-oder-wegwerfen
Nach dem Lesen eines Konzepts gab es bisher nur zwei Wege: "Gefaellt mir" oder
archivieren. Der haeufige Fall fehlte komplett — EIN Punkt passt nicht und der
Commander hat einen besseren Vorschlag.

- POST /api/ideen/nachschaerfen -> konzept_ueberarbeiten(): legt eine neue
  Idee-Karte an (kein Bau), verkettet an die Quell-Karte, mit dem Wortlaut der
  Rueckmeldung. Auftrag: bestehendes Konzept lesen, genannte Punkte UND deren
  Folgewirkungen aendern, Rest stehen lassen, kurzer Haertetest nur auf die
  geaenderten Teile. Schafft der Vorschlag ein Problem: einbauen UND die Folge
  sichtbar in die Risiken schreiben — nicht uebergehen, nicht schoenreden.
  Die neue Fassung geht an DIESELBE Stelle (Repo-Commit bzw. Datei + .bak), so
  zeigt "Konzept" ueberall den frischen Stand. Beliebig oft wiederholbar, weil
  die Ueberarbeitung selbst wieder eine Idee-Karte ist.
- UI: Rueckmeldefeld direkt unter dem gelesenen Konzept, ueber dem
  Gefaellt-mir-Weg (der haeufigere Fall gehoert nach oben und offen sichtbar).
- SOUL: neuer Sonderfall UEBERARBEITUNG (Schritt 2 entfaellt, kein zweites Repo).

Zwei Fehler nebenbei gefunden und behoben:
- _wo_liegt(): der Auftrag verwechselte "Repo bekannt" mit "Konzept kommt aus
  dem Repo". Faellt MC2 auf die lokale Datei zurueck, stand vorher "liegt im
  Repo als <lokaler Dateiname>" im Auftrag — eine Datei, die es dort nie gab.
- SOUL-Regel 4: der Push gilt erst als erledigt, wenn `git ls-remote` ihn
  BEWEIST. Ein Worker hat am 21.07. genau hier abgekuerzt, Erfolg gemeldet, und
  das Repo war nachher nicht auffindbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:05:34 +02:00
HitonabiandClaude Opus 4.8 94dc3fba9b Karten-Titel: kurz und an der Wortgrenze statt abgeschnittener Textwueste
Der Commander schreibt seine Ideen roh — ein ganzer Absatz wurde 1:1 zum
Kartentitel und war danach ueberall abgeschnitten ("... und lass uns ueberlegen
wie"). Drei Stellen:

- add_idea(): Zurufe ueber 90 Zeichen bekommen einen kurzen Anzeige-Titel
  (erster Satz, sonst Schnitt an der Wortgrenze). Der VOLLE Wortlaut wandert in
  den Auftrag, mit Kopfzeile "der Kartentitel ist nur eine Kurzfassung" — sonst
  arbeitet der Worker am abgeschnittenen Satz.
- _projekt_titel(): der Gruppenname nahm stur die aelteste Karte und schnitt sie
  bei 80 Zeichen mitten im Wort ab. Jetzt gewinnt bei fehlendem gemeinsamen
  Praefix der kuerzeste Mitglieds-Titel (die aus einem Konzept abgeleiteten
  Karten sind die gepflegten) — Praefixe wie "Idee:" fallen weg, Schnitt an der
  Wortgrenze. Aus der ARM-Kette wird so "ARM-UI — Modernes Dashboard fuer
  Automatic Ripping Machine" statt des halben Zurufs.
- UI: Tooltip der Zeile zeigt den vollen TITEL statt des Auftragstexts (der
  beginnt mit Vorspann-Bausteinen und half beim Lesen nie).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:46:40 +02:00
HitonabiandClaude Opus 4.8 3cdd5cdd10 Auftragsbuch: Konzepte sichtbar, Modi unterscheidbar, Ideen weiterfuehrbar
Drei Luecken, die die neue Idee-Bahn unbrauchbar machten (User-Fund 21.07.):

1. Konzept unsichtbar. Der projektstart-Worker folgte dem Ende des
   konzept-fliessband-Skills (Kopie nach ~/konzepte + Telegram) statt seiner
   SOUL (Repo + KONZEPT.md) und schloss die Karte ab. Die Konzept-Ansicht sucht
   nur in Gitea -> "noch kein Repo hinterlegt", obwohl das Konzept fertig war
   (zweimal passiert: arm-ui, pomodoro-timer). konzept_of() faellt jetzt auf
   ~/konzepte/*.md zurueck (Pfad aus der Abschlussmeldung, sonst ueber den Titel
   geraten; streng auf dieses Verzeichnis begrenzt) und nennt die Quelle.
   Zusaetzlich sagen SOUL und Skill jetzt ausdruecklich, dass die Skill-Ablage
   fuer Kanban-Worker NICHT das Ende ist.

2. Modus nicht erkennbar. Idee und IDE-Projekt trugen dasselbe "IDE"-Abzeichen,
   Box-Karten gar keins. list_queue() liefert jetzt `art` (aus dem Body-Marker
   AUFTRAGS-ART: IDEE PRUEFEN), die UI zeigt BOX / IDEE / IDE mit Erklaerung —
   plus eine immer sichtbare Zeile, wann man welchen Modus nimmt.

3. Idee war eine Sackgasse. Neu: POST /api/ideen/weiterfuehren legt aus einer
   fertig durchdachten Idee eine IDE-Projekt-Karte an — verkettet an die Idee,
   mit dem fertigen Konzept als Vorlage ("denk es NICHT neu"), Titel aus der
   Konzept-Ueberschrift statt aus dem Rohtext. Liegt das Konzept schon in einem
   Repo, wird genau dieses weitergenutzt statt ein zweites anzulegen.
   In der UI sitzt der Knopf unter dem gelesenen Konzept (Ziel + Aufwand).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:40:16 +02:00
Auftragsbuch 9307c8b752 Auftragsbuch: 'doku/orchestrator-skill' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:33 +02:00
Auftragsbuch 204238925a Auftragsbuch: 'doku/gitea-skill-doku' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:25 +02:00
Hitonabi a16cff39ce Doku: orchestrator Skill dokumentieren 2026-07-21 04:05:11 +02:00
Hitonabi 11c3416571 Doku: gitea-workflow Skill dokumentieren 2026-07-21 03:53:59 +02:00
HitonabiandClaude Fable 5 7389386791 Vierter Modus 'Idee': Produkt-Idee durchdenken statt Projekt starten
Fuer echte Software-Ideen (z.B. Game-Server-Management), die weder MC2/Lucy-
Umbau noch schon ein Zed-Projekt sind. Volle Denk-Kaskade mit PRODUKT-Blick
(was, fuer wen, was gibt es schon, kleinster Wurf, was spricht dagegen) statt
Umsetzungs-Blick. Ergebnis: NUR KONZEPT.md — keine Roadmap, kein Geruest, keine
Bau-Aufforderung; der Commander entscheidet danach. Kein Ziel-Schalter (eine
Idee braucht noch keine Plattform). Eigene Farbe (sky), Button 'Durchdenken'.
Nebenbei: projektstart-SOUL.md ist jetzt versioniert + deploy-fest (lag bisher
nur auf der Box).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:57:06 +02:00
HitonabiandClaude Fable 5 40c8ed4754 Docker-Ziel bekommt portablen Kontext statt Heimlabor-Steckbrief
Docker heisst bei diesem Commander: bewusst raus aus der LXC-Welt, portabel/
extern. Der Proxmox-/pve-/PBS-Kontext haette das Konzept nur in die falsche
Richtung geschubst. LXC-Pfad (und die Kein-Ziel-Wahl = seine Standard-Welt)
behalten den vollen Heimlabor-Steckbrief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:45:08 +02:00
HitonabiandClaude Fable 5 656c5045da Infrastruktur-Kontext: LXC-Bestand nicht hartkodieren, nachschlagen lassen
Die fest eingetragene Container-Liste veraltet (106 homepage war schon weg) und
kostet im Steckbrief bei JEDEM Worker-Turn Kontext. Jetzt nur noch das Prinzip:
AI-Box = Werkbank, pve = separates Ziel-System — Bestand bei Bedarf per
'ssh pve pct list' nachsehen statt annehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:43:09 +02:00
HitonabiandClaude Fable 5 828dbe13b7 Infrastruktur: AI-Box vs Proxmox-Host klar trennen (Blindfleck)
Die IDE-Planer kannten den Proxmox-Host gar nicht — der Worker laeuft AUF der
AI-Box und nahm an, 'hier' sei auch das Ziel (User musste das muehsam erklaeren).
Steckbrief + IDE-Auftrag nennen jetzt beide Maschinen explizit: AI-Box
192.168.178.151 = Werkbank (nie Deployment-Ziel), Proxmox 'pve' 192.168.178.108
= separates Ziel-System mit den LXCs (100 adguard, 101 npmplus, 102 netbird,
103 pve-scripts-local, 104 gitea/.153, 105 PBS), per 'ssh pve' erreichbar, pct.
Live verifiziert (pct list, gitea-IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:39:59 +02:00
HitonabiandClaude Fable 5 82a3bc3c59 Ziel Docker = NATIVES Docker (keine LXC-Nesting-Konstrukte)
Korrektur: Docker-Wahl bedeutet normales, portables Docker nach Standard-Praxis
(Dockerfile, compose, ueber Gitea-/GitLab-Pipelines baubar) — nicht 'compose im
LXC mit Nesting'. Genau dann geht der Commander bewusst aus der LXC-Welt raus.
Infrastruktur-Steckbrief bleibt Kontext (lokal, kein Cloud/Bezahldienst), zwingt
aber nicht mehr LXC-Denke auf den Docker-Pfad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:23:16 +02:00
HitonabiandClaude Fable 5 d5da853db2 IDE-Intake: Ziel-Plattform waehlbar (LXC oder Docker) + Infrastruktur-Steckbrief
Zweiter, dezenter Schalter (nur bei IDE): Ziel = LXC (Standard) oder Docker.
Die Wahl fliesst als 'ZIEL-PLATTFORM'-Zeile in den Auftrag, dazu immer ein
Infrastruktur-Steckbrief (Proxmox/LXC, PBS-Backups, systemd, self-hosted Gitea,
alles lokal) — sonst plant jedes Modell nach Industrie-Default Docker/Cloud.
Skill: Ziel + Infrastruktur sind bindend; traegt die Wahl nicht, muss das
Konzept es ausdruecklich sagen statt sie still zu ignorieren.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:18:20 +02:00
HitonabiandClaude Fable 5 7b9752261a Konzept-Ansicht: Repo-URL auch in Markdown-Backticks erkennen
Der Worker schreibt die Repo-URL in der Abschluss-Zusammenfassung als
`https://...` — die Lookahead-Liste der Regex kannte den Backtick nicht,
also fand die Konzept-Ansicht kein Repo ('wird gerade vorbereitet') obwohl
Repo + KONZEPT.md laengst da waren. Fix: greedy matchen, Treffer hinterher
saeubern (Backticks/Satzzeichen/.git) statt das Endezeichen zu raten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:14:45 +02:00
HitonabiandClaude Fable 5 f77c09295a Auftragsbuch: Modus-Knoepfe farblich trennen (Box=teal, simpel=violett, gruendlich=amber)
Simpel und gruendlich waren beide violett. Jetzt drei klar unterscheidbare
Farben; der Vorbereiten-Button und die Erklaerung faerben sich mit dem
gewaehlten Modus (amber = intensiver/laenger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:10:37 +02:00
HitonabiandClaude Fable 5 cfcb5d2db5 IDE-Intake: Tiefe-Wahl (simpel/gruendlich) statt einem IDE-Knopf
Drei Modus-Knoepfe: Box · IDE: simpel · IDE: gruendlich. Die Wahl fliesst als
'AUFWAND (vom Commander gewaehlt): SIMPEL/GRUENDLICH'-Zeile in den Auftrag und
UEBERSTIMMT die Groessen-Selbsteinschaetzung von konzept-fliessband (simpel=2
Rollen/1 Runde, gruendlich=volle Kaskade). Skill: bei echter Ambition zur vollen
Tiefe (nicht 'im Zweifel kleiner'), explizite Commander-Wahl schlaegt Schaetzung.
Auto-Skalierung bleibt Fallback fuer Telegram/Lucy (ohne Knopf).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:04:19 +02:00
HitonabiandClaude Fable 5 efca31c21f gitea-repo-create: Anlage-Token im ECHTEN User-Home finden (getent)
Worker-Subprozesse laufen mit abweichendem $HOME → das Skript fand das
write:user-Anlage-Token nicht und fiel aufs Push-Token (write:repository)
zurueck → 'Token hat fehlenden Scope', IDE-Vorbereiter blockte am Repo-Anlegen
(Pomodoro-Testlauf). Fix: echten Home aus /etc/passwd aufloesen (getent),
unabhaengig von $HOME.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:50:49 +02:00
HitonabiandClaude Fable 5 a57e971607 konzept-fliessband: Aufwand zur Projektgroesse skalieren + Profil-Sync
Mini-Projekte bekamen die volle Kaskade (3-5 Rollen x bis 3 Runden = ~15-20
heavy-Aufrufe a ~90s → 35 min fuer einen Pomodoro-Timer). Neu: Rollen+Runden
skalieren mit Groesse (klein=2 Rollen/1 Runde, gross=volle Kaskade), frueher
raus bei WASSERDICHT. Deploy spiegelt den Skill jetzt auch in Profile, die ihn
haben (projektstart). Ergaenzend (Box-Config): projektstart-Manager coder->hermes,
damit heavy warm bleibt (coder 46G verdraengte heavy 60G bei jedem Aufruf;
hermes 22G persistent koexistiert mit heavy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:45:10 +02:00
HitonabiandClaude Fable 5 bf9c22ab78 Auftragsbuch: Konzept-Ansicht fuer IDE-Projekte (KONZEPT.md inline)
IDE-Vorbereiten-Karten bekommen einen 'Konzept'-Knopf: MC2 holt KONZEPT.md
aus dem vorbereiteten Gitea-Repo (Gitea-Raw-API + Auth aus git-credentials)
und rendert es direkt im Auftragsbuch (leichter MD-Renderer lib/markdown.tsx).
So liest der Commander das ausgearbeitete Konzept in der GUI, ohne das Repo
aufzumachen (User-Wunsch: 'lesen gehoert in MC2, feilen in die IDE').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:02:12 +02:00
HitonabiandClaude Fable 5 ff8125d2ac No-Progress-Bremse: worker.sh/fremdblick.sh ausnehmen (Fliessband-Motor)
konzept-fliessband/orchestrator rufen worker.sh pro Runde/Rolle wiederholt auf
(verschiedene Prompts, aehnliche Prosa) — die Bremse hielt das faelschlich fuer
eine Schleife und blockte (Pomodoro-IDE-Test lief 19 min dagegen an). Diese
Delegations-Helfer haben ihr eigenes Runden-Limit → von der Bremse ausnehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:47:45 +02:00
HitonabiandClaude Fable 5 4bb6b28297 Auftragsbuch: Intake-Toggle kuerzer beschriftet (Modus / Box / IDE)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:38:39 +02:00
HitonabiandClaude Fable 5 e3b3725b6f Hermes-Runtime-Patch-Traeger + Fan-out-/Kontext-Fixes
Neuer update-fester Patch-Traeger (deploy/hermes-patches/apply.py) haelt
MC2-eigene Fixes im Hermes-Quellcode, idempotent, in deploy.sh + postcheck
verdrahtet (Exit 10 = Gateway-Neustart, Exit 1 = rot):
 0001 needs_input/capability warten auf Commander (keine triage-Eskalation
      -> keine Muenz-Schleife)
 0002 Decompose erst bei Startreife (Parents done)
 0003 Orphan-Guard: Reaper killt Worker, deren Task nicht mehr running
      (mc2_kanban_reaper.py) -> max_in_progress leckt nicht
 0004 Projekt-/Repo-Kontext vererbt sich auf Kind-Karten
 0005 Etappen-Auto-Kette: Bulk-Root-triage-Karten einer Session verketten
Steckbrief: 'Repo fehlt -> selbst anlegen' + verschaerfte Kontext-Regel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:22:07 +02:00
HitonabiandClaude Fable 5 77ae504b58 IDE-Welt: Vorbereiten-Bahn — Idee → Konzept + leeres Repo + Doku, Bau in Zed
Neue Intake-Weiche Box/IDE. IDE-Ideen gehen direkt an das neue Profil
'projektstart' (am Triage/Auto-Zerleger vorbei, kein Werkstatt-Bau): es
haertet via konzept-fliessband ein Konzept, legt ein leeres Gitea-Repo an
und fuellt es NUR mit Doku (Konzept/Roadmap/AGENTS.md/.aiexclude). Ergebnis
ist ein vorbereitetes Projekt, das der Commander in Zed selbst baut.
UI: Toggle 'Box baut / Ich bau in Zed', IDE-Badge auf Karten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:43 +02:00
HitonabiandClaude Fable 5 8153d14d9f Queue-Steuerung: Stopp/Neuer-Versuch/Prio + Haenger-Warnung (UI+Telegram)
Karten lassen sich gezielt anhalten (generischer Block + Prozessbaum-Kill),
frisch starten (Retry beendet Worker samt Kindern - uvicorn-Falle) und per
Prio-Pfeilen umsortieren (Dispatcher zieht priority DESC nativ). Laufende
Karten ohne Heartbeat >10 min bekommen ein Warn-Badge; der Ketten-Digest
alarmiert nach 15 min per Telegram, inkl. Kontext-Verdichtungs-Hinweis.
Ausloeser: Scaffold-Worker hing 45 min an selbst gestartetem uvicorn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:26:09 +02:00
HitonabiandClaude Fable 5 a91d70d9b7 Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende
Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'.
Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links +
Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort,
Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:07:15 +02:00
Auftragsbuch eb1f333bba Auftragsbuch: 'wartung/gedaechtnis-check-timer-dokumentation' angenommen (Ein-Klick-Gate) 2026-07-20 04:10:55 +02:00
Hermes-Agent 24e2017d5b Doku: Gedaechtnis-Check-Timer in Automatik-Fahrplan aufnehmen 2026-07-20 03:54:55 +02:00
HitonabiandClaude Fable 5 2383dd26fa Uebergabe: Abnahme-Ergebnis des Selbst-Audits (t_4dd12efb, 6/6 PASS)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:56:44 +02:00
HitonabiandClaude Fable 5 aebe10306d Uebergabe-Dokument: Drei-Welten-Umbau 19.07. (finaler Claude-Stand)
Die Box-lesbare Kapsel des kompletten Umbaus: Architektur, neue Mechanik,
Verdikte, IDE-Pfade, offene Faeden. Claude-Gedaechtnis/Artifacts werden mit
Abo-Ende unlesbar - alles Betriebsrelevante steht ab jetzt HIER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:44:30 +02:00
HitonabiandClaude Fable 5 6b03a2c3cf Gedaechtnis-Bereiche: alltag/projekt-Feld statt zweitem mem0
- classify_facts ordnet neue Fakten zusaetzlich einem Bereich zu (alltag=Person,
  projekt=Technik); Metadatum bereich, in /memory-Items sichtbar.
- GET /memory?bereich=... filtert; Alt-Fakten ohne Feld laufen IMMER mit.
- docs/wissen/GEDAECHTNIS-BEREICHE.md: Entscheid (kein zweites mem0), Stand,
  4-Wochen-Selbstpruefung und kompletter Bauauftrag fuer den Recall-Filter -
  damit die Werkstatt das ohne externe Hilfe fertigbauen kann.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:38:12 +02:00
HitonabiandClaude Fable 5 a1d7a4b6b9 mem0-Konsolidierung: monatlicher Lauf (3. des Monats)
Legt semantisch gleiche Fakten zusammen und loest Widersprueche (neuester gewinnt)
ueber /graph-Cluster + fast-Hirn (NoThink). Harte Leitplanken: pro Gruppe ueberlebt
immer ein Fakt, max 1 Update, LLM-Fehler = Gruppe unangetastet, Kappung pro Lauf.
Erster Live-Lauf 19.07.: 459 -> 420 Fakten (15 zusammengefasst, 39 geloescht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:32:52 +02:00
HitonabiandClaude Fable 5 903f3bc074 Phase 2 Drei-Welten-Plan: No-Progress-Bremse + Betrieb-Playbook + mem0-Lern-Buendelung
- no-progress-bremse.py: generische, turn-uebergreifende Schleifenbremse (pre/post_tool_call);
  Muster-Hash statt hartkodierter Fehler-Strings, Selbst-Lernen via neue-signaturen.jsonl.
  Schliesst die Luecke der nativen guardrails (pro Turn, nur klassifizierte Fehler).
- ensure-profile-hooks.py: kann Eintraege in BESTEHENDE Event-Bloecke einfuegen
  (zweiter pre_tool_call-Key haette den Tabu-Guard lautlos verschluckt) + registriert Bremse.
- betrieb-playbook-Skill: SSH-/Deploy-Diagnose-Checklisten (Passphrase-Falle zuerst),
  Triage-Regel im Steckbrief-Hook; Skill wird in alle Profile gesynct.
- mc2-memory: Lern-Extraktion gebuendelt (4 Turns oder 180 s idle = EIN Lauf) statt pro Turn -
  Extraktion konkurrierte mit Lucys Hirn um die Slots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:21:18 +02:00
HitonabiandClaude Fable 5 331aafa25d gitea-repo-create: dediziertes Anlage-Token (Push-Token bleibt unberuehrt)
Sicherer als .git-credentials zu ueberschreiben: Anlage-Token (write:user) aus
$GITEA_CREATE_TOKEN oder ~/.config/gitea/create-token; Push/PR-Token in
~/.git-credentials bleibt unangetastet (kein Bruch, falls das neue Token kein
write:repository hat). User/Host nicht-geheim mit Defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:43:31 +02:00
HitonabiandClaude Fable 5 cab748f32e Lucy kann Projekt-Repos anlegen (gitea-repo-create.sh) - PRIVAT-only
Lucke im "Projekte selbst starten"-Weg: Werkstatt konnte nur Branches in
bestehende Repos proposen, kein neues Projekt-Repo anlegen. Neues Skript:
POST /api/v1/user/repos, IMMER privat (Gitea ist oeffentlich erreichbar!),
auto_init + default main, Token aus ~/.git-credentials (nie ausgegeben),
Audit-Log + stiller Chronik-Spiegel je Anlage.
Braucht Token-Scope write:user (aktuelles Box-Token hat nur write:repository);
ohne das meldet das Skript GENAU, welches Token in Gitea zu generieren ist,
statt kryptisch zu scheitern. Token-Upgrade = Commander-Handgriff (Credential).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:29:40 +02:00
Auftragsbuch d91bd5a139 Auftragsbuch: 'wartung/gitea-disable-registration' angenommen (Ein-Klick-Gate) 2026-07-17 17:30:42 +02:00
Hitonabi f21fe90090 feat(gitea): DISABLE_REGISTRATION=true & Security-Memo draft 2026-07-17 17:24:53 +02:00
HitonabiandClaude Fable 5 7b826f66c4 Venv-Audit: grep -c Zaehl-Bug (doppelte Zeile -> Rechenfehler)
Erstlauf-Befund: "grep -c ... || echo 0" haengte bei 0 Treffern eine zweite
Zeile an (grep -c druckt selbst schon "0") -> "integer expected" + kaputte
Summe. || echo 0 raus, Ergebnis auf Ziffern reduziert. Fund selbst war echt:
1 CVE in mem0, 27 Pakete veraltet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:03:10 +02:00
HitonabiandClaude Fable 5 e556dcc853 Venv-Audit: uv-venvs haben kein pip - Inspektion via Audit-venv pip --path
Erstlauf-Befund: mem0-/voice-venvs sind uv-erstellt (kein bin/pip, kein
pip-Modul). Das Audit-venv prueft deren site-packages jetzt von aussen
(pip list --outdated/--format=freeze --path) - Dienst-venvs bleiben unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:59:24 +02:00
HitonabiandClaude Fable 5 b26fb40cbc Restore-Probe: --ignore-ownership (Erstlauf-Befund) + Tarball-Grep nur Dateien
Erstlauf fand echten Stolperstein: pxar-Restore extrahiert als User korrekt,
scheitert aber am chown root-eigener Dateien (llamaswap) -> Fehl-Exit trotz
intakter Daten. Restore-Aufrufe jetzt mit --ignore-ownership/--ignore-acls
(uns interessiert der INHALT); llamaswap-Fallback prueft den Inhalt statt des
Exit-Codes. Kern-Datei-Grep matcht nur noch Dateien, keine Verzeichnisse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:55:28 +02:00
HitonabiandClaude Fable 5 bc60c6fefc Betriebs-Luecken: Restore-Probe, Venv-Audit, Disk-Waechter, PC-Watch
Scope-Entscheid 17.07.: MC2 bleibt Box-Zentrale (KEINE Homelab-Zentrale) -
Infra-Radar (Proxmox/PBS/QNAP/Gitea) geht als eigenes Projekt an Lucys Queue.
- restore-probe.sh (Cron 1. d. M. 06:40, no-agent): stellt WIRKLICH wieder her -
  PBS-Canary aus hermes.pxar (selektiv via --pattern, sudo-frei ueber die
  User-Unit-Zugaenge) + Tarball-Probe + Frische-Check <36h; Erstlauf-Fallbacks
  (llamaswap.pxar / Kern-Datei) bis der Canary in den Sicherungen ankommt.
- venv-audit.sh (Cron 2. d. M. 06:40, no-agent): pip outdated + pip-audit-CVEs
  fuer mem0-/voice-venv ueber eigenes Audit-venv; Funde -> Kanban-Karte je Monat,
  Update bleibt Commander-Entscheid.
- self-smoke: Disk-Waechter (Check 7, Schwelle 85%, nennt die groessten Brocken).
- Trend-Radar-Watchlist: neue Typen github_release_major + pypi; Eintraege
  electron-major (PC-Shell auf 33!) und pocket-tts (PyPI-Versionswatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:50:48 +02:00
HitonabiandClaude Fable 5 de21e1ac68 Pruefstand+Radar: MTP-Fairness, Tempo-Baseline-Refresh, Puls-Langzeit-Historie
Drei Luecken aus der Selbst-Review 17.07.:
1. MTP-Fairness: Kandidaten mit MTP-Kopf (Dateiname oder nextn-Tensoren im
   GGUF-Kopf) bekommen eine Zusatz-Tempomessung mit draft-mtp; Steckbrief
   rechnet best-of speed/speed_mtp - das Live-Hirn laeuft schliesslich MIT MTP.
2. Baseline-Refresh: baseline.json traegt jetzt den Engine-Build; weicht die
   installierte Engine ab (So-Auto-Update), misst der Pruefstand-Cron nur den
   Tempo-Teil aller Rollen neu (Geister-Zahlen-Schutz, Qualitaet bleibt gueltig).
3. Langzeit-Historie: Puls wird als JSONL angehaengt statt ueberschrieben;
   Radar vergleicht zusaetzlich gegen ~8 Wochen zurueck und meldet
   SCHLEICHENDE REGRESSION, die woechentlich unter der 10-%-Schwelle bleibt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:28:27 +02:00
HitonabiandClaude Fable 5 9c03316755 Trend-Radar: mechanische Amtsinhaber-Sperre (Testlauf-Befund 17.07.)
Der erste echte Radar-Lauf schlug prompt das eigene Hirn-Repo
(unsloth/Qwen3.6-35B-A3B-MTP-GGUF) als hermes-Kandidat vor - Zitat-Gate korrekt
passiert, aber Repackaging des Amtsinhabers ist nie ein Kandidat. Jetzt doppelt:
Raster-Regel + mechanischer Block ueber Modellnamen-Token im Gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:47:28 +02:00
HitonabiandClaude Fable 5 f5e362e902 Pruefstand: Vision-Checkliste beugungsfest (Baseline-Befund 17.07.)
Beide Augen-Modelle beschrieben das Testbild korrekt ("in der oberen linken
Ecke"), scheiterten aber an undeklinierten Pflicht-Stichworten ("obere linke").
Gruppen um Beugungs-Stämme ergänzt (oberen link / unteren recht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:29:50 +02:00
HitonabiandClaude Fable 5 2be599bfe9 Pruefstand-Harness: Think-Blöcke strippen + Budgets rauf (Baseline-Befund 17.07.)
Rohe :8080-Aufrufe an Denk-Modelle (Qwen3.6) liefern <think>-Monologe im content:
der Deutsch-Richter benotete Grübel-Text (Note 1-3), das Zitat-Gate suchte darin,
und 900 max_tokens gingen komplett fürs Denken drauf (leere Antworten).
- inhalt_von() entfernt <think>-Blöcke; abgeschnittenes Denken = ehrlich leer
- recherche 1200->2800, deutsch 900->2400 max_tokens (Denkbudget einpreisen)
- Fehlschläge speichern jetzt antwort_auszug (Diagnose ohne Nachstellen)
- pv-anteil: überstrenges Pflicht-Stichwort 412 entfernt
Mock-E2E nach Patch: coding 6/6, recherche 4/4; Think-Strip-Unit-Probe grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:09:06 +02:00
HitonabiandClaude Fable 5 b0dce954e9 Trend-Radar + Pruefstand: Box beobachtet Modell-/Engine-Trends und testet Kandidaten selbst
- trend-radar-feed.sh (Cron Sa 05:15): Live-Puls ALLER Rollen mit Vorwochen-Vergleich,
  Engine-A/B gegen neuen llama.cpp-Build (Fruehwarnung vor So-Auto-Update), mechanische
  Watch-Liste (MMQ-Issue, ROCm-Releases, Community-Grid), HF-Kandidaten-Suche mit
  Zitat-Gate -> max. 1 Pruefstand-Kandidat/Woche
- pruefstand.sh + harness.py (Cron So 01:00, no-agent): volles Programm je Kandidat -
  6 Coding-Aufgaben mit echten Unit-Tests, 4 Agenten-Aufgaben (Tool-Loop + Endzustand),
  4 Recherche-Fragen mit Zitat-Ehrlichkeits-Gate, Deutsch-Richter, Vision-Testbild,
  Tempo kurz+lang; Baseline der Amtsinhaber als Vergleichsmassstab
- Leitplanken (User-Entscheid 17.07.): Download-Deckel 35 GB (drueber -> Karte),
  1 Kandidat/Woche, Cache mit Auto-Aufraeumen, RAM-/Platz-Wache, Bench-Port :5899,
  Radar/Pruefstand EMPFEHLEN nur - Rollen-Wechsel bleibt Commander-Klick
- E2E bewiesen (Mock-LLM): coding 6/6, recherche 4/4 inkl. Zitat-Gate, Agent-Roundtrip,
  Richter-Parsing; Suiten-Selbsttest mit Referenzloesungen 6/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:57:07 +02:00
Auftragsbuch 8579fe9934 Auftragsbuch: 'wartung/mc2-gateway-venv-check' angenommen (Ein-Klick-Gate) 2026-07-17 12:15:27 +02:00
Auftragsbuch 32c24d7b60 Auftragsbuch: 'feature/skill-inactive-filter' angenommen (Ein-Klick-Gate) 2026-07-17 12:14:22 +02:00
Hitonabi 547851f1c3 feat: Skript zur Filterung inaktiver Skills
- filter_inactive_skills.py: Filtert Skills mit use_count=0 oder last_used_at=null
- Ausschluss von Media-Skills (gif-search, heartmula, songsee, youtube-content)
- Ausgabe sortiert nach last_used_at (älteste zuerst)

Hinweis: Die Dateien liegen im scripts/ Verzeichnis, da es dort keine
semantisch passendere Location gibt. Das Skript ist für den Hermes Skill-Index gedacht.
2026-07-17 04:27:16 +02:00
Hitonabi 1daacea468 feat(gateway): Prüfskript für Python-Interpreter im MC2-Virtualenv
- check_venv.sh prüft Existenz + Ausführbarkeit des Interpreters
- Gibt bei Erfolg 0 zurück, sonst 1 mit klarer Fehlermeldung
- Wird als ExecStartPre= in der systemd-Unit eingebunden

Hinweis für Commander: Die systemd-Unit deploy/mc2-gateway.service
muss nach Annahme manuell um ExecStartPre=... erweitert werden.
2026-07-17 03:53:19 +02:00
HitonabiandClaude Fable 5 5aded967ef Gateway: Kontext-Limit-Warnung ignoriert Warm-Pings (max_tokens<=16)
Lucys Augen-Waermer (alle 10 min, max_tokens=1 an "vision"), warmup.sh und
models/load enden konstruktionsbedingt mit finish_reason=length — die Warnung
vom 15.07. hielt jeden Ping fuer einen abgerissenen Worker und spammte den
Briefkasten (~alle 20 min, Serie 16.07. abends). Wer freiwillig auf <=16
Tokens deckelt, will keine echte Antwort -> keine Warnung; echte Abrisse
melden weiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:01:05 +02:00
HitonabiandClaude Fable 5 a22f27bc60 Gateway: No-Think-Schnellspur fuer Lucys Voice-Turns (Hirn-Latenz 6-21s -> <1s)
Live-Diagnose 16.07.: Die 'Hirn'-Zeit der Voice-Turns (Dashboard 6,4-21,5s) war fast
vollstaendig Qwen3.6-Reasoning VOR dem ersten sprechbaren Wort (reasoning_content
2500+ Zeichen bei ~100 t/s; /no_think-Softswitch wird ignoriert; Prefix-Cache war
NICHT das Problem - live gemessen: Zeitstempel-Aenderung kostet nur ~0,8s).

Fix: Lucy traegt Marker [[MC:NO_THINK]] im System-Prompt; das Gateway strippt ihn
aus allen Messages (konstanter Text -> Prefix-Cache stabil) und setzt
chat_template_kwargs enable_thinking:false. Direkt am Hirn gemessen: 9,9s -> 0,8s,
Persona-Qualitaet unveraendert. Requests ohne Marker (Crons/Telegram/Werkstatt)
denken unveraendert weiter. Abschaltbar via MC_NO_THINK_MARKER="".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:55:14 +02:00
HitonabiandClaude Fable 5 31fde2a68e self-smoke/postcheck: Browser-Tool-Check + Selbstheilung (agent-browser-Link-Falle, Vorfall 16.07.)
Hermes' browser_navigate starb still als "Failed to open <url>": das
Hermes-Update 15.07. 23:53 (npm ci --workspace) bog ~/.local/bin/agent-browser
per postinstall auf node_modules/ um und loeschte das Paket im selben Lauf
wieder -> toter Symlink. Kein fehlender MCP/Skill.

Neu, damit die Box das OHNE Zuruf erkennt und heilt:
- self-smoke.sh Check 6: agent-browser --version; bei totem Link auf das
  npm-global-Binary (~/.local/lib/node_modules/...) zurueckbiegen und
  hermes-gateway/hermes-builtin-ui try-restarten (browser_tool cached
  "nicht installiert" pro Prozess). Selbstheilung wird als Info gemeldet
  (neuer HEALED-Kanal), nicht als Alarm; unfixbar -> Rot wie gehabt.
- hermes-postcheck.sh: gleicher Check direkt nach jedem Hermes-Update,
  denn genau dort entsteht der Bruch.

Heil-Logik isoliert auf der Box getestet (Fake-HOME + systemctl-Stub):
toter Link -> geheilt+Restart; danach PASS ohne Restart; Binary weg -> FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:38:15 +02:00
HitonabiandClaude Fable 5 0890698750 Auftragsbuch: Ergebnis-Panel klebt nicht mehr auf Fehler (nochmal versuchen)
Fiel der erste Ladeversuch in ein Deploy-Fenster (Zentrale startet nach
Karten-Annahme kurz neu), blieb "Ergebnis nicht ladbar" dauerhaft stehen,
obwohl die API das Ergebnis liefert (Vorfall 16.07., t_2a7cfd46). Die
Fehlzeile ist jetzt ein Klick-Retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:08:19 +02:00
HitonabiandClaude Fable 5 77dfac79ee selbst-inventur: Diff-Filter warf alle Bullet-Zeilen weg (immer "0 Aenderungen")
Steckbrief-Zeilen sind Bullets ("- ..."), im Diff also "+- "/"-- " — der alte
Filter ^[+-][^+-] (gedacht gegen +++/----Kopfzeilen) verschluckte damit jede
echte Aenderung. Jetzt werden nur die Datei-Kopfzeilen ausgefiltert.
Beweis 16.07.: realer Nacht-Diff hatte 7 Aenderungen, Meldung sagte 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:04:58 +02:00
HitonabiandClaude Fable 5 fa22435d7c tabu-pfade-guard: V16 — relative Pfade bei riskantem cwd blocken (Vorfall 16.07.)
Nacht-Worker t_7a05992a ersetzte 03:59 den Live-Checkout mit cwd=$HOME und
RELATIVEN Pfaden (rm -rf mission-control-v2 && git clone ...) — der Guard
prueft beim terminal-Tool nur den Kommandotext auf home-verankerte Formen
und liess das durch (.venv weg -> Gateway 203/EXEC-Crash-Loop).

Drei Stopfen:
(1) cwd IN einem Tabu-Pfad -> jede Schreib-Operation geblockt (Lesen frei).
(2) git clone/init mit Ziel direkt in $HOME geblockt (Worker klonen im
    Task-Workspace); explizites Ziel ausserhalb $HOME bleibt erlaubt.
(3) cwd == Vorfahr eines Tabu-Pfads -> relative Nennung des Tabu-Ziels
    zaehlt als Treffer; Lookarounds lassen den Workspace-Klon-Pfad
    (.hermes/kanban/workspaces/<id>/mission-control-v2) weiter durch.
Nebenbefund zu: clone fehlte in der git-Schreibliste (a) — auch
git clone URL ~/mission-control-v2 war vorher ungeblockt.

Lokal bewiesen: 35/35 Payload-Faelle (Vorfalls-Kommando geblockt,
Workspace-Arbeit/Lesen frei, Alt-Verhalten regressionsfrei).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:02:35 +02:00
Auftragsbuch 4df6a3ecf9 Auftragsbuch: 'wartung/parse-mc2-timeout-user-journal' angenommen (Ein-Klick-Gate) 2026-07-16 11:01:48 +02:00
werkstatt d697f3cc0d fix: parse_mc2_timeout.py auf User-Journal umstellen (--user) 2026-07-16 11:00:33 +02:00
Auftragsbuch fd241aca51 Auftragsbuch: 'wartung/parse-mc2-timeout' angenommen (Ein-Klick-Gate) 2026-07-16 10:39:41 +02:00
werkstatt d2d726c105 wartung: Skript zum Parsen von systemd-Timeout-Fehlern (parse_mc2_timeout.py)
Einfaches Python-Skript, das journalctl nach 'State stop-sigterm timed out' durchsucht und Ergebnisse mit ISO-Zeitstempel in ~/logs/mc2-timeout.log schreibt. Bereitet Berechtigungsfehler sauber auf.
2026-07-16 04:08:10 +02:00
HitonabiandClaude Fable 5 71192b3db0 Zeitachse (24h-Historie), Verbinden-Autofill, Modelltausch abgesichert
Paket 1 - Metrik-Historie mit Zeitachse (User: 'WANN war Last?'):
- services/metrics_history.py: 10s-Sampler (CPU/RAM/GPU/Disk + Token-Totale) in
  24h-Ringpuffer (aelteres faellt automatisch raus, nichts waechst unbegrenzt),
  persistiert alle 5 min -> uebersteht Deploys; GET /api/system/history?minutes
  mit Downsampling auf ~300 Punkte; Lifespan-Task in app.py
- Cockpit-Karten System-Status + Token-Durchsatz: Bereichs-Schalter Live/1h/24h,
  sichtbare Zeitachse (HH:MM:SS bei kurzen, HH:MM bei langen Fenstern), Tooltip
  zeigt Uhrzeit; Token-Raten in 1h/24h aus Totale-Deltas

Paket 2 - Verbinden selbsterklaerend:
- Box-IP vorbefuellt aus window.location.hostname (Dev-Fallback bleibt)
- MCP-Scriptpfad-Feld aus der Kopfzeile in die 'Gedaechtnis anbinden'-Karte
  verschoben, mit Erklaerung WANN man es braucht

Paket 3 - Modelltausch-Loecher gestopft (Review 16.07.):
- install: Registrierung OHNE Alias-Umzug; Rolle wird erst NACH erfolgreichem
  Download uebernommen (on_done) - hermes dabei durch den warm-bewussten
  set_agent_brain-Flow statt rohem Alias-Move (Lucy waere sonst bis Download-
  Ende tot gewesen); Re-Install erhaelt bestehende Aliase
- set_role_alias: lebenswichtige Aliase (hermes/embed) des Ziel-Modells
  ueberleben jeden Rollen-Klick (Qwen3.6 haelt live hermes+fast!)
- Rollen-Endpoint verweigert Rollen-Wechsel am Halter geschuetzter Aliase
  mit klarer Anleitung; Werkbank sperrt die Rollen-Chips sichtbar

Verifiziert: py_compile + Sampler-Smoke (2 Punkte, Persist ok) + Alias-Logik-
Unittest (3 Faelle) + Browser (Zeitachse tickt, Schalter, Leerzustand, Verbinden).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:08:03 +02:00
HitonabiandClaude Fable 5 26224804ed Tab-Review-Fixes: toter Gedaechtnis-Knopf, Konsole-Restart, Cockpit-Dopplung
- Gedaechtnis: 'Im Terminal starten' navigierte zu nicht existenter View 'terminal'
  -> 'konsole'; interne Duplikate (AUTO-Set, CAT_LABEL_MAP) entfernt
- Konsole: box-console jetzt in Dienste-Liste + Restart-Allowlist; Offline-Overlay
  bekommt Ein-Klick-'Dienst neu starten' statt SSH-Anweisung
- Cockpit Werkzeuge: Doppel-Eintrag ('Box-Shell' + 'Interaktives Terminal', beide
  -> Konsole) zu EINEM Eintrag 'Konsole (Box-Shell)' zusammengelegt
- Auftragsbuch: 'Nichts zu entscheiden'-Banner beruecksichtigt blockierte
  Ideen-Karten; Abschnitt heisst neutral 'Patch-Vorschlaege'
- Hermes-Diagnose: Klickweg (Dienste-Schublade oeffnen) statt veraltetem
  Drawer-Namen + systemctl-Kommandos
- Entdecken: Upgrade-Knopf uebernimmt fremde Quant nicht mehr blind
- Skills: Status-Badge zeigt Klartext (abgeschaltet/defekt/...) statt rohem State

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:42:04 +02:00
HitonabiandClaude Fable 5 bc60f9254b Verbinden-Tab: eine Geschichte statt Lanes-vs-Modelle-Widerspruch
- Hero 'Leitung 1' zeigte 'Lanes chat / coding', alle Snippets nutzen aber die
  ECHTEN Modelle (Verdikt 09.07.: IDE-Welt = coder/heavy/vision, kein Lane-Routing)
  -> Zeile zeigt jetzt die Modelle; Fusstext erklaert die Lanes als Zusatz
- Kilo-Note entwirrt: 'Ask/Debug -> Lane chat' als virtuelles Modell benannt
- Anleitung Par.10 (IDE-Tools) auf dieselbe Story (coder/heavy/vision statt chat/coding)
- connect.py: stale '_gw'-Kommentar (MC2 serviert /v1 selbst) auf UMBAU-v3-Stand;
  check_health nutzt HERMES_BUILTIN_UI_UPSTREAM statt hartem 127.0.0.1:9119

Live geprueft vor dem Fix: Lanes chat/coding/auto funktionieren (200), stehen aber
nicht in /v1/models; /v1/messages weiterhin 404 (Claude-Code-Hinweis korrekt);
hermes-ui 200 + Token-Datei existiert (Anleitung stimmt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:32:12 +02:00
HitonabiandClaude Fable 5 0c69c491e5 Single Source of Truth: Frontend/Backend-Ungereimtheiten ausgeraeumt (Review 15.07.)
Backend (Wahrheit reparieren):
- gateway_reachable() prueft jetzt den ECHTEN mc2-gateway (:9010 via V1_UPSTREAM)
  statt nur die Engine - toter Denkpfad war in Health/Cockpit unsichtbar
- /api/system/services vollstaendig: + LLM-Gateway, + Steuerpult, + Waechter
  (mc2-steward via is-active); scope-Feld (user/system); mc2-gateway/mc2-steward
  in die Restart-Allowlist
- Connect-Health Leitung 1 prueft den Client-Pfad (V1_UPSTREAM statt llama-swap)
- agent_status liefert pc_executor_url (UI zeigte hartcodierte IP/Ports)
- SSE-Endlosschleife gefixt: /api/auftragsbuch schrieb announce-branches bei JEDEM
  Aufruf neu -> mtime-Bump -> invalidate -> Refetch im 3-s-Takt je Client;
  jetzt nur noch bei echter Aenderung

Frontend (ein Datenweg):
- SystemDrawer komplett auf React-Query-Hooks (vorher eigenes 3-s-setInterval auf
  dieselben Endpunkte inkl. teurem Updates-Check); Dienste-Liste = Backend-Antwort
  (SERVICES-UI-Kopie geloescht); useDialog statt Eigenbau; fmtBytes statt Duplikat
- useLucyHealth: Dienst-Matching per systemd-unit statt Namensfragment; Gateway-
  Reparatur zielt auf mc2-gateway; neuer Waechter-Check; Backend-tot => ehrliches
  rotes Verdikt statt eingefrorener letzter Stand (auch Sidebar)
- Toter Code raus: views/models/Cockpit.tsx (908 Z.) + RoleAssignModal + Placeholder;
  LaneEditor (Routing-Policy) + WarmSetManager damit ZURUECK im UI als Tab
  "Routing & Warm-Set" im Modell-Manager
- Cockpit: blockierte Ideen-Karten erscheinen in "Braucht dich" + Kachel-Hint;
  Verbinden-Kachel zeigt 3 Leitungen live statt hartem "Zed"
- Maschinenraum: ehrliches "frei" (reale Belegung) + eigenes KV-Cache-Segment
- AgentView: Ports/PC-Adresse aus der API; Guide lehrt Lanes chat/coding
- queries.ts: useModels nutzt SSE-relax; Chronik-Limit im Query-Key;
  useJobs/useZeitmaschine mit enabled-Schalter

Verifiziert: tsc+vite gruen, Backend-Smoke beide Gateway-Modi, UI live gegen die
Box (Cockpit/Werkbank/Routing-Tab/Drawer rendern mit Echtdaten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 22:27:32 +02:00
Auftragsbuch b36561990e Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 22:08:21 +02:00
HitonabiandClaude Fable 5 41b0edba53 UMBAUPLAN: P1 KOMPLETT - Stufe 2 gefahren + Neustart-Beweis bestanden
Cutover 15.07. 21:26 (alle 14 Hermes-base_urls auf :9010, Backups).
Abschlussbeweis 21:27: MC2-Restart mitten im Betrieb, Denkpfad :9010
antwortete durchgehend HTTP 200 - null Aussetzer. Reine Doku.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:28:18 +02:00
Auftragsbuch b7e6cf4acc Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 19:45:45 +02:00
Auftragsbuch f271b04754 Auftragsbuch: 'feature/pydantic-response-models' angenommen (Ein-Klick-Gate) 2026-07-15 19:44:20 +02:00
HitonabiandClaude Fable 5 78bff28acf UMBAUPLAN: P1/P2-Status auf AKTIV getrued (beide angenommen+verifiziert)
Fahrplan-Punkte 1-3 als erledigt markiert (inkl. Bild-Weiche-Fix d2398b3
und 256er-Testbild-Lehre); einzig offener Schritt ist Punkt 4 = Stufe 2
(gateway-cutover.sh). Reine Doku - Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:43:22 +02:00
HitonabiandClaude Fable 5 ff6ef3eb1f UMBAUPLAN: Sofort-Paket (Abschnitt 3) als komplett erledigt markiert
Alle vier Punkte live verifiziert (15.07. abends): Bild-Weiche-Karte
angenommen+E2E, t_8231d6b8 abgeschlossen, sudo-Runde durch (v1-Unit/opt
restlos weg, Root-Warmup-Kopie byte-identisch mit Repo), Briefing-Cron
auf unendlich. Reine Doku — Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:42:09 +02:00
Hitonabi 21ea7e1dd3 backend: Pydantic response_models für 5 GET-Endpoints hinzufügen
- health.py: HealthResponse
- eigenleben.py: EigenlebenOverviewResponse + SkillTextResponse
- chronik.py: ChronikResponse + ChronikItem
- wissen.py: WissenListResponse + WissenFileResponse
- reminders.py: ReminderListResponse + ReminderOut

py_compile bestanden, /docs zeigt neue Schemata.
Response-Payloads unverändert — nur Typisierung hinzugefügt.
2026-07-15 19:33:39 +02:00
HitonabiandClaude Fable 5 e91f6b7ba8 SSE-Eventstrom versoehnt: getesteter Kern + Modell-Quelle aus der Werkstatt-Karte
Die angenommene Karte feature/sse-backend-v1 ersetzte die bereits E2E-
getestete Hand-Implementierung mit drei harten Fehlern: 'type:' statt
'event:' (ungueltiges SSE-Framing - EventSource-Listener feuern NIE),
globaler Snapshot ueber alle Clients (verschluckt Events), Ideen-Zaehler
gegen nicht existierenden Endpunkt :9010/v1/ideen. Dazu doppelter
Router-Mount in app.py (Merge-Folge).

Zurueck auf den bewiesenen Kern (3s-Fingerprints, Basislinie je
Verbindung, is_disconnected, korrektes Framing) + die GUTE Idee der
Karte uebernommen: Running-Set der Modelle als Quelle (Laden/Entladen
invalidiert die Modelle-Ansicht). System-/Token-Metriken bleiben bewusst
beim Polling (aendern sich jede Sekunde = invalidate-Laerm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:30:27 +02:00
Auftragsbuch d0a967e765 Auftragsbuch: 'feature/sse-backend-v1' angenommen (Ein-Klick-Gate) 2026-07-15 16:10:08 +02:00
HitonabiandClaude Fable 5 87670dd3a6 self-smoke Check 5: Bild-Weiche v2 taeglich aktiv durchspielen
Der Pfad (Bild an coder -> Vision beschreibt -> Coder antwortet) brach am
15.07. zweimal still: erst als nie verdrahteter Patch in der Hermes-Quelle,
dann am gepoolten Keep-Alive-Socket nach Modell-Swap (:9010). 256x256-Rot
als Testbild - Winzbilder liefern VL-Halluzinationen (Kapuzinerkresse).
Nebeneffekt: waermt coder+vision morgens vor. Vorschlag der P2-Session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:58:52 +02:00
werkstatt 41e569cf99 backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)
- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
2026-07-15 15:38:32 +02:00
HitonabiandClaude Fable 5 d2398b3624 Bild-Weiche v2: frischer Client + Retry fuer den Vision-Unteraufruf
Befund :9010 (Journal): httpx.ReadTimeout nach Sekunden trotz timeout=240 -
der gepoolte Keep-Alive-Client reicht Sockets wieder aus, die llama-swap
beim Modell-Swap gekappt hat. Der Beschreibungs-Unteraufruf nutzt jetzt
einen eigenen Kurzzeit-Client und versucht es einmal erneut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:30:36 +02:00
HitonabiandClaude Fable 5 202e97333a Bild-Weiche v2: stummen Fallback-except durch Logging ersetzen (Diagnose :9010)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:22:46 +02:00
Auftragsbuch 0eb2b37abc Auftragsbuch: 'wartung/skills-text-kategorie-fix' angenommen (Ein-Klick-Gate) 2026-07-15 15:10:52 +02:00
HitonabiandClaude Fable 5 0f13a4f18d Skills-View: Text-Endpoint matcht jetzt Kategorie-Skills ({skill_id:path})
Kategorie-Skills (z. B. autonomous-ai-agents/hermes-agent) haben einen
Schraegstrich in der ID. Die Route /api/eigenleben/skill/{skill_id} nahm nur
EIN Pfadsegment -> Request fiel in den SPA-Fallback (index.html, Status 200),
die UI zeigte "(Konnte den Skill-Text nicht laden.)". Live bewiesen:
skill/wartung = JSON, skill/autonomous-ai-agents%2Fhermes-agent = HTML.

Fix: :path-Konverter in der Route; dazu im Service "."/".."-Segmente
explizit abweisen (das Zeichen-Set der bestehenden Validierung liesse
".." als Segment durch, bisher irrelevant, mit :path sauber dicht).
Backend-only, kein dist-Rebuild noetig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:07:44 +02:00
HitonabiandClaude Fable 5 5d0a61c38e UMBAU v3 P3a handgebaut: SSE-Eventstrom /api/events (Invalidation-Bus)
User-Entscheid 15.07. abends: P3-Worker-Jobs abgebrochen (22 Anlaeufe,
0 Ergebnisse) - 'du bearbeitest sie ein letztes Mal manuell'.

P3a KOMPLETT: backend/routers/events.py streamt SSE; ein Sammler prueft
alle 3 s billige Fingerabdruecke (Briefkasten-Cursor, Queue-Status,
Auftragsbuch-/Reminder-mtimes) und schickt bei Aenderung 'invalidate'
mit den React-Query-Keys. frontend/src/lib/events.ts haelt EIN
EventSource, invalidiert gezielt und entspannt die vier gedeckten
Poller x5 (relax() in queries.ts); reisst der Strom, reconnectet
EventSource selbst und die UI pollt wie bisher. Live-Graphen bleiben
bewusst beim Polling (aendern sich jede Sekunde).

P3b (TanStack Router) BEWUSST NICHT umgesetzt: Hash-Navigation liefert
Deep-Links + Zuruecktaste bereits; Router-Migration = hohes Regressions-
risiko ueber alle Views bei minimalem Gewinn fuer 1-Nutzer-LAN (gleiche
Logik wie das React-bleibt-Verdikt in UMBAUPLAN 3b).
P3c (OpenAPI-Typen) VERTAGT: ohne Pydantic-response_models liefert der
Generator leere Typen - Voraussetzung ist erst das Backend-Typing
(eigene, klein geschnittene Karten-Serie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:00:52 +02:00
HitonabiandClaude Fable 5 5dde4c9f5c Box kann jetzt Frontend bauen: Node LTS sudo-frei installiert, Hook-Wahrheit umgedreht
User-Entscheid 15.07.: 'Node sollte auf jeden Fall auf die Box - generell
alles, um sich selbst zu warten.' Node v24.18.0 (LTS) liegt unter
~/.local/node (offizielles Tarball, KEIN sudo), Symlinks in ~/.local/bin,
das im PATH des hermes-gateway (= aller Worker) bereits enthalten ist.
Bau-Beweis auf der Box: frischer Klon, npm ci + npm run build -> dist in
~4 s. Der Steckbrief-Hook weist Worker jetzt an, dist selbst zu bauen und
mitzucommitten (vorher: 'Box kann nicht bauen'-Vermerk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:51:04 +02:00
Auftragsbuch bc69cf5cc9 Auftragsbuch: 'feature/p2-steward' angenommen (Ein-Klick-Gate) 2026-07-15 14:47:55 +02:00
HitonabiandClaude Fable 5 7223ca96c1 Deploy-Robustheit: Selbst-Reset-Guard + TimeoutStopSec=5 (Lehren P1-Deploy 15.07.)
1) deploy.sh fuehrt sich nach dem git reset einmal frisch neu aus (exec-Guard,
   Env-Marke MC2_DEPLOY_REEXEC): der Reset ersetzt die laufende Datei, bash las
   einen alt/neu-Zeilen-Mix — beim P1-Deploy fielen so daemon-reload und der
   mc2-gateway-Start aus (Lucy 502, Heilung von Hand).
2) mc2-gateway.service TimeoutStopSec=5: uvicorn wartete beim Stop auf offene
   LLM-Streams (default 90 s SIGKILL-Fenster ohne Listener = 502-Fenster bei
   jedem Deploy-Restart, live gemessen 14:29->14:31). Stateless Proxy, Clients
   retrien — hart nach 5 s ist verlustarm.
UMBAUPLAN: Annahme-Hinweis fuer P2 (einmal deploy.sh von Hand nachlaufen lassen,
weil der Guard erst MIT diesem Deploy an Bord kommt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:47:35 +02:00
HitonabiandClaude Fable 5 3cf111d973 Kontext-Limit sichtbar machen: Gateway-Warnung bei finish_reason=length + Etappen-Regel
User-Wunsch 15.07. ('es sollte eine Warnung geben - Kontext ist nicht
unendlich'): 4 P3-Worker starben still an finish_reason=length.
- Gateway erkennt abgerissene Antworten (Stream-Chunk + Non-Stream) und
  meldet je Modell max. alle 10 min in den Briefkasten (silent -> Chronik),
  Zustellung per MC_ANNOUNCE_HTTP ans Steuerpult (Store bleibt Ein-Schreiber).
- Steckbrief-Hook: KONTEXT-BUDGET & ETAPPEN-REGEL - gezielt lesen, grosse
  Aufgaben in Etappen schneiden (kanban_create/complete mit Plan) statt
  am Limit zu sterben.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:43:36 +02:00
HitonabiandClaude Fable 5 3468bc9c80 Steckbrief-Hook: Repo-Wegweiser + Frontend-Wahrheit (kein node auf der Box)
User-Beobachtung 15.07.: jeder Worker kartiert das Repo neu (ls-Ketten,
geratene Pfade wie 'read queries.ts -> not found'). Der Hook sagt jetzt
jedem Worker-Turn, WO was liegt (Router/Services/Views/queries.ts/nav) und
die dist-Wahrheit: Box hat KEIN node/npm -> frontend/dist ist auf der Box
nicht baubar, Vorschlaege muessen das ehrlich vermerken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:36:51 +02:00
Auftragsbuch ba0458c0af Auftragsbuch: 'feature/p2-steward' angenommen (Ein-Klick-Gate) 2026-07-15 14:29:20 +02:00
HitonabiandClaude Fable 5 fdabe1768e Skills-Ansicht: Umbenennung + aktiv/inaktiv + Karten-Ergebnisse im Auftragsbuch
User-Feedback 15.07.:
1) 'Von allein' heisst jetzt 'Skills' (id #eigenleben bleibt, Links halten).
2) Jeder Skill traegt aktiv/inaktiv - Quelle ist Hermes' EIGENE Wahrheit
   (hermes_cli.banner.get_available_skills via Hermes-venv, dieselbe Logik
   wie das eingebaute WebUI: Plattform/Voraussetzungen/Config). Bewusst NUR
   die Datenquelle wiederverwendet, nicht deren UI - Skill-VERWALTUNG bleibt
   im Hermes-GUI-Tab, alles andere waere Bloat/Doppelbau.
3) Auftragsbuch: fertige Queue-Karten sind aufklappbar und zeigen das
   ERGEBNIS des Workers (kanban_complete-Summary, lazy via
   GET /api/ideen/{id}/ergebnis) - 'Dinge werden getestet, ich sehe aber
   das Ergebnis nicht' ist damit zu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:22:05 +02:00
Auftragsbuch bf9fd808d8 Auftragsbuch: 'feature/von-allein-und-gateway-p1' angenommen (Ein-Klick-Gate) 2026-07-15 14:19:45 +02:00
HitonabiandClaude Fable 5 a9c0239f8a UMBAU v3 P2: Steward — Waechter-Loops als eigener Prozess mc2-steward
Re-Warm, Health-Sentry und Mem0-Dedupe laufen als eigener Dienst
(backend/steward.py, Restart=always) statt im Steuerpult-Lifespan:
MC2-Neustarts nehmen den Waechtern nicht mehr Timing/Flanken-Gedaechtnis,
und der Sentry ueberwacht erstmals MC2 SELBST + mc2-gateway (Telegram
funktioniert auch bei totem Steuerpult; Briefkasten-Abgabe per HTTP via
MC_ANNOUNCE_HTTP, Store bleibt exklusiv beim MC2-Prozess). Warm-Nudge
nach Config-Aenderung via mtime-Watch (5 s) statt In-Process-Signal.
Reiner Konfig-Split: MC2-Unit setzt die drei ENABLED-Schalter auf 0,
Zeilen entfernen = Rollback. reminders_loop bleibt bewusst in MC2
(teilt Datei+CRUD mit /api/reminders, Zwei-Schreiber-Risiko).

Stellt ausserdem den beim Karten-Neuaufbau (ccc9a25) verlorenen
stack-postcheck-Block fuer mc2-gateway wieder her (+ Steward-Check 4c).

Baut auf feature/von-allein-und-gateway-p1 auf; Annahme schliesst P1 ein.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:15:36 +02:00
HitonabiandClaude Fable 5 ccc9a25a25 UMBAU v3 P1: Gateway-Auszug — /v1 wird eigener Prozess mc2-gateway (:9010)
ACHTUNG: Annahme AKTIVIERT die Stufe 1 (deploy.sh installiert
mc2-gateway.service + setzt MC_V1_UPSTREAM in der MC2-Unit; Health mit
Kaltstart-Retry bis 12s, dann hart rot -> Runner-Rollback greift).
MC2 :9001/v1 wird duenner Roh-Weiterleiter, LAN-Clients merken nichts;
Rollback = MC_V1_UPSTREAM-Zeile aus der Unit entfernen. token_stats
laedt bei Fremd-Aenderung per mtime nach (Gateway schreibt, Steuerpult
liest). UMBAUPLAN Abschnitt 3b dokumentiert P1-P4. Stufe 2 (Lucy direkt
an :9010, ueberlebt MC2-Neustarts) = deploy/gateway-cutover.sh, separat.

Neu aufgesetzt 15.07. auf aktuellem main (a3d9c74): die urspruengliche
Karte trug die inzwischen veraltete Von-allein-View doppelt - die ist
laengst auf main live. Inhalt = P1 der Parallel-Session, unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:51:51 +02:00
HitonabiandClaude Fable 5 a3d9c745cd Von allein: Punkt-Ordner (.archive) nicht als Skills listen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:49:15 +02:00
HitonabiandClaude Fable 5 b0eec715c8 Von allein: Kategorie-Skills (2. Ebene) mitzeigen (research/blogwatcher & Co.)
Die mitgelieferten Hermes-Skills sind Kategorie-Ordner (DESCRIPTION.md +
Unter-Skills mit eigener SKILL.md) - der Walker lief nur ueber Ebene 1
und zeigte 12 statt ~40 Skills. Jetzt beide Ebenen, Unter-Skills mit
Kategorie-Badge; Detail-Endpunkt erlaubt Kategorie/Name-Pfad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:47:53 +02:00
HitonabiandClaude Fable 5 86bd72838a Neue Ansicht 'Von allein': Skills + Vorschlags-Bilanz der Box (+ schlafende P1-Basis)
User-Wunsch 15.07.: EIN Viewpoint, was die Box selbst geleistet hat -
Chronik ist zu kompliziert. Neue Operativ-Ansicht #eigenleben:
- Skills aus ~/.hermes/skills (= Lucys Satz) mit Klartext-Beschreibung,
  Nutzungszaehler (.usage.json) und Herkunft (selbst erstellt / von uns
  gebaut / mitgeliefert), aufklappbar mit vollem SKILL.md.
- Vorschlags-Bilanz: angenommen (Auftragsbuch-Chronik) + abgelehnt mit
  Grund (Lern-Journal) als eine Timeline + Zaehler.
Backend: services/eigenleben.py + routers/eigenleben.py (read-only).

Mit an Bord (User-Ja, SCHLAFEND): config.V1_UPSTREAM + app.py-Weiche +
gateway_forward aus dem P1-Gateway-Auszug - ohne MC_V1_UPSTREAM in der
Unit exakt altes Verhalten. Der aktive Rest von P1 kommt separat ueber
Branch umbau/p1-gateway-auszug (dort liegt auch diese View schon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:45:04 +02:00
HitonabiandClaude Fable 5 734cafd98f R5: git-init-Orphans mechanisch verhindern (Guard-Block + Orphan-Gate)
Drei Naechte in Folge bauten Worker git-init-Branches ohne gemeinsamen
Ursprung mit main (cleanse-skill-index 12.07., skill-kanten-generator
13.07., blogwatcher-logging ~14.07.) - bei der Annahme technisch tot,
Nacht-Arbeit verloren. SOUL-Prosa allein driftet (Meta-Lehre Fallstrick-
Audit) -> jetzt mechanisch: tabu-pfade-guard blockt 'git init' im
Kanban-Workspace hart; pre-verify-gates prueft VOR dem Fertigmelden
merge-base gegen origin/main (Orphan-Gate ROT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:25:03 +02:00
HitonabiandClaude Fable 5 abc8a365d2 Review-Haertung R1/R2/R4 + Repo-Waechter (Vorfall verlorener Merge 12.07.)
R1: Steckbrief-Hook erinnert Worker an kanban_complete/kanban_block-Pflicht
    (Nacht-Karte 14.07. blockierte trotz fertiger Arbeit am fehlenden Aufruf).
R2: tabu-pfade-guard sperrt ~/.config/systemd/user fuer Worker — Unit-/
    Override-Dateien nur noch ueber angenommene Karte.
R4: Orchestrator-/Wartungs-Worktrees von /tmp nach ~/.hermes/worktrees
    (ueberleben Reboot, keine kaputten Registrierungen).
Neu: self-smoke Check 4 'Repo-Waechter' — Box-main mit Commits, die origin
    fehlen, loest Alarm aus (so ging die angenommene Karte
    wartung/lucy-annahme-fixes am 12.07. still verloren; am 15.07. als
    rescue/-Branch gerettet und wieder gemergt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:04:16 +02:00
Hitonabi 1cd492eb33 Merge branches 'wartung/bild-weiche-v2' and 'doku/umbauplan-abloesung' 2026-07-15 10:03:18 +02:00
HitonabiandClaude Fable 5 3abc133118 Umbauplan Abschluss-Review 15.07.: IST-Befund, Konfig-Urteil, priorisierte Arbeitsliste
Komplettes Projekt-Review (Repo+Box live+Recherche). Enthaelt Sofort-Paket
(Klicks+sudo-Runde), Robustheit/Aufraeumen/Features/Tempo-Karten und die
Uebergabe-Logik fuer die Zeit nach Claude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:52:11 +02:00
HitonabiandClaude Fable 5 0354ce999f Bild-Weiche v2: Coder-Ziele bekommen Vision-BESCHREIBUNG statt Umleitung
Idee aus verwaistem (nie verdrahtetem) Patch in der Hermes-Quelle
api_server.py - regelkonform in den MC2-Gateway umgezogen, Original
als docs/archiv/hermes-api_server-vision-patch-verwaist.diff archiviert.
Request mit Bild an coder: Bilder werden vorab von VL-30B beschrieben
und als Text injiziert, die Code-Frage bleibt beim Spezialisten.
Fallback bei Analyse-Fehler: bisherige Vision-Umleitung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:46:36 +02:00
HitonabiandClaude Opus 4.8 960bda6021 fremdblick.sh: Prosa-Default 2200 -> 4000 Tokens (Reasoning-Modell-Headroom)
GLM-4.7-Flash (Prosa-Kritiker) ist ein Reasoning-Modell und verbrennt real ~2000
Tokens im reasoning_content, bevor das Urteil in content landet (verifiziert 14.07.).
Bei 2200 schnitt es Gefahr mitten im Denken ab -> leeres content -> FEHLGESCHLAGEN;
das traf die naechtlichen Kritiker mit Default-Budget (Traum-Gate etc.). Der
Release-Radar ueberschreibt ohnehin auf 4500; der Code-Modus (Qwen-Coder, 1600) ist
kein Reasoning-Modell und bleibt unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:54:07 +02:00
HitonabiandClaude Opus 4.8 5f6e3c77d4 Konzept-Fliessband: Skill fuer wasserdichte Konzepte (heavy denkt, Skeptiker haertet)
Neuer Skill konzept-fliessband: rohe Idee -> mehrere Fach-Rollen (heavy/gpt-oss-120b
waehlt den Cast) schaerfen ein Konzept in Runden, ein Advocatus Diaboli (fremdblick/GLM)
haertet bis wasserdicht (max 3 Runden) -> Konzept-Dokument als Vorlage fuer projekt-start
in Hermes Desktop. Propose-only, KEIN Code.

Behebt das delegate_task=coder-Loch: Konzept-/Recherche-Arbeit lief bisher stumm auf dem
Code-Modell, weil natives delegate_task hart auf delegation.model=coder verdrahtet ist und
pro Aufruf kein Modell waehlen kann.

Gehaertet gegen die Reasoning-Modell-Token-Falle (heavy & GLM antworten erst in
reasoning_content): WORKER_MAXTOK=6000 / FREMDBLICK_MAXTOK=4000 + Kaltstart-Retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:30:13 +02:00
HitonabiandClaude Opus 4.8 85a744ac7b Faden 11-Fix: ASCII im Routing-Header (kein '→' -> latin-1-500)
Bild-Request loeste HTTP 500: HTTP-Header muessen latin-1 sein, das '→'
(U+2192) im x-mc-route-reason war nicht kodierbar. Die Weiche selbst
routete korrekt (VL-30B lud). Pfeil auf '->' geaendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:54:57 +02:00
HitonabiandClaude Opus 4.8 faf25bf76c Faden 8+5-Fix: Worker-Scope korrigieren + Profil-Hooks reproduzierbar
Zwei zusammenhaengende Bugs, beim echten Worker-Test entdeckt:

1) HOOKS FEUERTEN FUER WORKER NIE. Kanban-Worker laufen unter IHREM Profil
   (HERMES_HOME=~/.hermes/profiles/<name>) und lesen dessen config.yaml, NICHT
   die Top-Level ~/.hermes/config.yaml. Die neuen Hooks waren nur oben
   registriert -> fuer Worker inaktiv. FIX: ensure-profile-hooks.py registriert
   pre_llm_call + pre_tool_call in jeder Worker-Profil-config (idempotent,
   validiert, Backup); deploy.sh ruft es je Profil auf -> reproduzierbar.

2) FALSCHER SCOPE. Beide Hooks scopeten auf task_id == t_<hex>. Aber Worker
   tragen als effective_task_id den SESSION-Zeitstempel (z.B.
   20260713_224206_267304), NICHT die Kanban-t_-ID -> der Check schlug immer
   fehl -> box-steckbrief injizierte nie, tabu-guard liess alles durch.
   FIX:
   - tabu-pfade-guard.py: Schutz jetzt UNBEDINGT (kein Task-Scope) - kein
     legitimer Hermes-Agent-Flow schreibt je in Live-Checkout/Hermes-Quelle
     (Werkstatt arbeitet im Workspace-Klon, Wartung ist Shell nicht Agent-Tool).
   - box-steckbrief-inject.sh: Scope jetzt cwd unter ~/.hermes/kanban/workspaces/
     (zuverlaessiger Worker-Signal; Lucy/Voice/CLI laufen nie dort).

Verifiziert: echter betrieb-Worker versuchte in den Live-Checkout zu schreiben
-> GEBLOCKT, Datei nicht erstellt, TABU-Meldung im Log. 26 Guard-Unit-Faelle gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:51:47 +02:00
HitonabiandClaude Opus 4.8 5d02e7af91 Faden 11: Gateway-Bild-Weiche - Requests mit Bild automatisch an VL-30B
Entscheid Weg A (vision-harness-verdikt): das MTP-Hirn (fast/hermes) bleibt
schnell und bildunfaehig; Bild-Requests routet das MC2-Gateway automatisch ans
Vision-Modell - kein manueller Modellwechsel, Lucys Flow bleibt.

router_logic.py: has_image(body) erkennt OpenAI-multimodalen content
(image_url/input_image/image); _text_of zieht jetzt nur Text-Parts fuer das
Komplexitaets-Routing (str() einer content-Liste haette die Zeichen-Schwelle
verfaelscht). VISION_CAPABLE = {vision, scout}.
routing_policy.py: neues UI-editierbares vision-Alias (Default env MC_ROUTE_VISION
= "vision"; leer = Weiche aus), darf wie coder_lite leer sein.
gateway_proxy.py _proxy: nach der Alias-Wahl - wenn ein Bild dabei ist und das
Ziel nicht bildfaehig - Override auf das vision-Alias (auch bei explizitem
model=hermes; genau Lucys Fall). Header x-mc-route-reason.

Verifiziert (Unit): Bild+hermes->vision, Bild+auto->vision, Text->fast,
Bild+scout->scout (schon bildfaehig), has_image korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:42:08 +02:00
HitonabiandClaude Opus 4.8 913256e241 gitattributes: agent-hooks/*.py auf LF pinnen (Shebang-Falle)
Ohne Regel wuerde autocrlf tabu-pfade-guard.py bei Checkout zu CRLF machen ->
`#!/usr/bin/env python3\r` bricht auf der Box. Analog zur *.sh-LF-Regel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:27:27 +02:00
HitonabiandClaude Opus 4.8 5a236b4b28 Faden 8: Tabu-Pfade hart sperren (pre_tool_call-Guard)
Fallstrick #2: Worker koennen in die falsche Ebene wandern - Patches im
Live-Checkout ~/mission-control-v2 ablegen oder die Hermes-Quelle
~/.hermes/hermes-agent anfassen. Hermes' native is_write_denied deckt diese
Pfade NICHT ab (nur Credentials/System, live geprueft).

deploy/agent-hooks/tabu-pfade-guard.py (neu): pre_tool_call-Hook, blockt
Schreib-Operationen (write_file/patch + terminal git-write/sed-i/redirect/rm)
auf den home-verankerten Live-Checkout und die Hermes-Quelle - NUR fuer echte
Kanban-Worker (task_id t_<hex>); Lucy/CLI (UUID) = No-op (~15 ms). Der
Workspace-KLON (<workspace>/mission-control-v2) bleibt beschreibbar (nur der
home-verankerte Pfad ist tabu) -> legitime Worker-Arbeit ungestoert. Lesen
erlaubt. 25 Faelle gruen (Block + Allow, inkl. git log 2>/dev/null, Redirect
nach /tmp, Workspace-Commit). deploy.sh synct den Hook (cmp-Guard).

Registrierung in ~/.hermes/config.yaml (pre_tool_call) einmalig von Hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:26:29 +02:00
HitonabiandClaude Opus 4.8 967225b1d7 Faden 7: betrieb-Profil (Ops/Bench/Mess-Worker) - SOUL + deploy-Sync
Fallstrick #1: Mess-/Bench-/Verify-Aufgaben landeten auf default (Lucys
nicht-technische Persona) und flailten. Neues Worker-Profil betrieb (analog
werkstatt): misst/bencht/diagnostiziert/verifiziert auf der LEBENDEN Box,
aendert aber nichts (kein Restart/Config/Deploy).

deploy/betrieb-SOUL.md: Ops-Leitplanken - nur lesen am Live-System, kennt die
Werkzeuge (Health-curl, systemctl --user status MIT XDG_RUNTIME_DIR, bench-
Skripte), Bench-Vorsicht (kein OOM, Lucys Warm-Set schuetzen), Verifizieren-
vor-Behaupten, falsche Ebene -> werkstatt/default. deploy.sh synct sie (guarded
auf existierendes Profil).

Profil selbst + Modell (hermes) + Routing-Descriptions werden einmalig auf der
Box angelegt (hermes profile create betrieb --clone-from werkstatt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:44:07 +02:00
HitonabiandClaude Opus 4.8 e7f143823e Faden 6: Grenzen-Landkarte (was gehoert wohin) + Worker-Hinweis
Verhindert das "Chat-in-MC2"-Muster und Ebenen-Verwechslungen: neue
docs/wissen/GRENZEN.md mappt die fuenf Flaechen (MC2=Steuerpult,
Lucy=Chat/Stimme, Hermes=Fundament/Quelle-tabu, Telegram, Hermes-Desktop)
mit "gehoert hin / gehoert NICHT hin", den Code-Ebenen-Grenzen und einer
"ich will X bauen - wohin?"-Entscheidungshilfe + roten Linien.

Eingewoben: README-Lesereihenfolge, ARBEITSWEISE (Rollen-Abgrenzung zeigt
auf GRENZEN + "MC2 = Steuerpult, kein Chat"), und der pre_llm_call-Worker-
Hook (box-steckbrief-inject.sh) gibt jedem Worker die Kurz-Grenzen inline
(MC2=Steuerpult/kein Chat-UI, Lucy=Chat, Hermes-Quelle tabu) + Zeiger auf
GRENZEN.md. Verifiziert: Hook injiziert 1236 Z inkl. Grenzen, Lucy-UUID No-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:29:33 +02:00
HitonabiandClaude Opus 4.8 4e8f0b91dd deploy: Agent-Hooks nur bei echter Aenderung kopieren (cmp-Guard)
Ein blindes cp aktualisiert die mtime auch bei identischem Inhalt und loest
dadurch bei jedem Deploy die harmlose "script modified since approval"-Warnung
von `hermes hooks doctor` aus. cmp -s ueberspringt unveraenderte Hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:21:46 +02:00
HitonabiandClaude Opus 4.8 69537de304 Faden 5: Scope-Gate auf Kanban-Muster t_<hex> verschaerfen
Wichtige Korrektur vor dem Scharfschalten: Hermes setzt
effective_task_id = task_id OR uuid4() (agent/turn_context.py:216) — Lucys
interaktiver/Voice-Chat laeuft also mit einer NICHT-leeren UUID-Ersatz-task_id.
Der urspruengliche "task_id nicht leer"-Check haette damit auch Lucy getroffen
(Persona-/Latenz-Schaden). Fix: nur injizieren, wenn task_id dem echten
Kanban-Muster t_<hex> entspricht; UUIDs (mit Bindestrichen, nie t_-Prefix)
fallen auf No-op. Verifiziert: t_-ID -> injiziert, UUID/leer/non-t_ -> No-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:18:16 +02:00
HitonabiandClaude Opus 4.8 c81523c0f4 Faden 5: Box-Selbstwissen mechanisch in jeden Kanban-Worker (pre_llm_call-Hook)
Fallstrick #1 behoben: Ops-/Mess-/Verify-Aufgaben landen oft auf `default`
(= Lucys Persona, "nicht technisch, keine Pfade/Versionen") und flailen, weil
sie llama-swap :8080 & Co. nicht kennen. Nur `werkstatt` hatte Box-Wissen.

deploy/agent-hooks/box-steckbrief-inject.sh (neu): pre_llm_call-Hook, haengt
eine kompakte Box-Orientierung ephemer an die User-Message JEDES Kanban-Workers
(egal welches Profil) — aber NUR wenn eine task_id gesetzt ist. Lucys
interaktiver/Voice-Chat hat keine task_id -> sofortiger No-op (7 ms gemessen),
kein Persona- oder Latenz-Eingriff. Inhalt: Identitaet + Modell-Endpunkte
(llama-swap :8080, MC2 :9001/v1, gateway :8642, mem0 :8765) + Tabu-Live-Checkout
+ Zeiger auf den vollen Selbst-Steckbrief und docs/wissen. = der "via Hook/
Wrapper/Guard"-Weg aus der Fallstrick-Karte (Dispatcher-Spawn ist Hermes-intern).

deploy/agent-hooks/pre-verify-gates.sh: der bestehende py_compile/dist-Gate-Hook,
bisher nur manuell auf der Box -> jetzt verbatim im Repo getrackt (ueberlebt
Box-Neuaufbau, schliesst eine Autonomie-Luecke).

deploy.sh synct beide nach ~/.hermes/agent-hooks/ (+chmod). Die Registrierung
in ~/.hermes/config.yaml (hooks: pre_llm_call) bleibt einmalig von Hand
(config.yaml ist Zwei-Schreiber-sensibel).

Verifiziert: Hook-Logik auf der Box gegen synthetische Payloads (Worker ->
987-Zeichen-Kontext injiziert, Lucy/leer -> {}, JSON valide), bash -n + LF sauber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:10:34 +02:00
HitonabiandClaude Opus 4.8 e5f546a72f Faden 4: Antwort-Feld fuer haengende Aufgaben (needs_input)
Sackgasse behoben: ein Worker blockte eine Aufgabe mit einer Frage
(kanban block --kind needs_input), aber die Zentrale zeigte nur den
roten Status "haengt - braucht dich" - ohne die Frage und ohne
Eingabefeld. Der Worker blieb so dauerhaft stehen.

Backend (services/ideen.py):
- _blocker_frage(): liest die Worker-Frage aus dem juengsten
  blocked-Event (payload.reason/kind), Fallback: BLOCKED:-Kommentar.
- list_queue() reichert blockierte Karten mit frage + frage_kind an
  (max 6 Extra-show-Aufrufe pro Poll).
- answer_task(): unblock --reason schreibt die Antwort als Kommentar
  und stellt die Karte auf ready -> Dispatcher spawnt den Worker neu,
  der die Antwort im Kontext vorfindet. Mit ID-/Laengen-Validierung.

Router: POST /api/ideen/antwort.

Frontend (AuftragsbuchView): blockierte Karten zeigen die Frage
(je nach Grund "Die Box hat eine Frage:" / "kam hier nicht weiter:")
plus Antwortfeld + "Antworten & weiter". Fallback-Text ohne Grund.
Vertraegt sich mit dem Live-Log-Blick (nur triage/running).

Verifiziert: E2E gegen die echte Box-Kanban-CLI (Frage auslesen,
answer_task entsperrt + protokolliert, Fehlerpfade), py_compile +
Frontend-Build gruen, Antwort-UI im Dev-Server gerendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:48:18 +02:00
Auftragsbuch 5436e4df16 Auftragsbuch: 'feature/soul-push-verify' angenommen (Ein-Klick-Gate) 2026-07-13 18:23:33 +02:00
werkstatt a1957d7094 deploy/werkstatt-SOUL.md: Drittes Gate 'Push wirklich gelandet' hinzufügen 2026-07-13 18:21:49 +02:00
Auftragsbuch 5690217d84 Auftragsbuch: 'feature/soul-push-verify' angenommen (Ein-Klick-Gate) 2026-07-13 18:18:03 +02:00
claude-direktandClaude Fable 5 0a9293f669 Werkstatt-Anleitung: Push wirklich verifizieren bevor Abschluss
Drittes Gate nach merge-base + rebase: git ls-remote origin <branch> muss den Branch zeigen, sonst kanban_block statt kanban_complete. Behebt Schein-Erfolg (13.07.2026: Worker meldete gepusht+done, Branch war nie auf Gitea).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:17:08 +02:00
Auftragsbuch 5496159b8b Auftragsbuch: 'feature/live-log-blick' angenommen (Ein-Klick-Gate) 2026-07-13 17:17:41 +02:00
werkstatt 9a41cce99b MC2: Live-Log-Blick - Worker-Log laufender Ideen im Auftragsbuch aufklappbar machen
Backend:
- backend/services/ideen.py: neue Funktion log_of(task_id)
  - ruft 'hermes kanban log <id>'
  - validiert task_id mit _TASK_ID_RX
  - strippt ANSI-Steuerzeichen (\x1b\[[0-9;]*m)
  - gibt letzte ~120 Zeilen zurück
  - kurzer Cache (~4s)
  - auf Windows/available=False leer zurück, nie crashen
- backend/routers/ideen.py: GET /api/ideen/{id}/log -> ideen.log_of(id)

Frontend:
- frontend/src/views/AuftragsbuchView.tsx: IdeenSection
  - Ideen mit status 'triage' oder 'running' per Klick aufklappbar
  - im aufgeklappten Zustand /api/ideen/{id}/log alle ~4s pollen
  - Log monospace/<pre> in scrollbaren, höhenbegrenztem Kasten
  - vorhandenes api()-Muster + react-query nutzen
- frontend/dist mitgebaut und committet

Branch: feature/live-log-blick
2026-07-13 17:17:16 +02:00
Auftragsbuch 1427048f38 Auftragsbuch: 'wartung/telegram-ping-neue-karte' angenommen (Ein-Klick-Gate) 2026-07-13 17:05:41 +02:00
Hitonabi 82453e9f12 Wartung: Telegram-Ping bei neuer Auftragsbuch-Karte
- Persistenz-Datei ANNOUNCE_BRANCHES_PATH (mc2-announce-branches.json) für gemeldete Branches
- list_proposals() pingt NEUE Branches per notify_telegram() und announce.add()
- Idempotenz: nur EINMAL pro Branch, bei jedem Poll nur wirklich NEUE melden
- notify_telegram ist best-effort/posix (Windows-Dev = No-op), in try/except gekapselt
2026-07-13 17:04:12 +02:00
HitonabiandClaude Fable 5 a2bfe0e480 Fix: Speicher-Anzeige einheitlich (Box-Health == System-Status)
Die 'Box gesund'-Speicher-Pille zeigte den GTT-Pool (GPU-Sicht auf den
Unified-Memory der APU), die System-Status-Kachel daneben den System-RAM
— zwei verschiedene Zahlen fuer denselben Speicher (z.B. 73/124 vs 90/122).

useLucyHealth: Speicher-Ampel jetzt aus sys.ram (dieselbe Quelle wie die
RAM-Zeile), Schwellen am RAM-Charakter geeicht (warn 85%, full 93%; 4 Modelle
warm ~78% bleibt gruen). CockpitView: Pille auf 1 Nachkommastelle wie gb().
Live verifiziert: beide zeigen jetzt identisch 95.9/122.7 GB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:42:41 +02:00
HitonabiandClaude Fable 5 c71a0a1abd Anleitung: System-Schaubild + Ist-Stand-Kur (Stand Juli 2026)
- Neue Sektion 'So funktioniert dein System' ganz oben: Landkarte
  (Du -> 4 Tueren -> Box-Innenleben -> PC-Executor) + Kreislauf
  (Idee -> Queue -> Werkstatt -> Auftragsbuch -> DEIN Klick -> Live,
  Rueckkanal Chronik/Morgenlage) + Nacht-Fahrplan; reines Inline-SVG
  mit Design-Tokens (frontend/src/views/guide/SystemSchaubild.tsx)
- Veraltete 'Bei dir konkret'-Kaesten repariert:
  * Modell-Line-up auf Klartext-Rollen (Hirn/Gross/Coder/Augen/Spaeher/
    Kritiker/Gedaechtnis/Sortierer) statt fast/heavy/scout
  * Hermes: eigenes Agent-Hirn statt 'Hirn = fast'; Zugaenge Desktop//hermes-ui,
    Konsole, Telegram, Lucy statt totem 'Terminal'-Tab
  * IDE-Kapitel: Hermes Desktop als Haupt-IDE ergaenzt
  * Backup: PBS-Backup-Zentrum + Tarball statt nur restore.sh; Auto-Revert erwaehnt
  * Troubleshooting: Zentrale->Cockpit, Terminal->Konsole, Zeitmaschine ergaenzt
- Datumsstand Juni -> Juli 2026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:33:06 +02:00
HitonabiandClaude Fable 5 94444f4dcb Frontend-Erneuerung: Mobile, Diät, Tempo, UI-Politur (10-Punkte-Paket)
- Fix: 'Interaktives Terminal' im Cockpit führte ins Leere (#terminal) → Konsole
- Mobile: Off-Canvas-Sidebar mit Hamburger unter md, Inhalt volle Breite
  (vorher: 240px starre Sidebar, 135px Rest mit Quer-Scroll auf dem Handy)
- Dead Weight raus: src/mc3/ (verworfener Prototyp), DashboardView (alte
  Zentrale) + 6 nur dort genutzte Karten, 8 ungenutzte npm-Pakete
  (three, three-vrm, react-three fiber/drei, sigma, graphology ×2, @types/three)
- Code-Splitting: alle Views außer Cockpit per React.lazy → Haupt-Bundle
  983 → 739 KB, jede View ein eigenes Chunk
- Fonts lokal (@fontsource) statt Google-CDN → UI offline identisch
- Polling harmonisiert: EINE Takt-Tabelle in queries.ts (Graphen 3s, Rest
  8-60s), keine Override-Intervalle mehr in Komponenten; Updates-Check
  (apt/git!) von 4s auf 60s
- CSS-Hack-Layer aufgelöst: !important-Klassen-Sniffing → explizite
  .mc-card/.mc-card-sm/.nav-active-Klassen (Look identisch, ungeschichtete
  Regeln statt Utility-Raten); totes .light-Theme raus; Scrollbars 1× definiert
- Aurora: animierte Fullscreen-Blur-Filter → statische radial-gradients
  (gleicher Look, keine Dauer-GPU-Last)
- Sicherheit: Sudo-Passwort/HF-Token nur noch bei mutierenden Requests,
  nicht mehr auf jedem GET-Poll
- Strg+K statt ⌘K auf Windows; dist neu gebaut

Live gegen die Box verifiziert: alle 10 Views + Mobile-Flow, 0 Konsolen-Fehler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:29:51 +02:00
Auftragsbuch f88f8e8bf9 Auftragsbuch: 'feature/projekt-start-skill' angenommen (Ein-Klick-Gate) 2026-07-13 11:13:15 +02:00
HitonabiandClaude Fable 5 52f25432b8 Projekt-Start-Skill: "erst denken, dann bauen" fuer Hermes Desktop
Portiert vom PC-Skill (F:\Coding Stuff\projekt-start): 4 Phasen mit hartem
Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md/.aiexclude-Ritual und
Kontext-Waechter. Zed-Verweise durch Hermes Desktop ersetzt (Modellwaehler
"Box / Coder (bauen)" / "Box / Planer (denken)", Planer-Kontexthinweis),
.aiexclude-Regel in die AGENTS.md-Vorlage gehoben, Verweis auf den
Werkstatt-Weg fuer Box/Lucy-Wartung ergaenzt. deploy.sh installiert ihn
nach ~/.hermes/skills/projekt-start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:56:33 +02:00
957 changed files with 59065 additions and 27619 deletions
-27
View File
@@ -1,27 +0,0 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
+29 -2
View File
@@ -18,11 +18,38 @@
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5180",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"env": {
"MC_API_TARGET": "http://192.168.178.151:9001"
},
"autoPort": false,
"port": 5180
},
{
"name": "frontend-mock",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5181",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": {
"MC_API_TARGET": "http://127.0.0.1:9000"
},
"autoPort": false,
"port": 5181
}
]
}
-92
View File
@@ -1,92 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git fetch *)",
"Bash(git push:*)",
"Bash(git rev-list *)",
"Bash(ssh hitonabi@192.168.178.151:*)",
"Bash(grep -E \"\\\\.py$|^d\")",
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
"Bash(python -c ' *)",
"Bash(echo \"tsc exit: $?\")",
"WebSearch",
"WebFetch(domain:lobehub.com)",
"WebFetch(domain:docs.litellm.ai)",
"WebFetch(domain:github.com)",
"WebFetch(domain:runaihome.com)",
"WebFetch(domain:docs.getbifrost.ai)",
"WebFetch(domain:thefrontierlab.ai)",
"WebFetch(domain:www.glukhov.org)",
"WebFetch(domain:cognio.so)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
"Bash(git checkout *)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
"Bash(xargs cat)",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"WebFetch(domain:unsloth.ai)",
"WebFetch(domain:huggingface.co)",
"Bash(xargs ls -la)",
"Bash(xargs wc -l)",
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
"WebFetch(domain:docs.mem0.ai)",
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"WebFetch(domain:www.hermes-ai.net)",
"WebFetch(domain:releases.electronjs.org)",
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)"
]
}
}
+9
View File
@@ -7,6 +7,15 @@
*.service text eol=lf
*.timer text eol=lf
# Agent-Hooks laufen auf der Box (Shebang!) — .py-Hooks MÜSSEN LF behalten,
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
deploy/agent-hooks/*.py text eol=lf
deploy/*.py text eol=lf
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
deploy/**/*.py text eol=lf
# Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf
*.bat text eol=crlf
+17 -5
View File
@@ -7,11 +7,6 @@ __pycache__/
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local
*.env
.DS_Store
@@ -20,3 +15,20 @@ frontend/dist/avatar.vrm
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
# Lokale Claude-Code-Einstellungen (enthielten bis 24.09.2026 ein Token) und Worktree-Ordner
.claude/settings.local.json
.claude/worktrees/
# Caches und lokale Umgebungen, egal in welchem Ordner
.ruff_cache/
.pytest_cache/
.venv/
node_modules/
# Sicherungskopien von Hand
*.bak
*.bak-*
*.orig
+34 -8
View File
@@ -1,4 +1,4 @@
# AGENTS.md — Mission Control 2.0
# AGENTS.md — Homelab Orchestrator (vormals Mission Control 2.0)
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
@@ -6,7 +6,13 @@ Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `RE
## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
- **System:** Bosgame M5 (AMD Strix Halo APU)
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
@@ -18,11 +24,22 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern.
Zweistufig seit 24.09.2026: Stufe 1 holt den Stand und startet die NEUE Fassung des Skripts,
Stufe 2 prüft (pruefen.sh), spielt Units/Cron-Skripte/Skills aus, startet neu und prüft nach.
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Agentic IDE & Vibe Coding
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
- Der Agent in OpenChamber nutzt die Modelle der Box über `http://192.168.178.151:9001/v1` (Provider `aibox`, nur Rollen-Aliase; Vorlage der PC-Config: `deploy/opencode-config/`).
- **Prüftor:** `bash deploy/pruefen.sh` (Shell-/Python-Syntax, ruff, Importe, pytest) muss vor jedem Push grün sein.
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand.
## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
@@ -33,9 +50,17 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- **Gate vor Commit:** `bash deploy/pruefen.sh` (enthält `py_compile`, `ruff check .` und die Tests).
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
@@ -44,5 +69,6 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
Remote = `ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git` (SSH, Push aus
Git-Bash geht). Bei Aussetzern **Push mit Retry**. Neue Repos per API anlegen. Kein GitHub.
Ungemergtes, das weg soll, erst als Tag `archiv/<name>` sichern, dann löschen.
+72 -54
View File
@@ -1,72 +1,90 @@
# Mission Control 2.0
# Homelab Orchestrator
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren** —
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
„Werkstatt"), nicht mehr manuelle Releases.
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über
Vulkan): hält sie aktuell, passt auf sie auf und sucht bessere Modelle. Live seit 23.09.2026.
- **Homelab** soll den Proxmox-PC mit seinen Containern und Arcane (Docker) betreuen: offene Updates zeigen, per
Knopf einspielen, danach die Erreichbarkeit prüfen. In Arbeit ([Fahrplan](docs/wissen/OFFENE-FAEDEN.md)).
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem
Proxmox-PC (Rolle `homelab`); beide prüfen sich gegenseitig. Repo, Dienste und Dateien tragen noch den alten Namen
„Mission Control 2" (`mission-control-v2`, `mission-control-2`, `mc2-*`). Alles läuft lokal, kein Cloud-Modell im
Datenpfad.
## Status: **live + eingefroren (MC2 Final)**
## Oberfläche
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
ohne Betreuung monatelang weiter):
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
Branch → Gate → Deploy selbst).
| Seite | Inhalt |
|---|---|
| **Start** | Warnlampen, Instrumente (Speicher, Temperatur, Platte, Laufzeit), Checkliste mit den Hinweisen des Wächters und ihren Knöpfen, Flugplan (was lief, was kommt), Radar-Kasten |
| **Updates** | Bausteine Betriebssystem, Motor (llama.cpp), llama-swap und Hermes; Verlauf der Update-Läufe; Sicherungen |
| **Modelle** | Speicher, Rollen Hirn und Coder, wer die Modelle nutzt, Modell-Radar, Aufräumen, Modelle selbst suchen |
| oben rechts bzw. „Mehr" | Hermes-Dashboard, Dienste und Protokolle, Einstellungen |
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
Anleitung für den Alltag: [docs/BEDIENUNG.md](docs/BEDIENUNG.md).
## Was von allein läuft
- **Wächter** (jede Minute): Dienste, Timer, Hermes-Jobs, Kern, Platte, festgehaltene Updates und, sobald
eingerichtet, die Partner-Instanz. Abgestürzte Dienste startet er selbst neu (höchstens 2× je Stunde), Rotes meldet
er per Telegram.
- **Updates** sonntags 04:30: llama-swap → Motor → Hermes, danach Prüfung; rot → zurück und festhalten. Die Box
startet nur sonntags zwischen 04:00 und 06:59 neu.
- **Modell-Radar** nachts 00:30–02:30: sucht Kandidaten für Hirn und Coder und testet höchstens einen pro Woche;
übernommen wird nur per Knopf.
- **Sicherung** täglich gegen 03:30, 14 Stück, Kopie auf dem Proxmox-Host.
- **Meldungen** gehen an Telegram und in Lucys Briefkasten; nachts gesammelt, um 07:00 als eine Morgenmeldung,
Dringendes sofort.
Alle Zeiten: [docs/wissen/STACK.md](docs/wissen/STACK.md), Abschnitt „Automatik".
## Dienste und Ports (KI-Box)
| Port | Unit | Aufgabe |
|---|---|---|
| `9001` | `mission-control-2` | Oberfläche, `/api`, `/v1` für Lucy und OpenChamber, Hermes-Dashboard unter `/hermes-ui/` |
| `9010` (nur lokal) | `mc2-gateway` | `/v1`-Datenpfad: `model: auto`, Bild-Weiche |
| – | `mc2-steward` | Wächter und Re-Warm |
| `8080` | `llama-swap` (System-Dienst) | Modell-Router, startet je Modell einen `llama-server` |
| `8642` | `hermes-gateway` | Hermes Agent „Lucy", Telegram |
| `9119` | `hermes-builtin-ui` | Hermes-Dashboard |
| `8021` (nur lokal) | `lucy-stimme` | Lucys Stimme |
| `8650` (nur lokal) | `voice-service` | Spracherkennung (schläft seit 24.09.) |
| `7682` (nur lokal) | `box-console` | Web-Konsole (schläft seit 24.09.) |
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien
liegen in [`deploy/`](deploy/).
## Entwickeln
**Backend:**
```bash
# Backend lokal auf :9000 (Windows)
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt -r requirements-dev.txt
.venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Vite leitet /api an :9000 weiter; MC_API_TARGET=http://192.168.178.151:9001 zeigt auf die Box)
cd frontend && npm ci && npm run dev
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`);
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue
`frontend/dist` mitcommitten, denn die Box baut kein Frontend. Gearbeitet wird auf einem Branch; `main` liegt auf
Gitea (`ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git`), danach auf der Box
`bash ~/mission-control-v2/deploy/deploy.sh`. Regeln für Agenten: [AGENTS.md](AGENTS.md).
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
## Doku
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
| Dokument | Für | Inhalt |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
| `MC_PORT` | `9000` | MC-Backend-Port |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln |
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten |
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall |
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand |
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen |
| [docs/wissen/](docs/wissen/) | Agenten | Stand, Verdikte, Fallen, Arbeitsweise, offene Fäden |
-50
View File
@@ -1,50 +0,0 @@
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
## Nordstern & Randbedingungen (WICHTIG für den Review)
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
+109 -67
View File
@@ -1,47 +1,37 @@
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen.
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
homelab — der Proxmox-PC: der Homelab-Teil
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
/api/partner/… durch.
"""
import asyncio
import logging
import os
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable
from typing import Any
import httpx
from fastapi import FastAPI
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from kern.einstellungen import einstellungen
from routers import health, partner
from services.herkunft import erlaubt
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import (
agent,
auftragsbuch,
chronik,
connect,
console,
gateway_proxy,
health,
hermes_ui,
ideen,
maintenance,
memory,
models,
routing,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import memory as memory_svc
from services import reminders, sentry, warmer
ROLLE = einstellungen().rolle
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)).
@@ -52,10 +42,11 @@ logging.basicConfig(
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
def _box_hintergrund() -> list[asyncio.Task[Any]]:
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
from services import reminders, warmer
tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
@@ -63,16 +54,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL,
)
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL,
memory_svc.AUTO_DEDUPE_THRESHOLD,
)
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
if os.environ.get("MC_PROBELAUF", "") != "1":
tasks.append(asyncio.create_task(reminders.reminders_loop()))
else:
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
return tasks
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
if os.environ.get("MC_PROBELAUF", "") != "1":
from services import jobengine
await asyncio.to_thread(jobengine.wiederaufnehmen)
tasks = _box_hintergrund() if ROLLE == "box" else []
if ROLLE == "homelab":
from services.homelab import updates
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient(
@@ -87,7 +89,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware(
@@ -98,6 +100,15 @@ app.add_middleware(
)
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
@app.middleware("http")
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
return await call_next(request)
@app.middleware("http")
async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
@@ -108,32 +119,59 @@ async def no_cache(
return resp
def _box_router(app: FastAPI) -> None:
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
from routers import (
boxwart,
einstellungen,
events,
gateway_proxy,
hermes_ui,
maintenance,
messwerte,
models,
radar,
routing,
system,
voice,
zeitmaschine,
)
from routers import reminders as reminders_router
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
app.include_router(messwerte.router) # Messwerte mit Verlauf (1h, 24h, 7d; schreibt der Steward)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
if V1_UPSTREAM:
from routers import gateway_forward
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
else:
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
app.include_router(hermes_ui.router)
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(
voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
if ROLLE == "box":
_box_router(app)
else:
from routers import einstellungen, homelab
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
@@ -144,6 +182,10 @@ if FRONTEND_DIST.exists():
@app.get("/{full_path:path}")
def spa(full_path: str):
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try:
+26 -31
View File
@@ -9,8 +9,6 @@ Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
@@ -19,20 +17,18 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# 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")))
# 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("/")
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
# 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 —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
"-c {ctx} -ngl 999 -fa on --load-mode none"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
@@ -61,13 +57,18 @@ HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
@@ -92,32 +93,30 @@ HERMES_API_KEY = (
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
# passen. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0`, weil Hermes seine Anmeldung auf
# Loopback abschaltet (dann wäre /hermes-ui/ ohne Login offen). Dass sie trotzdem nicht im LAN hängt,
# liegt allein an ufw (9119 ist nicht freigegeben — 27.08. und 25.09.2026 vom PC aus gegengeprüft).
# Wer die Firewall anfasst, öffnet damit auch diese Oberfläche. Das feste Session-Token der früheren
# Desktop-Anbindung ist seit 25.09.2026 weg. MC2 erreicht sie über Loopback und reicht sie
# same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
@@ -125,7 +124,3 @@ FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resol
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
+63
View File
@@ -0,0 +1,63 @@
"""
MC2-Gateway — der /v1-Datenpfad als EIGENER Prozess (UMBAU v3, P1).
Befund der Live-Inspektion 15.07.2026: Jeder LLM-Aufruf der Box (Lucys Haupt-Hirn,
Nacht-Crons, Worker-Delegation, Vision, Decomposer/Specifier/Curator) lief durch den
Steuerpult-Prozess auf :9001 — den am häufigsten neu gestarteten Dienst des Stacks.
Dieser Einstieg hebt denselben Gateway-Router (routers/gateway_proxy.py) UNVERÄNDERT
in einen bewusst winzigen, langweiligen Prozess: Unit mc2-gateway.service, Loopback
:9010, Restart=always. Das Steuerpult darf beliebig neu starten — die Wirbelsäule steht.
Bewusst NICHT hier: weitere Router, Hintergrund-Loops, CORS, Frontend-Auslieferung.
LAN-Clients (IDE-Lane) erreichen /v1 weiter über MC2 :9001, das roh hierher
durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
"""
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import httpx
from config import LLAMA_SWAP_URL, VERSION
from fastapi import FastAPI, Request
from routers import gateway_proxy
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Gleiche Client-Parameter wie zuvor in app.py: Keep-Alive/Pooling statt neuer
# Client pro Anfrage (Sockets/TIME_WAIT unter parallelen Agent-Strömen).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
)
log.info("mc2-gateway bereit (Engine: %s)", LLAMA_SWAP_URL)
try:
yield
finally:
await app.state.gw_client.aclose()
app = FastAPI(title="MC2 Gateway", version=VERSION, lifespan=lifespan)
app.include_router(gateway_proxy.router)
@app.get("/gw/health")
async def health(request: Request):
"""Eigener Health-Pfad (nicht /api/health — das gehört dem Steuerpult):
beweist Prozess UND Engine-Erreichbarkeit, für deploy.sh/stack-postcheck.sh."""
engine = False
try:
r = await request.app.state.gw_client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=5.0)
engine = r.status_code == 200
except httpx.HTTPError:
pass
return {"status": "ok", "service": "mc2-gateway", "version": VERSION,
"engine_reachable": engine}
+3
View File
@@ -0,0 +1,3 @@
"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen —
Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere
Module ziehen nach und nach um."""
+40
View File
@@ -0,0 +1,40 @@
"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators.
Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“):
box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy)
homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane)
Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und
jede Instanz prüft, ob die andere noch antwortet.
"""
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
ROLLEN = ("box", "homelab")
@dataclass(frozen=True)
class Einstellungen:
rolle: str # "box" | "homelab"
instanz: str # Anzeigename dieser Instanz
daten_dir: Path # Zustandsdateien dieser Instanz
partner_url: str # Basis-URL der anderen Instanz, leer = keine
partner_name: str # Anzeigename der anderen Instanz
@lru_cache(maxsize=1)
def einstellungen() -> Einstellungen:
rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box"
if rolle not in ROLLEN:
raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.")
box = rolle == "box"
standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2"
return Einstellungen(
rolle=rolle,
instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"),
daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)),
partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"),
partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"),
)
+45
View File
@@ -0,0 +1,45 @@
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
"""
import os
import re
import threading
import time
import httpx
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def github_json(pfad: str, timeout: float = 8) -> object:
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
with _lock:
_cache[pfad] = (jetzt, daten)
return daten
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
def neueste_version(repo: str) -> dict | None:
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
daten = github_json(f"repos/{repo}/releases/latest")
if not isinstance(daten, dict) or not daten.get("tag_name"):
return None
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
return {"tag": roh, "tag_roh": tag, "version": roh.lstrip("vV"),
"datum": str(daten.get("published_at") or "")[:10] or None}
+301
View File
@@ -0,0 +1,301 @@
"""Messreihen: Minutenwerte mit Verlauf für beide Bereiche (Monitoring, seit 24.09.2026).
Ablage: je Quelle und Tag eine Datei, darin eine JSON-Zeile je Minute:
<Datenordner>/mc2-messwerte/<quelle>-JJJJ-MM-TT.jsonl (Tag nach Berliner Zeit, kern/zeit.py)
{"t":1790000000,"cpu":12.5,"ram":41.2,…} (t = Unix-Sekunden der Messung, null = keine Messung)
Quellen: „box“ (die KI-Box; schreibt ihr Steward) sowie „pve“, „ct-<vmid>“ und „vm-<vmid>“ (das Homelab; schreibt der
Homelab-Teil, was der Ausführer jede Minute schickt). Dateien, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt, löscht
das erste Schreiben eines neuen Tages.
Schreiben: eine Zeile anhängen, unter einer Thread-Sperre und (Linux) flock auf der Datei. Fehlt am Dateiende der
Zeilenumbruch (Absturz mitten im Schreiben), beginnt die neue Zeile auf einer eigenen statt an die kaputte anzuwachsen.
Lesen (lesen()): 1h in 60-s-Schritten, 24h als 5-min-Mittel, 7d als 30-min-Mittel. Die Schritte liegen auf vollen
Vielfachen ihrer Länge, der letzte ist der laufende. Ein Schritt ohne Messung bleibt null — Lücken werden nicht
aufgefüllt. Kaputte Zeilen werden übersprungen. Die Summen je Tagesdatei werden für 5- und 30-min-Schritte gemerkt,
solange sich die Datei nicht ändert (vergangene Tage liest so nur die erste Anfrage).
"""
import json
import logging
import math
import os
import re
import threading
import time
from collections.abc import Iterable, Iterator
from datetime import datetime, timedelta
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
log = logging.getLogger(__name__)
ORDNER_NAME = "mc2-messwerte"
AUFBEWAHREN_TAGE = 8
# Zeitraum → (Länge, Schritt) in Sekunden.
ZEITRAEUME: dict[str, tuple[int, int]] = {"1h": (3600, 60), "24h": (24 * 3600, 300), "7d": (7 * 24 * 3600, 1800)}
MERKEN_AB_S = 300 # Summen je Datei merken; bei 60-s-Schritten (1440 je Tag) lohnt es den Speicher nicht
MERKEN_MAX = 512
LUECKE_MAX_S = 300 # Zählerstände, die weiter auseinanderliegen, ergeben keine Rate mehr
RATE_MAX = 5e9 # mehr als 5 GB/s ist kein Verkehr, sondern ein Zählersprung
_QUELLE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
_DATEI = re.compile(r"(?P<quelle>[a-z0-9][a-z0-9-]*)-(?P<tag>\d{4}-\d{2}-\d{2})\.jsonl")
_lock = threading.Lock()
_aufgeraeumt: dict[str, str] = {} # Ordner → Tag des letzten Aufräumens (je Prozess)
_merk_lock = threading.Lock()
_merk: dict[tuple[str, int], tuple[tuple[int, int], dict[int, dict[str, list[float]]]]] = {}
def ordner() -> Path:
return einstellungen().daten_dir / ORDNER_NAME
def ist_zahl(wert: object) -> bool:
"""Eine endliche Zahl. bool zählt nicht, NaN und unendlich auch nicht."""
return isinstance(wert, (int, float)) and not isinstance(wert, bool) and math.isfinite(wert)
def zeitraum_pruefen(zeitraum: str) -> tuple[int, int]:
"""(Länge, Schritt) in Sekunden; ValueError bei einem unbekannten Zeitraum."""
if zeitraum not in ZEITRAEUME:
raise ValueError(f"Den Zeitraum „{zeitraum}“ gibt es nicht; möglich sind {', '.join(ZEITRAEUME)}.")
return ZEITRAEUME[zeitraum]
def _datum(t: float):
return datetime.fromtimestamp(t, LOCAL_TZ).date()
def _tage(von: float, bis: float) -> list[str]:
"""Alle Tage (Berliner Zeit), deren Dateien Messungen aus [von, bis) enthalten können."""
tag, letzter = _datum(von), _datum(max(von, bis - 1))
tage = []
while tag <= letzter:
tage.append(tag.isoformat())
tag += timedelta(days=1)
return tage
def _datei(quelle: str, tag: str) -> Path:
if not _QUELLE.fullmatch(quelle):
raise ValueError(f"Ungültiger Name einer Messquelle: {quelle!r}")
return ordner() / f"{quelle}-{tag}.jsonl"
# --- Schreiben -----------------------------------------------------------------------------------
def _wert(wert: object) -> int | float | None:
if not ist_zahl(wert):
return None
return wert if isinstance(wert, int) else round(float(wert), 3)
def schreiben(quelle: str, werte: dict[str, object], t: float | None = None) -> None:
"""Einen Messpunkt anhängen. werte: Name → Zahl oder None (nicht gemessen)."""
t = time.time() if t is None else float(t)
punkt: dict[str, object] = {"t": int(t)}
punkt.update({name: _wert(wert) for name, wert in werte.items() if name != "t"})
zeile = (json.dumps(punkt, separators=(",", ":")) + "\n").encode()
pfad = _datei(quelle, _datum(t).isoformat())
with _lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a+b") as f:
if fcntl is not None:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.seek(0, os.SEEK_END)
if f.tell() > 0:
f.seek(-1, os.SEEK_END)
if f.read(1) != b"\n":
zeile = b"\n" + zeile # die letzte Zeile brach ab: nicht an sie anhängen
f.write(zeile)
f.flush()
finally:
if fcntl is not None:
fcntl.flock(f, fcntl.LOCK_UN)
_einmal_am_tag_aufraeumen(t)
def _einmal_am_tag_aufraeumen(t: float) -> None:
schluessel, tag = str(ordner()), _datum(t).isoformat()
if _aufgeraeumt.get(schluessel) == tag:
return
_aufgeraeumt[schluessel] = tag
try:
aufraeumen(t)
except OSError:
log.warning("messreihen: Aufräumen in %s ging nicht", schluessel, exc_info=True)
def aufraeumen(jetzt: float | None = None) -> int:
"""Tagesdateien löschen, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt. Andere Dateien bleiben. Rückgabe: wie
viele gelöscht wurden."""
grenze = (_datum(time.time() if jetzt is None else jetzt) - timedelta(days=AUFBEWAHREN_TAGE)).isoformat()
try:
namen = os.listdir(ordner())
except FileNotFoundError:
return 0
geloescht = 0
for name in namen:
treffer = _DATEI.fullmatch(name)
if treffer and treffer["tag"] < grenze:
try:
(ordner() / name).unlink()
geloescht += 1
except FileNotFoundError:
pass # ein anderer Prozess war schneller
return geloescht
# --- Lesen ---------------------------------------------------------------------------------------
def _punkt(zeile: bytes) -> dict | None:
"""Eine Zeile als Messpunkt; None bei allem, was keiner ist (kaputt, leer, ohne Zeit)."""
zeile = zeile.strip()
if not zeile:
return None
try:
punkt = json.loads(zeile)
except ValueError: # auch UnicodeDecodeError
return None
return punkt if isinstance(punkt, dict) and ist_zahl(punkt.get("t")) else None
def _punkte_der_datei(pfad: Path) -> Iterator[dict]:
try:
roh = pfad.read_bytes()
except OSError:
return
for zeile in roh.split(b"\n"):
if (punkt := _punkt(zeile)) is not None:
yield punkt
def _eimer(pfad: Path, schritt: int, ab: float) -> dict[int, dict[str, list[float]]]:
"""Summe und Anzahl je Schritt und Wert für eine Tagesdatei. Gemerkt (ab 5-min-Schritten) wird die ganze Datei;
sonst zählen erst Messungen ab `ab`. Das Ergebnis nicht verändern: es kann gemerkt sein."""
try:
stand = pfad.stat()
except OSError:
return {}
merken = schritt >= MERKEN_AB_S
schluessel, kennung = (str(pfad), schritt), (stand.st_mtime_ns, stand.st_size)
if merken:
with _merk_lock:
gemerkt = _merk.get(schluessel)
if gemerkt and gemerkt[0] == kennung:
return gemerkt[1]
eimer: dict[int, dict[str, list[float]]] = {}
for punkt in _punkte_der_datei(pfad):
if not merken and punkt["t"] < ab:
continue
ziel = eimer.setdefault(int(punkt["t"]) // schritt * schritt, {})
for name, wert in punkt.items():
if name != "t" and ist_zahl(wert):
summe = ziel.setdefault(name, [0.0, 0])
summe[0] += wert
summe[1] += 1
if merken:
with _merk_lock:
if len(_merk) >= MERKEN_MAX:
_merk.clear()
_merk[schluessel] = (kennung, eimer)
return eimer
def _mittel(summe: list[float] | None) -> int | float | None:
"""Mittel eines Schritts: ab 100 ganzzahlig, darunter eine Nachkommastelle; ohne Messung None."""
if not summe or not summe[1]:
return None
wert = summe[0] / summe[1]
return round(wert) if abs(wert) >= 100 else round(wert, 1)
def lesen(quelle: str, zeitraum: str, namen: Iterable[str], jetzt: float | None = None) -> dict:
"""Die Reihen einer Quelle: {"zeitraum", "schritt_s", "reihen": {name: [[t, wert], …]}}. t = Beginn des Schritts
(Unix-Sekunden), wert = Mittel der Messungen darin oder None. Jede Reihe hat Länge/Schritt Einträge (60, 288, 336)."""
dauer, schritt = zeitraum_pruefen(zeitraum)
jetzt = time.time() if jetzt is None else jetzt
ende = (int(jetzt) // schritt + 1) * schritt # Ende des laufenden Schritts
beginn = ende - dauer
summen: dict[int, dict[str, list[float]]] = {}
for tag in _tage(beginn, ende):
for t0, werte in _eimer(_datei(quelle, tag), schritt, beginn).items():
if not beginn <= t0 < ende:
continue
ziel = summen.setdefault(t0, {})
for name, (summe, anzahl) in werte.items():
gesamt = ziel.setdefault(name, [0.0, 0])
gesamt[0] += summe
gesamt[1] += anzahl
raster = range(beginn, ende, schritt)
return {"zeitraum": zeitraum, "schritt_s": schritt,
"reihen": {name: [[t0, _mittel(summen.get(t0, {}).get(name))] for t0 in raster] for name in namen}}
def _ende_der_datei(pfad: Path, groesse: int = 16384) -> list[bytes]:
"""Die letzten Zeilen einer Datei, ohne sie ganz zu lesen."""
try:
with open(pfad, "rb") as f:
f.seek(0, os.SEEK_END)
laenge = f.tell()
f.seek(max(0, laenge - groesse))
roh = f.read()
except OSError:
return []
zeilen = roh.split(b"\n")
return zeilen[1:] if laenge > groesse else zeilen # die erste Zeile ist dann angeschnitten
def letzter_punkt(quelle: str, jetzt: float | None = None, max_alter_s: float | None = None) -> dict | None:
"""Die jüngste Messung einer Quelle (heute, sonst gestern). None, wenn es keine gibt oder sie älter ist als
max_alter_s — ein alter Wert ist kein aktueller."""
jetzt = time.time() if jetzt is None else jetzt
heute = _datum(jetzt)
for tag in (heute, heute - timedelta(days=1)):
for zeile in reversed(_ende_der_datei(_datei(quelle, tag.isoformat()))):
if (punkt := _punkt(zeile)) is not None:
if max_alter_s is not None and jetzt - punkt["t"] > max_alter_s:
return None
return punkt
return None
def punkte(quelle: str, von: float, bis: float) -> list[dict]:
"""Alle Messungen einer Quelle mit von ≤ t < bis, nach Zeit geordnet (für Prüfungen des Wächters)."""
gefunden = [p for tag in _tage(von, bis) for p in _punkte_der_datei(_datei(quelle, tag)) if von <= p["t"] < bis]
return sorted(gefunden, key=lambda p: p["t"])
def quellen(von: float, bis: float) -> list[str]:
"""Die Quellen, von denen es für [von, bis) eine Tagesdatei gibt."""
tage = set(_tage(von, bis))
try:
namen = os.listdir(ordner())
except OSError:
return []
return sorted({t["quelle"] for name in namen if (t := _DATEI.fullmatch(name)) and t["tag"] in tage})
# --- Zähler → Raten --------------------------------------------------------------------------------
def rate(alt: object, neu: object, dt: float) -> float | None:
"""Zuwachs je Sekunde zwischen zwei Ständen eines kumulativen Zählers (Bytes, Tokens). None, wenn ein Stand fehlt,
die Stände mehr als LUECKE_MAX_S auseinanderliegen, der Zähler kleiner wurde (Neustart, Überlauf) oder der Sprung
unmöglich groß ist — eine erfundene Rate wäre schlimmer als eine Lücke."""
if not (ist_zahl(alt) and ist_zahl(neu)) or not 0 < dt <= LUECKE_MAX_S:
return None
zuwachs = float(neu) - float(alt)
if zuwachs < 0:
return None
wert = zuwachs / dt
return None if wert > RATE_MAX else wert
+53
View File
@@ -0,0 +1,53 @@
"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche).
Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede
Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter
und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch,
damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet.
"""
import threading
import time
import httpx
from kern.einstellungen import einstellungen
ZEITLIMIT_S = 5.0
CACHE_S = 15.0
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
def _kurz(exc: Exception) -> str:
if isinstance(exc, httpx.TimeoutException):
return "antwortet nicht rechtzeitig"
if isinstance(exc, httpx.ConnectError):
return "nicht erreichbar"
if isinstance(exc, httpx.HTTPStatusError):
return f"antwortet mit HTTP {exc.response.status_code}"
return f"{exc.__class__.__name__}: {exc}"[:160]
def status(frisch: bool = False) -> dict:
"""Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}."""
e = einstellungen()
if not e.partner_url:
return {"eingerichtet": False}
with _lock:
if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S:
return dict(_cache["daten"])
daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url,
"erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None}
try:
r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S)
r.raise_for_status()
h = r.json()
daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version"))
except Exception as exc:
daten["fehler"] = _kurz(exc)
daten["geprueft"] = time.time()
with _lock:
_cache.update(ts=time.time(), daten=daten)
return dict(daten)
+158
View File
@@ -0,0 +1,158 @@
"""Füllstands-Vorhersage „voll in etwa N Tagen“ (Ausbauplan Welle 1, seit 25.09.2026).
Die Schwellen bei 80 und 90 % melden sich erst, wenn es schon eng ist. Die Vorhersage schaut auf das Tempo: Wächst
eine Belegung weiter wie zuletzt, wann ist sie voll? Gelb unter GELB_TAGE, rot unter ROT_TAGE Tagen — so bleibt Zeit,
Platz zu schaffen, bevor etwas stillsteht.
Grundlage sind die 30-min-Mittel der letzten sieben Tage aus kern/messreihen.py (Belegung in %). Daraus werden
Tagesmittel über 24-Stunden-Fenster (das jüngste endet jetzt); ein Fenster zählt ab sechs Stunden mit Messungen.
Die Rate in Prozentpunkten am Tag:
• Normalfall: die Steigung der Ausgleichsgeraden durch die Tagesmittel der Woche.
• Das Wachstum stockt — der letzte Tag wuchs um weniger als ein Viertel der Wochenrate, etwa nach einem großen
Download, der einmal kam: Dann gilt der letzte Tag (mindestens 0). Ein einzelner Sprung hält so keinen Alarm.
• Es wird schneller — die letzten beiden Tage wuchsen beide stärker als die Woche, und in der Woche wurde nichts
freigeräumt: Dann gilt ihr Mittel. Ein Protokoll, das gerade vollläuft, wartet so nicht eine Woche.
Unter MIN_RATE gilt die Belegung als gleichbleibend. Ohne die beiden jüngsten Fenster und ein drittes (also rund zwei
Tage Verlauf) oder ohne Messung in den letzten zwei Stunden gibt es keine Vorhersage.
„Voll“ ist nicht überall 100 %: Proxmox rechnet bei Containern und beim Host die für root reservierten Blöcke von
ext4 (5 %) als belegt — dort ist für Dienste schon bei rund 95 % Schluss (AdGuard am 24.09.2026). Das gibt der
Aufrufer mit (voll_bei).
"""
import math
import threading
import time
from kern import messreihen
TAG_S = 86400
FENSTER = 7 # Tagesfenster in der Woche
MIN_WERTE = 12 # 30-min-Mittel je Fenster: sechs Stunden mit Messungen
FRISCH_S = 2 * 3600 # so alt darf die jüngste Messung höchstens sein
MIN_RATE = 0.05 # Prozentpunkte am Tag; darunter „gleichbleibend“
STOCKT = 0.25 # der letzte Tag wuchs um weniger als dieser Anteil der Wochenrate
FREIGERAEUMT = -0.5 # ein Tag, der um mehr als so viele Prozentpunkte sank, hat freigeräumt
GELB_TAGE, ROT_TAGE = 14, 3
MERKEN_S = 600 # je Reihe höchstens alle zehn Minuten neu rechnen (der Wächter fragt jede Minute)
VOLL_EXT4 = 95.0 # Container und Proxmox-Host: siehe oben
_lock = threading.Lock()
_merk: dict[tuple[str, str, float], tuple[float, dict]] = {}
def tagesmittel(reihe: list, jetzt: float) -> dict[int, float]:
"""Fenster k (0 = die letzten 24 Stunden, 1 = die 24 davor …) → Mittel der Werte darin. Nur Fenster mit
mindestens MIN_WERTE Werten. reihe: [[t, wert], …] wie messreihen.lesen() sie liefert."""
summen: dict[int, list[float]] = {}
for t, wert in reihe:
if not messreihen.ist_zahl(wert):
continue
k = int((jetzt - t) // TAG_S)
if 0 <= k < FENSTER:
summe = summen.setdefault(k, [0.0, 0])
summe[0] += wert
summe[1] += 1
return {k: s[0] / s[1] for k, s in summen.items() if s[1] >= MIN_WERTE}
def _steigung(punkte: list[tuple[float, float]]) -> float:
"""Steigung der Ausgleichsgeraden (kleinste Quadrate)."""
mx = sum(x for x, _ in punkte) / len(punkte)
my = sum(y for _, y in punkte) / len(punkte)
sxx = sum((x - mx) ** 2 for x, _ in punkte)
return sum((x - mx) * (y - my) for x, y in punkte) / sxx if sxx else 0.0
def vorhersage(reihe: list, jetzt: float, voll_bei: float = 100.0) -> dict:
"""{"tage": Tage bis voll oder None, "rate": Prozentpunkte am Tag oder None, "anteil": jüngstes Mittel in % oder
None, "grund": None | "verlauf" (zu wenig Verlauf) | "gleich" (wächst nicht)}."""
werte = [(t, w) for t, w in reihe if messreihen.ist_zahl(w)]
if not werte or jetzt - werte[-1][0] > FRISCH_S:
return {"tage": None, "rate": None, "anteil": None, "grund": "verlauf"}
anteil = float(werte[-1][1])
mittel = tagesmittel(werte, jetzt)
if 0 not in mittel or 1 not in mittel or len(mittel) < 3:
return {"tage": None, "rate": None, "anteil": anteil, "grund": "verlauf"}
woche = _steigung([(-k, m) for k, m in mittel.items()])
zuletzt = mittel[0] - mittel[1]
davor = mittel[1] - mittel[2] if 2 in mittel else None
schritte = [mittel[k] - mittel[k + 1] for k in range(FENSTER - 1) if k in mittel and k + 1 in mittel]
rate = woche
if zuletzt < STOCKT * woche:
rate = max(zuletzt, 0.0)
elif davor is not None and zuletzt > woche and davor > woche and min(schritte) > FREIGERAEUMT:
rate = (zuletzt + davor) / 2
if rate < MIN_RATE:
return {"tage": None, "rate": round(rate, 2), "anteil": anteil, "grund": "gleich"}
return {"tage": round(max(0.0, (voll_bei - anteil) / rate), 1), "rate": round(rate, 2), "anteil": anteil,
"grund": None}
def aus_messreihe(quelle: str, name: str, voll_bei: float = 100.0, jetzt: float | None = None) -> dict:
"""Die Vorhersage für eine Reihe aus kern/messreihen.py. Ohne jetzt (Betrieb) je Reihe höchstens alle MERKEN_S
neu gerechnet; mit jetzt (Tests) immer frisch."""
betrieb = jetzt is None
jetzt = time.time() if jetzt is None else jetzt
schluessel = (quelle, name, voll_bei)
if betrieb:
with _lock:
gemerkt = _merk.get(schluessel)
if gemerkt and 0 <= jetzt - gemerkt[0] < MERKEN_S:
return gemerkt[1]
ergebnis = vorhersage(messreihen.lesen(quelle, "7d", [name], jetzt)["reihen"][name], jetzt, voll_bei)
if betrieb:
with _lock:
_merk[schluessel] = (jetzt, ergebnis)
return ergebnis
def stufe(v: dict | None) -> str:
"""„rot“ unter ROT_TAGE, „gelb“ unter GELB_TAGE Tagen bis voll, sonst „ok“."""
tage = (v or {}).get("tage")
if tage is None:
return "ok"
return "rot" if tage < ROT_TAGE else "gelb" if tage < GELB_TAGE else "ok"
def in_tagen(tage: float) -> str:
"""„in weniger als einem Tag“, „in etwa 9 Tagen“, „in etwa 5 Wochen“ … — für Hinweise."""
if tage < 1:
return "in weniger als einem Tag"
if tage < 1.5:
return "in etwa einem Tag"
if tage < 21:
return f"in etwa {round(tage)} Tagen"
if tage < 90:
return f"in etwa {round(tage / 7)} Wochen"
return f"in etwa {round(tage / 30)} Monaten" if tage < 365 else "frühestens in einem Jahr"
def tempo(v: dict) -> str:
"""„Die Belegung wächst um etwa 1,2 % am Tag (jetzt 78 %).“"""
rate = f"{v['rate']:.1f}".replace(".", ",")
return f"Die Belegung wächst um etwa {rate} % am Tag (jetzt {round(v['anteil'])} %)."
def bis_voll(v: dict | None) -> float:
"""Tage bis voll; unendlich, wenn es keine Vorhersage gibt."""
return v["tage"] if v and v.get("tage") is not None else math.inf
IM_TEXT_TAGE = 60 # weiter weg sagt ein Hinweis zum Füllstand nicht mehr dazu, wann es voll wäre
def dazu(v: dict | None) -> str:
"""„ Bei diesem Tempo voll in etwa 9 Tagen.“ — der Zusatz für Hinweise, die schon der Füllstand auslöst."""
tage = bis_voll(v)
return f" Bei diesem Tempo voll {in_tagen(tage)}." if tage < IM_TEXT_TAGE else ""
RANG = {"ok": 0, "gelb": 1, "rot": 2}
def schlimmer(stufe_jetzt: str | None, v: dict | None) -> tuple[str, bool]:
"""Die ernstere Stufe aus Füllstand (None/„ok“ = unauffällig) und Vorhersage — und ob die Vorhersage sie bestimmt
(dann gehört „voll in etwa N Tagen“ in den Titel)."""
jetzt, kuenftig = stufe_jetzt or "ok", stufe(v)
return (kuenftig, True) if RANG[kuenftig] > RANG.get(jetzt, 0) else (jetzt, False)
+7
View File
@@ -0,0 +1,7 @@
"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel:
nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen."""
import os
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
+63
View File
@@ -0,0 +1,63 @@
"""Ziele und Bausteine (Phase 2, 24.09.2026): ein Modell für alles, was Updates bekommt.
Ein Ziel ist ein Gerät: die KI-Box, der Proxmox-Host, ein Container, die Arcane-VM. Es besteht aus
Bausteinen (Betriebssystem, Motor, die App im Container, Docker-Images …). Jeder Baustein sagt, wie
sein Stand ist — neu, aktuell, unbekannt (mit Grund) oder festgehalten — und wie man ihn einspielt.
Die KI-Box meldet ihre Bausteine über den Box-Adapter (services/box_updates.py), der Homelab-Teil
ab Phase 3 über Proxmox- und Arcane-Adapter. So wird aus beiden Instanzen EINE Liste offener Updates
(User-Entscheid 24.09.: eine Anlaufstelle für Updates im ganzen Homelab).
"""
from dataclasses import asdict, dataclass, field
from typing import Literal
Zustand = Literal["neu", "aktuell", "unbekannt", "festgehalten", "wird-geprueft"]
# Reihenfolge für die Anzeige: was Aufmerksamkeit braucht, zuerst.
_RANG = {"unbekannt": 0, "festgehalten": 1, "neu": 2, "wird-geprueft": 3, "aktuell": 4}
@dataclass
class Aktion:
"""Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Ziel pflegt."""
methode: str # "POST"
pfad: str # "/api/maintenance/engine-update"
frage: str # Rückfrage vor dem Klick
label: str = "Jetzt updaten"
@dataclass
class BausteinStand:
id: str # "engine"
name: str # "Motor (llama.cpp)"
zustand: Zustand
installiert: str | None = None
verfuegbar: str | None = None
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
@dataclass
class ZielStand:
id: str # "ki-box"
name: str # "KI-Box"
art: str # "ki-box" | "proxmox-host" | "container" | "vm"
instanz: str # welche Instanz es pflegt: "box" | "homelab"
erreichbar: bool | None = None # None = nicht geprüft
bausteine: list[BausteinStand] = field(default_factory=list)
geprueft: float | None = None # Unix-Sekunden der letzten Prüfung
rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup"
rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
kennzahl: dict | None = None # eine Live-Zahl für die Kachel {"text", "zeit"?, "stufe"?} (seit 25.09.2026)
def als_dict(self) -> dict:
daten = asdict(self)
daten["bausteine"].sort(key=lambda b: _RANG.get(b["zustand"], 9))
zaehlung: dict[str, int] = {}
for b in self.bausteine:
zaehlung[b.zustand] = zaehlung.get(b.zustand, 0) + 1
daten["zaehlung"] = zaehlung
daten["offen"] = zaehlung.get("neu", 0)
return daten
-56
View File
@@ -1,56 +0,0 @@
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
+21
View File
@@ -2,6 +2,16 @@
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-07-03",
"models": [
{
"role": "debugger", "name": "Muse-Glimmer-30B", "repo": "unsloth/Muse-Glimmer-30B-GGUF",
"family": "muse", "generation": 1.0, "total_params_b": 30, "active_params_b": 30,
"moe": false, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": true
},
{
"role": "coder", "name": "Qwen3.8-27B", "repo": "unsloth/Qwen3.8-27B-GGUF",
"family": "qwen", "generation": 3.8, "total_params_b": 27, "active_params_b": 27,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": true
},
{
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
@@ -55,6 +65,17 @@
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
}
]
}
+181
View File
@@ -0,0 +1,181 @@
"""
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
Nachtlauf das Hirn braucht. Ablauf:
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
Mit --nur-suche (seit 24.09.2026) nur Schritt 1, jederzeit: So startet der Knopf „Jetzt suchen“ die Suche als
Auftrag (services/radar.suche_starten). Getestet wird dabei nichts.
"""
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from services import radar
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger("radar_lauf")
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
def _beim_beenden(signum, _frame) -> None:
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
raise SystemExit(f"Signal {signum}")
def _notbremse(frist: float) -> threading.Timer:
def ziehen() -> None:
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
try:
radar.notstopp()
finally:
os._exit(3)
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
bremse.daemon = True
bremse.start()
return bremse
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
def melde_bestanden(test: dict) -> None:
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
f"{str(test.get('zusammenfassung') or '')[:300]} "
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
try:
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
def main() -> int:
beginn = radar.jetzt()
if radar.suche_faellig(beginn):
try:
ergebnis = radar.suche()
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
except Exception:
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
else:
log.info("Radar: heute schon gesucht.")
if not radar.im_fenster(radar.jetzt()):
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
radar.FENSTER_ENDE)
return 0
frist = radar.fenster_ende(radar.jetzt()).timestamp()
signal.signal(signal.SIGTERM, _beim_beenden)
bremse = _notbremse(frist)
try:
if (nachgeholt := radar.nachurteilen(frist)):
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
if not plan.get("kandidat"):
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
return 0
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
test = radar.teste(plan["kandidat"], frist)
if test:
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
if test.get("ergebnis") == "bestanden":
melde_bestanden(test)
return 0
finally:
bremse.cancel()
def nur_suche() -> int:
"""„Jetzt suchen“ aus der Oberfläche: nur die Suche, kein Test. Die letzte Zeile sagt das Ergebnis in Worten —
die Auftragskarte zeigt sie. Code 1, wenn die Suche scheitert oder keine Quelle erreichbar war."""
# Das Protokoll des Auftrags zeigt die Oberfläche: ohne eine Zeile je Hugging-Face-Abruf (samt signierter
# Download-Adressen), nur die Schritte des Radars und das Ergebnis.
logging.getLogger("httpx").setLevel(logging.WARNING)
try:
ergebnis = radar.suche()
except Exception as exc:
log.exception("Radar: Suche fehlgeschlagen")
print(f"Die Suche ist gescheitert: {exc.__class__.__name__}: {exc}", flush=True)
return 1
neu = ergebnis.get("neu") or []
quellen = ergebnis.get("quellen") or {}
teile = [f"{len(neu)} neu ({', '.join(neu)})" if neu else "nichts Neues",
f"{ergebnis.get('wartend', 0)} warten auf den Nachttest"]
if not quellen.get("discover", True):
teile.append("Hugging Face war nicht erreichbar")
if not quellen.get("watchlist", True):
teile.append("die Merkliste war nicht lesbar")
print(f"Suche fertig: {', '.join(teile)}.", flush=True)
return 0 if any(quellen.values()) else 1
def melde_ergebnis(test: dict) -> None:
"""Nach „Jetzt testen“ kommt jedes Ergebnis als Meldung — der Commander hat den Test selbst angestoßen."""
if test.get("ergebnis") == "bestanden":
melde_bestanden(test)
return
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
text = f"Test von {k.get('name') or test.get('kandidat')}: {str(test.get('zusammenfassung') or test.get('ergebnis'))[:400]}"
try:
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
log.warning("Radar: Meldung zum Test ging nicht raus.", exc_info=True)
def test_auf_knopfdruck(kid: str) -> int:
"""„Jetzt testen“ (seit 25.09.2026, services/radar.test_starten): ein Kandidat, jetzt, mit eigener Frist von
TAGTEST_H Stunden. Dieselben Leitplanken wie nachts: nur Warm-Set neben dem Kandidaten, Test-Wächter, Notbremse.
Ob der Kandidat tagsüber überhaupt darf (Speicher mit Coder, Sperrzeit), hat test_starten geprüft; hier noch
einmal, falls zwischen Knopf und Start Zeit verging."""
k = radar.lade_stand()["kandidaten"].get(kid)
testbar, grund = radar.jetzt_testbar(k or {})
if not k or not testbar:
print(f"Kein Test: {grund or 'den Kandidaten gibt es nicht mehr'}.", flush=True)
return 1
frist = time.time() + radar.TAGTEST_H * 3600
signal.signal(signal.SIGTERM, _beim_beenden)
bremse = _notbremse(frist)
try:
test = radar.teste(kid, frist, auf_knopfdruck=True)
if test:
print(f"{k.get('name')}: {test.get('ergebnis')} – {test.get('zusammenfassung')}", flush=True)
melde_ergebnis(test)
return 0
finally:
bremse.cancel()
if __name__ == "__main__":
argumente = sys.argv[1:]
if "--nur-suche" in argumente:
sys.exit(nur_suche())
if "--test" in argumente and argumente.index("--test") + 1 < len(argumente):
sys.exit(test_auf_knopfdruck(argumente[argumente.index("--test") + 1]))
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
# Nur fürs Prüftor (deploy/pruefen.sh) — die Dienste selbst brauchen das nicht.
# Gepinnt am 24.09.2026 auf den Stand im venv der Box (der Container installiert diese Datei nicht).
pytest==9.1.1
+16 -9
View File
@@ -1,9 +1,16 @@
fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9
huggingface_hub>=0.27
mcp>=1.2.0
tzdata>=2024.1
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues.
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen.
fastapi>=0.139.0,<=0.141.1
python-multipart==0.0.32
uvicorn[standard]>=0.51.0,<=0.53.0
httpx==0.28.1
ruamel.yaml==0.19.1
psutil==7.2.2
huggingface_hub>=1.23.0,<=2.0.0
# mcp braucht nur die MCP-Server in mcp/ (Hermes startet sie mit diesem venv, auf der Box). mcp 2.x hat FastMCP
# entfernt — `from mcp.server.fastmcp import FastMCP` scheitert dort. Der Container hat 2.2.0, nutzt es aber nicht;
# deshalb hier der Stand der Box (einrichten.sh zieht den Container beim nächsten Ausrollen auf 1.28.1).
mcp==1.28.1
tzdata>=2026.3,<=2026.4
-44
View File
@@ -1,44 +0,0 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter
from pydantic import BaseModel
from fastapi import HTTPException
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
class SetBrainReq(BaseModel):
model_id: str
@router.get("/agent/status")
def status() -> dict:
return agent_status()
@router.get("/agent/brain")
def brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
return hermes_brain_info()
@router.post("/agent/brain/set")
def set_brain(body: SetBrainReq) -> dict:
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
res = set_agent_brain(body.model_id)
if not res.get("ok"):
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
return res
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
-64
View File
@@ -1,64 +0,0 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
+228
View File
@@ -0,0 +1,228 @@
"""
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
GET /api/hinweise Hinweise + Verlauf des Wächters
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist)
POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
"""
import time
from datetime import datetime
import psutil
from config import MODELS_DIR
from fastapi import APIRouter, HTTPException
from kern import prognose
from kern.zeit import LOCAL_TZ
from pydantic import BaseModel
from services import (
aufraeumen,
backup,
box_updates,
llamaswap,
messwerte,
modell_nutzung,
system,
update_verlauf,
waechter,
zeitplan,
)
router = APIRouter(prefix="/api", tags=["boxwart"])
def _zeit_kurz(ts: float | None) -> str:
if not ts:
return "–"
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
heute = datetime.now(LOCAL_TZ).date()
if dt.date() == heute:
return f"heute {dt:%H:%M}"
if (heute - dt.date()).days == 1:
return f"gestern {dt:%H:%M}"
return f"{dt:%d.%m. %H:%M}"
def _lampen(stand: dict, u: dict) -> list[dict]:
ids = {h.get("id", "") for h in stand["hinweise"]}
versionen = system.check_versions_cached()
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
engine_ok = llamaswap.engine_reachable()
build = (versionen.get("engine") or {}).get("version_text", "")
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
hirn = llamaswap.brain_status() if engine_ok else {}
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
"geladen" if hirn.get("ready") else "lädt nicht"))
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
coder_an = bool(coder and coder.get("name") in laufend)
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
"antwortet nicht" if hermes_weg else "läuft"))
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
if job_hinweise else "alles gelaufen"))
try:
sicherungen = backup.list_backups()
except Exception:
sicherungen = []
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
zu_alt = not letzte or time.time() - letzte > 36 * 3600
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
try:
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
except Exception:
platte = None
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
n = len(box_updates.bausteine(u))
fest = len(update_verlauf.festgehalten())
unklar = len(box_updates.unklar(u))
if fest:
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
elif not box_updates.gelesen():
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
elif n:
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
elif unklar:
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
else:
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
return lampen
def _box() -> dict:
p = system.metrik_punkt()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
# Seit 25.09.2026 mit „voll in etwa N Tagen“ aus dem Verlauf der Woche (kern/prognose.py).
platte = {"used": du.used, "total": du.total, "percent": du.percent,
"vorhersage": prognose.aus_messreihe(messwerte.QUELLE, "platte")}
except Exception:
platte = None
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
"platte": platte, "uptime_s": p.get("uptime_s")}
@router.get("/start")
def start() -> dict:
stand = waechter.lese_stand()
u = box_updates.gecacht()
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
anzahl = len(stand["hinweise"])
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
return {
"zustand": {
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
"anzahl": anzahl,
"waechter_wach": wach,
"stand": stand["stand"],
"update_laeuft": stand["update_laeuft"],
},
"hinweise": stand["hinweise"],
"verlauf": stand["verlauf"][:20],
"lampen": _lampen(stand, u),
"box": _box(),
"updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u),
"festgehalten": update_verlauf.festgehalten(),
"gelesen": box_updates.gelesen()},
"flugplan": zeitplan.flugplan(),
}
@router.get("/hinweise")
def hinweise() -> dict:
return waechter.lese_stand()
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
if ergebnis.get("detail") and not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis["detail"])
return ergebnis
@router.get("/modelle/nutzung")
def nutzung() -> dict:
return modell_nutzung.nutzung()
@router.get("/updates/verlauf")
def updates_verlauf(anzahl: int = 8) -> dict:
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
@router.post("/updates/festgehalten/{baustein}/freigeben")
def festgehalten_freigeben(baustein: str) -> dict:
if not update_verlauf.freigeben(baustein):
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
class LoeschAuftrag(BaseModel):
art: str # "eintrag" | "ordner"
name: str
@router.get("/modelle/aufraeumen")
def aufraeumen_liste() -> dict:
return {"kandidaten": aufraeumen.kandidaten()}
@router.post("/modelle/aufraeumen/loeschen")
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
class AltrestAuftrag(BaseModel):
id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad
@router.get("/aufraeumen/altreste")
def altreste_liste() -> dict:
"""Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist."""
return {"altreste": aufraeumen.altreste()}
@router.post("/aufraeumen/altreste/loeschen")
def altrest_loeschen(auftrag: AltrestAuftrag) -> dict:
ergebnis = aufraeumen.altrest_loeschen(auftrag.id)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
@router.get("/ziele")
def ziele() -> dict:
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen."""
return {"ziele": [box_updates.ki_box_ziel().als_dict()]}
-15
View File
@@ -1,15 +0,0 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from services import announce
router = APIRouter(prefix="/api")
@router.get("/chronik")
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
-21
View File
@@ -1,21 +0,0 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health
router = APIRouter(prefix="/api")
@router.get("/connect")
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
kwargs = {}
if mcp_path:
kwargs["mcp_script_path"] = mcp_path
return build_snippets(host=host, **kwargs)
@router.get("/connect/health")
def connect_health() -> dict:
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
return check_health()
-88
View File
@@ -1,88 +0,0 @@
"""
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
"""
import asyncio
import logging
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
_register("console", BOX_CONSOLE_UPSTREAM)
+62
View File
@@ -0,0 +1,62 @@
"""Einstellungen der Oberfläche (seit 24.09.2026).
KI-Box (Rolle box):
GET /api/einstellungen Update-Zeitfenster und Radar-Schalter samt Zustand laut systemd
POST /api/einstellungen/update-fenster {tag: 1–7, uhrzeit: "HH:MM"}
POST /api/einstellungen/radar {an: true|false}
POST /api/einstellungen/telegram-test Testmeldung über notify.sh, Ergebnis aus dem Melde-Log
Homelab-Teil (Rolle homelab):
POST /api/homelab/einstellungen/telegram-test dasselbe für den Meldeweg des Homelab-Teils
Die Oberfläche schickt alles unter /api/homelab/… an den Homelab-Teil und den Rest an die KI-Box (lib/instanz.ts) —
so erreicht jeder Test-Knopf die richtige Instanz, egal welche die Seite ausliefert. Dünn: die Logik liegt in
services/einstellungen.py.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import einstellungen
router = APIRouter(prefix="/api/einstellungen", tags=["einstellungen"])
homelab_router = APIRouter(prefix="/api/homelab/einstellungen", tags=["einstellungen"])
class Zeitfenster(BaseModel):
tag: int
uhrzeit: str
class Schalter(BaseModel):
an: bool
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das ging gerade nicht.")
return ergebnis
@router.get("")
def stand() -> dict:
return einstellungen.stand()
@router.post("/update-fenster")
def update_fenster(fenster: Zeitfenster) -> dict:
return _antwort(einstellungen.update_fenster_setzen(fenster.tag, fenster.uhrzeit))
@router.post("/radar")
def radar(schalter: Schalter) -> dict:
return _antwort(einstellungen.radar_schalten(schalter.an))
@router.post("/telegram-test")
def telegram_test() -> dict:
"""Gesendet oder nicht, beides ist eine Antwort (200) — der Grund steht im Ergebnis."""
return einstellungen.telegram_test()
@homelab_router.post("/telegram-test")
def telegram_test_homelab() -> dict:
return einstellungen.telegram_test()
+128
View File
@@ -0,0 +1,128 @@
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
Frontend-Gegenstück: frontend/src/lib/events.ts
"""
import asyncio
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
METRIK_S = 1.0 # Takt der Messpunkte
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
def _mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except OSError:
return 0.0
def _fingerprints() -> dict[str, object]:
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
fp: dict[str, object] = {}
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
from services import waechter
fp["start"] = _mtime(waechter.STORE_PATH)
except Exception:
pass
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
from services import llamaswap
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception:
pass
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
from services import jobengine
fp["jobs"] = json.dumps(
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs(mit_log=False)]
)
except Exception:
pass
return fp
async def _strom(request: Request, mit_metrik: bool):
"""Gemeinsamer Kern beider Endpunkte.
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
"""
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
alt = await asyncio.to_thread(_fingerprints)
yield ": verbunden\n\n"
seit_abdruck = 0.0
seit_ping = 0.0
takt = METRIK_S if mit_metrik else ABDRUCK_S
while True:
if await request.is_disconnected():
return
await asyncio.sleep(takt)
seit_abdruck += takt
seit_ping += takt
if mit_metrik:
try:
from services.system import metrik_punkt
punkt = await asyncio.to_thread(metrik_punkt)
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
seit_ping = 0.0
except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0
neu = await asyncio.to_thread(_fingerprints)
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu)
if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0
if seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
+66
View File
@@ -0,0 +1,66 @@
"""
Dünner /v1-Roh-Weiterleiter (UMBAU v3, P1).
Wenn MC_V1_UPSTREAM gesetzt ist (Unit-Env), reicht das Steuerpult /v1 unangefasst an
den eigenständigen mc2-gateway-Prozess (Loopback :9010) durch — LAN-Clients wie die
IDE-Lane erreichen den Gateway sonst nicht. Hier passiert BEWUSST nichts: kein
Routing, keine Bild-Weiche, keine Token-Zählung — all das macht genau einmal der
Gateway-Prozess (routers/gateway_proxy.py). Rollback = Env-Zeile aus der Unit
entfernen → app.py bindet wieder den lokalen Gateway ein.
"""
import logging
from config import V1_UPSTREAM
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1")
# Hop-by-hop-Header dürfen nicht blind weitergereicht werden; content-length wird von
# httpx (Request) bzw. Starlette-Chunking (Response) neu bestimmt.
_HOP_HEADERS = {
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
"upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer",
}
def _clean(headers) -> dict:
return {k: v for k, v in headers.items() if k.lower() not in _HOP_HEADERS}
@router.api_route("/{path:path}", methods=["GET", "POST"])
async def forward(path: str, request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (app.py lifespan)
url = f"{V1_UPSTREAM}/v1/{path}"
if request.url.query:
url = f"{url}?{request.url.query}"
body = await request.body()
req = client.build_request(
request.method, url, content=body or None, headers=_clean(request.headers),
timeout=None,
)
try:
upstream = await client.send(req, stream=True)
except Exception as exc: # Gateway-Prozess weg → ehrlicher 502 statt Hänger
log.warning("/v1-Weiterleitung an %s fehlgeschlagen: %s", V1_UPSTREAM, exc)
return JSONResponse(
{"error": {"message": f"mc2-gateway ({V1_UPSTREAM}) nicht erreichbar: {exc}",
"type": "gateway_unavailable"}},
status_code=502,
)
async def gen():
try:
async for chunk in upstream.aiter_raw():
yield chunk
finally:
await upstream.aclose()
# Streaming-Passthrough für BEIDE Fälle (SSE + normale JSON-Antwort): Starlette
# chunkt selbst, Status/Header (inkl. x-mc-routed-to) kommen vom Gateway.
return StreamingResponse(
gen(), status_code=upstream.status_code, headers=_clean(upstream.headers)
)
+206 -16
View File
@@ -1,13 +1,19 @@
import hashlib
import json
import logging
import os
from collections import OrderedDict
import httpx
from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import choose_for_lane
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image
from services.routing_policy import load_policy
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
@@ -19,6 +25,125 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
_BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
def _apply_no_think_marker(body: dict) -> None:
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
if not _NO_THINK_MARKER:
return
found = False
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and _NO_THINK_MARKER in c:
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
found = True
elif isinstance(c, list):
for part in c:
if (isinstance(part, dict) and isinstance(part.get("text"), str)
and _NO_THINK_MARKER in part["text"]):
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
found = True
if found and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
_BESCHREIBUNGEN_MAX = 64
def _bild_schluessel(part: dict) -> str:
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
async def _beschreibe(part: dict, vision_alias: str) -> str:
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
neu, darum wird jedes Bild nur einmal beschrieben."""
schluessel = _bild_schluessel(part)
if schluessel in _BESCHREIBUNGEN:
_BESCHREIBUNGEN.move_to_end(schluessel)
return _BESCHREIBUNGEN[schluessel]
frage = {
"model": vision_alias,
"stream": False,
"max_tokens": 700,
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
text = ""
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
text = text.strip()
if text:
_BESCHREIBUNGEN[schluessel] = text
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
_BESCHREIBUNGEN.popitem(last=False)
return text
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
if len(neu) > _BILD_MAX:
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
return False
texte = []
for msg, idx in alt:
text = await _beschreibe(msg["content"][idx], vision_alias)
if not text:
return False
texte.append((msg, idx, text))
for msg, idx, text in texte:
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
return True
def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
@@ -36,11 +161,29 @@ def _inject_language(body: dict, alias: str) -> None:
elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
können damit umgehen und zeigen den Grund an."""
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
status_code=status, headers=headers)
@router.get("/models")
async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json()
try:
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
try:
data = r.json()
except ValueError:
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
# statt eines 500ers aus dem Parser.
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
return JSONResponse(
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
"type": "upstream_error"}}, status_code=502)
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
@@ -89,7 +232,21 @@ async def models(request: Request):
async def _proxy(path: str, request: Request):
body = await request.json()
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
try:
body = await request.json()
except Exception:
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
"type": "invalid_request_error"}}, status_code=400)
if not isinstance(body, dict):
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
f"'{type(body).__name__}' bekommen.",
"type": "invalid_request_error"}}, status_code=400)
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower()
@@ -99,17 +256,39 @@ async def _proxy(path: str, request: Request):
else:
alias = requested
routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy()
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
angefragt = alias
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder →
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
vision_alias = pol.get("vision")
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
frisch, alt = bilder_aufteilen(body)
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
lane = routed.get("x-mc-lane", "-")
if frisch or not beschrieben:
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
body["model"] = alias
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
"x-mc-lane": lane}
else:
routed = {"x-mc-routed-to": alias,
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
"x-mc-lane": lane}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"):
req = client.build_request("POST", url, json=body, timeout=None)
r = await client.send(req, stream=True)
try:
r = await client.send(req, stream=True)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
if r.status_code != 200:
await r.aread()
try:
@@ -121,15 +300,26 @@ async def _proxy(path: str, request: Request):
async def gen():
try:
async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias)
record_stream_chunk(chunk, alias, body.get("max_tokens"))
yield chunk
finally:
await r.aclose()
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
r = await client.post(url, json=body, timeout=600.0)
resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
try:
r = await client.post(url, json=body, timeout=600.0)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
try:
resp_json = r.json()
except ValueError:
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
if isinstance(resp_json, dict):
record_usage(resp_json.get("usage"), alias)
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
for c in resp_json.get("choices") or []):
warn_truncation(alias, body.get("max_tokens"))
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
+30 -13
View File
@@ -1,21 +1,38 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
from fastapi import APIRouter
Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab,
wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box."""
from config import VERSION
from services import gateway, llamaswap
from fastapi import APIRouter
from kern.einstellungen import einstellungen
from pydantic import BaseModel
router = APIRouter(prefix="/api")
@router.get("/health")
class HealthResponse(BaseModel):
status: str
version: str
rolle: str
instanz: str
engine_reachable: bool | None = None
gateway_reachable: bool | None = None
brain: dict | None = None
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True)
def health() -> dict:
return {
"status": "ok",
"version": VERSION,
"engine_reachable": llamaswap.engine_reachable(),
"gateway_reachable": gateway.gateway_reachable(),
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
"brain": llamaswap.brain_status(),
}
e = einstellungen()
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
if e.rolle == "box":
from services import gateway, llamaswap
antwort.update(
engine_reachable=llamaswap.engine_reachable(),
gateway_reachable=gateway.gateway_reachable(),
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail.
brain=llamaswap.brain_status(),
)
return antwort
+39 -11
View File
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter.
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
"""
import asyncio
@@ -22,11 +27,10 @@ import re
import httpx
import websockets
from config import HERMES_BUILTIN_UI_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response
from config import HERMES_BUILTIN_UI_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
@@ -92,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
def praefixiere_ort(ort: str) -> str:
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
return f"/{_BASE}{ort}"
return ort
def praefixiere_html(body: bytes) -> bytes:
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
async def _proxy_http(request: Request, path: str = "") -> Response:
@@ -114,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
)
body = r.content
if "text/html" in r.headers.get("content-type", "").lower():
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
return Response(content=body, status_code=r.status_code, headers=resp_headers)
body = praefixiere_html(body)
resp = Response(content=body, status_code=r.status_code)
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
# dürfen nicht zu einer zusammenfallen.
for k, v in r.headers.multi_items():
kl = k.lower()
if kl in _DROP:
continue
if kl == "location":
v = praefixiere_ort(v)
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
return resp
@router.get(f"/{_BASE}")
+353
View File
@@ -0,0 +1,353 @@
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
Dünn: die Logik liegt in services/homelab/.
"""
import asyncio
import json
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from kern.einstellungen import einstellungen
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
strom_router = APIRouter(prefix="/api", tags=["homelab"])
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
if not kanal.token_gueltig(x_mc2_ausfuehrer):
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
kanal.kontakt()
@router.get("/ziele")
def ziele() -> dict:
return inventar.ziele()
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
return ergebnis
@router.post("/ziele/{ziel_id}/update")
def update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
@router.post("/ziele/{ziel_id}/os-update")
def os_update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "os"))
@router.post("/ziele/{ziel_id}/docker")
def docker(ziel_id: str) -> dict:
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
return _antwort(updates.starten(ziel_id, "docker"))
@router.post("/ziele/{ziel_id}/rueckweg-probe")
def rueckweg_probe(ziel_id: str) -> dict:
"""Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen."""
return _antwort(updates.starten(ziel_id, "probe"))
@router.post("/ziele/pve/neustart")
def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart"))
@router.post("/ziele/pve/kernel-aufraeumen")
def host_kernel_aufraeumen() -> dict:
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
return _antwort(updates.starten("pve", "kernel"))
@router.post("/ziele/{ziel_id}/suchen")
def suchen(ziel_id: str) -> dict:
gast = inventar.gast(ziel_id)
if not gast or not gast.get("erlaubt"):
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
@router.get("/hinweise")
def hinweise() -> dict:
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
from services import waechter
return waechter.lese_stand()
@router.get("/laeufe")
def laeufe() -> dict:
return {"laeufe": updates.laeufe()}
# --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------
# GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge
# POST /api/homelab/alle „Alle aktualisieren“ starten
# GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf
# GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst
# GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
# DELETE /api/homelab/einstellungen/arcane-schluessel
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
# POST /api/homelab/einstellungen/adguard-zugang {"benutzer", "passwort"}: erst bei AdGuard prüfen, dann speichern
# DELETE /api/homelab/einstellungen/adguard-zugang
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
@router.get("/alle/plan")
def alle_plan() -> dict:
from services.homelab import sammellauf
return sammellauf.plan()
@router.post("/alle")
def alle_starten() -> dict:
from services.homelab import sammellauf
return sammellauf.starten()
@router.get("/sammellauf")
def sammellauf_stand() -> dict:
from services.homelab import sammellauf
return {"sammellauf": sammellauf.aktueller()}
@router.post("/sammellauf/{sl_id}/quittieren")
def sammellauf_quittieren(sl_id: str) -> dict:
"""Beendetes „Alle aktualisieren“ als gesehen markieren (die Update-Seite blendet es aus)."""
from services.homelab import sammellauf
return {"ok": sammellauf.quittieren(sl_id)}
@router.get("/protokoll")
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
from services.homelab import protokoll
return {"eintraege": protokoll.eintraege(grenze, berichte)}
@router.get("/speicher")
def speicher_lage() -> dict:
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
from services.homelab import speicher
return speicher.lage()
@router.get("/zertifikate")
def zertifikate_lage() -> dict:
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
from services.homelab import zertifikate
return zertifikate.lage()
@router.post("/sicherungen/probe")
def sicherungen_probe() -> dict:
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
from services.homelab import speicher
return _antwort(speicher.probe_starten())
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
# POST /api/homelab/snapshots/rueckwege-loeschen alle Rückwege von Updates, nacheinander (nur per Klick)
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
@router.get("/snapshots")
def snapshots_liste() -> dict:
from services.homelab import snapshots
return snapshots.liste()
@router.post("/snapshots/rueckwege-loeschen")
def rueckwege_loeschen() -> dict:
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
Klick des Users, nacheinander, mit Meldung am Ende."""
from services.homelab import snapshots
return _antwort(snapshots.rueckwege_loeschen())
@router.post("/snapshots/{ziel_id}/anlegen")
def snapshot_anlegen(ziel_id: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.anlegen(ziel_id))
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.loeschen(ziel_id, name))
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
@router.get("/einstellungen")
def einstellungen_stand() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.stand()
@router.post("/einstellungen/arcane-schluessel")
async def arcane_schluessel_setzen(request: Request) -> dict:
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
Eingabe, also den Schlüssel, dabei wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
@router.delete("/einstellungen/arcane-schluessel")
def arcane_schluessel_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_schluessel_loeschen()
class ArcaneEcht(BaseModel):
an: bool
@router.post("/einstellungen/arcane-echt")
def arcane_echt(schalter: ArcaneEcht) -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
@router.post("/einstellungen/adguard-zugang")
async def adguard_zugang_setzen(request: Request) -> dict:
"""Wie beim Arcane-Schlüssel liest der Dienst den Body selbst: Eine 422 würde das Passwort wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
werte = homelab_einstellungen.zugang_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.adguard_zugang_setzen, werte)
@router.delete("/einstellungen/adguard-zugang")
def adguard_zugang_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.adguard_zugang_loeschen()
@router.get("/ausfuehrer")
def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt()
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"auftraege": kanal.liste()[:20]}
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
def auftrag_abholen() -> dict:
return kanal.naechster() or {}
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
from services.homelab import speicher
kanal.bericht_speichern(daten)
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
return {"ok": True}
class Ergebnis(BaseModel):
code: int
text: str = ""
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
("homelab-hinweise", "mc2-waechter.json"))
def _abdruecke() -> dict[str, float]:
ordner = einstellungen().daten_dir
abdruck = {}
for schluessel, name in _DATEIEN:
try:
abdruck[schluessel] = (ordner / name).stat().st_mtime
except OSError:
abdruck[schluessel] = 0.0
return abdruck
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
alt = _abdruecke()
yield ": verbunden\n\n"
takte = 0
while not await request.is_disconnected():
await asyncio.sleep(takt_s)
takte += 1
neu = _abdruecke()
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
alt = neu
if schluessel or takte % lebenszeichen_takte == 0:
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
@strom_router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
return StreamingResponse(_strom(request), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
def messwerte_annehmen(daten: dict) -> dict:
from services.homelab import messwerte as homelab_messwerte
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
@router.get("/messwerte")
def messwerte_lesen(zeitraum: str = "1h") -> dict:
from services.homelab import messwerte as homelab_messwerte
if zeitraum not in homelab_messwerte.ZEITRAEUME:
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
return homelab_messwerte.abfrage(zeitraum)
-39
View File
@@ -1,39 +0,0 @@
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import ideen
router = APIRouter(prefix="/api")
class IdeeIn(BaseModel):
titel: str
notiz: str = ""
class TaskIn(BaseModel):
id: str
@router.get("/ideen")
def list_queue() -> dict:
return ideen.list_queue()
@router.post("/ideen")
def add_idea(body: IdeeIn) -> dict:
res = ideen.add_idea(body.titel, body.notiz)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
return res
@router.post("/ideen/archivieren")
def archive(body: TaskIn) -> dict:
res = ideen.archive_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
+47 -29
View File
@@ -1,20 +1,28 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
from fastapi import APIRouter, HTTPException, Header
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
mit einem im Browser geparkten Geheimnis übertüncht.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import maintenance
from services import geheimnisse, maintenance
router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel):
service: str
sudo_password: str | None = None
class GeheimnisReq(BaseModel):
schluessel: str
wert: str | None = None
@router.get("/maintenance/updates")
@@ -29,57 +37,67 @@ def update_details(kind: str) -> dict:
return maintenance.update_details(kind)
@router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict:
res = maintenance.check_updates_job(body.sudo_password)
def check_updates() -> dict:
res = maintenance.check_updates_job()
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/os-update")
def os_update(body: SudoReq) -> dict:
res = maintenance.os_update_job(body.sudo_password)
def os_update() -> dict:
res = maintenance.os_update_job()
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/engine-update")
def engine_update(body: SudoReq) -> dict:
res = maintenance.engine_update_job(body.sudo_password)
def engine_update() -> dict:
res = maintenance.engine_update_job()
if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return res
@router.post("/maintenance/swap-update")
def swap_update(body: SudoReq) -> dict:
res = maintenance.swap_update_job(body.sudo_password)
def swap_update() -> dict:
res = maintenance.swap_update_job()
if not res:
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
return res
@router.post("/maintenance/hermes-update")
def hermes_update() -> dict:
return maintenance.hermes_update_job()
def hermes_update(verlauf: bool = True) -> dict:
"""verlauf=false: Der Sonntags-Lauf trägt das Ergebnis selbst in seinen Update-Verlauf ein."""
return maintenance.hermes_update_job(verlauf=verlauf)
@router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job(body.sudo_password)
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
def update_all() -> dict:
return maintenance.update_all_job()
@router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service, body.sudo_password)
return maintenance.restart_service(body.service)
@router.get("/maintenance/logs")
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
return maintenance.logs(service, lines, x_sudo_password)
def logs(service: str, lines: int = 200) -> dict:
return maintenance.logs(service, lines)
@router.get("/maintenance/geheimnisse")
def geheimnisse_status() -> dict:
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
return geheimnisse.status()
@router.post("/maintenance/geheimnisse")
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
if not geheimnisse.setzen(body.schluessel, body.wert):
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
return {"ok": True, **geheimnisse.status()}
-93
View File
@@ -1,93 +0,0 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
import time
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
from services.voice_metrics import park, record_stage
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "knowledge"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "knowledge"
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.get("/memory/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
return memory.graph(min_score=min_score, top_k=top_k)
@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")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
if q:
_t0 = time.perf_counter()
res = memory.list_memories(q=q, category=category)
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("memory_retrieve", _ms)
park("retrieve", _ms)
return res
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
+18
View File
@@ -0,0 +1,18 @@
"""Messwerte der KI-Box mit Verlauf (Rolle box, seit 24.09.2026).
GET /api/messwerte?zeitraum=1h|24h|7d Minutenwerte des Stewards, verdichtet auf 60 s, 5 min bzw. 30 min
Dünn: die Logik liegt in services/messwerte.py, die Ablage in kern/messreihen.py.
"""
from fastapi import APIRouter, HTTPException
from services import messwerte
router = APIRouter(prefix="/api", tags=["messwerte"])
@router.get("/messwerte")
def messwerte_lesen(zeitraum: str = "1h") -> dict:
if zeitraum not in messwerte.ZEITRAEUME:
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
return messwerte.abfrage(zeitraum)
+60 -127
View File
@@ -1,12 +1,12 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
(ohne Nutzer seit dem Box-Wart-Umbau)."""
import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
router = APIRouter(prefix="/api")
@@ -30,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
name: str = "", role: str = "") -> dict:
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
ram = _ram_gb()
pb = params_b if params_b > 0 else budget.params_b_for(name)
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
return {
"params_b": round(pb, 1),
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel):
model_path: str
role: str | None = None
@@ -62,6 +41,10 @@ class RegisterReq(BaseModel):
@router.post("/models/register")
def register(req: RegisterReq) -> dict:
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
for pfad in (req.model_path, req.mmproj_path):
if pfad and not llamaswap.im_modellordner(pfad):
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
try:
model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
@@ -72,13 +55,29 @@ def register(req: RegisterReq) -> dict:
return {"ok": True, "model_id": model_id}
@jobengine.nacharbeit("modell:rolle")
def _rolle_uebernehmen(model_id: str, role: str) -> None:
"""Nach dem Download die gewünschte Rolle setzen. hermes geht dabei durch den warm-bewussten
Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) — der rohe Alias-Move hatte
diesen Flow bisher umgangen."""
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
class InstallReq(BaseModel):
repo: str
role: str | None = None
quant: str = "Q4_K_M"
ctx: int | None = None
jinja: bool = False
hf_token: str | None = None
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
@router.get("/hf/search")
@@ -114,24 +113,33 @@ def install(req: InstallReq) -> dict:
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
# Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
try:
model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
set_alias=False)
except PermissionError as exc:
raise HTTPException(500, str(exc))
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen).
role = (req.role or "").strip().lower()
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo]
for f in info["files"]:
args.append(f)
args.extend(info["files"])
if info["mmproj"]:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
if token := geheimnisse.hf_token():
env["HF_TOKEN"] = token
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
# Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet.
job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", zeitlimit_s=6 * 3600,
nacharbeit="modell:rolle" if role else None,
nacharbeit_daten={"model_id": model_id, "role": role} if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -147,23 +155,30 @@ def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)}
@router.post("/jobs/{job_id}/quittieren")
def quittieren(job_id: str) -> dict:
"""Beendeten Auftrag als gesehen markieren (die Oberfläche blendet ihn aus)."""
return {"ok": jobengine.quittieren(job_id)}
class RoleReq(BaseModel):
role: str | None = None
@router.get("/roles/{role}/recommend")
def recommend_role(role: str) -> dict:
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
from services import roles
return roles.recommend_for_role(role)
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower()
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
prot = llamaswap.protected_alias_of(model_id)
if prot and new_role != prot:
raise HTTPException(400,
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
if (body.role or "").strip().lower() == "hermes":
if new_role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
@@ -174,54 +189,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.get("/models/{model_id}/ctx/auto")
def auto_ctx(model_id: str) -> dict:
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
if not m:
raise HTTPException(404, "Modell nicht gefunden")
saw = budget.setup_aware_ctx_for_model(m)
return {"model_id": model_id, "current_ctx": m.get("ctx"),
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
"role": m.get("role"), **saw}
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/models/drafts")
def list_drafts(target: str = "") -> dict:
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
return llamaswap.drafts_for(target)
class DraftReq(BaseModel):
draft_path: str | None = None
@router.post("/models/{model_id}/draft")
def set_model_draft(model_id: str, body: DraftReq) -> dict:
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
try:
res = llamaswap.set_spec_draft(model_id, body.draft_path)
except PermissionError as exc:
raise HTTPException(500, str(exc))
if not res["ok"]:
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
return res
@router.post("/models/unload")
def unload_all_models() -> dict:
import httpx
@@ -248,21 +215,8 @@ def unload_model(model_id: str) -> dict:
@router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
return llamaswap.lade_modell(model_id)
@router.delete("/models/{model_id}")
@@ -270,24 +224,3 @@ def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
+55
View File
@@ -0,0 +1,55 @@
"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026).
Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/<pfad> ab; hier wird
daraus <partner>/api/<pfad>. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder
anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird.
"""
import httpx
from fastapi import APIRouter, HTTPException, Request, Response
from kern import partner
from kern.einstellungen import einstellungen
from services import software_stand
router = APIRouter(prefix="/api", tags=["partner"])
# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage
# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab).
_GESPERRT = ("partner", "stream")
_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
@router.get("/instanz")
def instanz() -> dict:
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen. Seit 24.09.2026 mit dem
Software-Stand (welcher Commit seit wann live ist); den der anderen Instanz liefert /api/partner/instanz."""
e = einstellungen()
return {"rolle": e.rolle, "instanz": e.instanz,
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name},
"stand": software_stand.stand()}
@router.get("/partner")
def partner_status() -> dict:
return partner.status()
@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
async def weiterreichen(pfad: str, request: Request) -> Response:
e = einstellungen()
if not e.partner_url:
raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.")
if pfad.split("/", 1)[0] in _GESPERRT:
raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.")
kopf = {"X-MC-Von": e.instanz}
if request.headers.get("content-type"):
kopf["Content-Type"] = request.headers["content-type"]
try:
async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c:
r = await c.request(request.method, f"{e.partner_url}/api/{pfad}",
params=request.query_params, content=await request.body(), headers=kopf)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type", "application/json"))
+51
View File
@@ -0,0 +1,51 @@
"""
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
POST /api/radar/{id}/testen Jetzt testen (seit 25.09.2026; nur wenn er samt Coder in den Speicher passt)
Dünn: die Logik liegt in services/radar.py. Getestet wird nachts (backend/radar_lauf.py) oder auf Knopfdruck.
"""
from fastapi import APIRouter, HTTPException
from services import radar
router = APIRouter(prefix="/api", tags=["radar"])
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
detail=ergebnis.get("detail") or "Das ging nicht.")
return ergebnis
@router.get("/radar")
def radar_stand() -> dict:
return radar.uebersicht()
@router.post("/radar/suche")
def radar_suche() -> dict:
"""Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID."""
return radar.suche_starten()
@router.post("/radar/{kandidat_id}/uebernehmen")
def radar_uebernehmen(kandidat_id: str) -> dict:
return _antwort(radar.uebernehmen(kandidat_id))
@router.post("/radar/{kandidat_id}/testen")
def radar_testen(kandidat_id: str) -> dict:
"""„Jetzt testen“ (seit 25.09.2026): nur, wenn der Kandidat samt Coder neben das Warm-Set passt und nicht in
der Nacht-Sperre — sonst 409 mit Grund. Antwortet sofort mit der Auftrags-ID."""
return _antwort(radar.test_starten(kandidat_id))
@router.post("/radar/{kandidat_id}/verwerfen")
def radar_verwerfen(kandidat_id: str) -> dict:
return _antwort(radar.verwerfen(kandidat_id))
+14 -2
View File
@@ -4,7 +4,6 @@ LAN-only wie alle MC2-Endpoints."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import reminders
router = APIRouter(prefix="/api")
@@ -16,7 +15,20 @@ class ReminderIn(BaseModel):
repeat: str = "" # '' einmalig | daily | weekdays | weekly
@router.get("/reminders")
class ReminderOut(BaseModel):
id: int
text: str
next_ts: float
repeat: str
created_ts: float
when_local: str
class ReminderListResponse(BaseModel):
items: list[ReminderOut]
@router.get("/reminders", response_model=ReminderListResponse)
def list_reminders() -> dict:
return {"items": reminders.list_all()}
+3 -34
View File
@@ -1,10 +1,8 @@
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
from fastapi import APIRouter
from services import gateway
from services.routing_policy import policy_meta, save_policy
router = APIRouter(prefix="/api")
@@ -12,32 +10,3 @@ router = APIRouter(prefix="/api")
@router.get("/routing")
def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
@router.get("/routing/policy")
def get_policy() -> dict:
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
return policy_meta()
class PolicyPatch(BaseModel):
fast: str | None = None
heavy: str | None = None
coder: str | None = None
coder_lite: str | None = None
heavy_chars: int | None = None
coding_escalate_chars: int | None = None
fast_no_think: bool | None = None
@router.put("/routing/policy")
def put_policy(patch: PolicyPatch) -> dict:
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
try:
new_policy = save_policy(fields)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
return {"policy": new_policy}
+13 -65
View File
@@ -1,4 +1,4 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
@@ -6,20 +6,12 @@ zurückgegeben statt zu crashen.
"""
import logging
import os
import subprocess
from fastapi import APIRouter
from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
from services import ablaeufe, dienste, maintenance
from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models
from services.llamaswap import list_models
from services.pricing import compute_savings
from services.system import system_status
from services.token_stats import get_stats
@@ -28,49 +20,23 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool:
try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
return {
"services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_ui": a["hermes_ui_url"],
},
}
"""Alle Dienste der Box mit Erreichbarkeit, Zustand und Kennzahlen (Speicher, CPU, Laufzeit,
Neustarts) — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge.
Logik in services/dienste.py; `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name."""
return dienste.liste()
@router.get("/system/ablaeufe")
def ablaeufe_liste() -> dict:
"""Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (services/ablaeufe.py)."""
return {"ablaeufe": ablaeufe.ablaeufe()}
@router.post("/system/backup")
@@ -83,15 +49,6 @@ def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@@ -106,15 +63,6 @@ def restart(req: RestartReq) -> dict:
return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats")
def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
+136 -204
View File
@@ -1,36 +1,30 @@
"""
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste,
Klon-Referenz und das alte Piper/Chatterbox-TTS.
"""
import json
import logging
import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from services import announce
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
@@ -43,17 +37,22 @@ except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
def _sse_fehler(text: str) -> bytes:
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
@@ -65,8 +64,8 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}})
try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
# Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
@@ -78,21 +77,11 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel):
text: str
engine: str = "piper"
voice: str = ""
language: str = ""
ref_path: str = ""
class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
@@ -115,9 +104,8 @@ class AlarmIn(BaseModel):
def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
auch nachts sofort raus (notify.sh)."""
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leere Meldung.")
@@ -132,8 +120,8 @@ def alarm(body: AlarmIn) -> dict:
@router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc:
@@ -147,43 +135,6 @@ def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
return announce.list_after(after, limit)
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001
out["error"] = str(exc)
return out
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices")
def voice_voices() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
@@ -192,12 +143,8 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
_t0 = time.perf_counter()
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
@@ -213,8 +160,7 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
@@ -223,55 +169,6 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference")
def voice_get_reference() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
return {"active": False, "error": str(exc)}
@router.delete("/voice/reference")
def voice_clear_reference() -> dict:
try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
@@ -289,69 +186,104 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
# Request hängt, während das Bild-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
desc = await _describe_images(imgs, body.text)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
# generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk
except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
return
async for chunk in r.aiter_raw():
yield chunk
except httpx.HTTPError as exc:
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
return StreamingResponse(gen(), media_type="text/event-stream")
# ---------------------------------------------------------------------------------------------
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
# /tts/stream -> PCM16 + X-Sample-Rate).
class LucyTtsIn(BaseModel):
text: str
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
@router.get("/lucy/stimme/health")
def lucy_stimme_health() -> dict:
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
try:
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
@router.post("/lucy/stimme/tts")
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
except httpx.HTTPStatusError as exc:
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
except httpx.HTTPError as exc:
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
@router.post("/lucy/stimme/tts/stream")
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
try:
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
upstream = await client.send(req, stream=True)
except httpx.HTTPError as exc:
await client.aclose()
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
if upstream.status_code != 200:
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
await upstream.aclose(); await client.aclose()
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
async def gen():
try:
async for chunk in upstream.aiter_raw():
yield chunk
finally:
await upstream.aclose()
await client.aclose()
return StreamingResponse(gen(), media_type="application/octet-stream",
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
-71
View File
@@ -1,71 +0,0 @@
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/api")
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
def _available() -> bool:
return VAULT.is_dir()
def _title_of(p: Path) -> str:
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
try:
with p.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s:
return s.lstrip("# ").strip()[:160]
except OSError:
pass
return p.stem
@router.get("/wissen")
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
return {"available": False, "files": []}
files = []
now = time.time()
for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
st = p.stat()
files.append({
"path": rel,
"name": p.stem,
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
"title": _title_of(p),
"mtime": st.st_mtime,
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
})
files.sort(key=lambda f: f["mtime"], reverse=True)
return {"available": True, "files": files}
@router.get("/wissen/datei")
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
try:
target = (VAULT / pfad).resolve()
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
raise HTTPException(404, "Notiz nicht gefunden.")
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
"mtime": target.stat().st_mtime}
except HTTPException:
raise
except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.")
-12
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import backup as backup_svc
router = APIRouter(prefix="/api")
@@ -32,17 +31,6 @@ def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
+247
View File
@@ -0,0 +1,247 @@
"""Abläufe der KI-Box — der Totmannschalter (Ausbauplan Welle 1, Punkt 1, seit 25.09.2026).
Der Wächter sah bisher nur, wenn ein geplanter Ablauf SCHEITERTE (pruefe_timer_dienste, pruefe_hermes_jobs). Ein Ablauf,
der gar nicht erst läuft, fiel niemandem auf: Vom 06. bis 17.09.2026 stand die Sonntags-Update-Kette still, bis es
jemand zufällig bemerkte. Hier steht deshalb je Ablauf, wie lange er höchstens schweigen darf (Takt + Karenz), und was
darüber liegt, wird ein Hinweis:
systemd-Timer letzter Start (LastTriggerUSec) älter als erlaubt → überfällig; der Timer selbst aus
(eingetragen, aber nicht aktiv) → aus. Bewusst abgeschaltet (inaktiv und ausgetragen, z. B. das
Radar in den Einstellungen) ist kein Befund.
Hermes-Cron-Jobs der nächste Lauf (next_run_at) liegt mehr als eine Stunde zurück → der Planer von Hermes lief
nicht (Gateway hing oder war aus).
Gitea-Ampel der Actions-Runner meldet sich in Gitea nicht als online (seit dem 28.08. stand er still, weil
er von Hand gestartet war; seit 25.09. ein Dienst auf arcane).
Nur lesend. Die Liste für die Oberfläche (Seite „Dienste und Protokolle“ → Zeitpläne) kommt aus ablaeufe().
"""
import logging
import os
import re
import subprocess
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import httpx
from kern.zeit import LOCAL_TZ
log = logging.getLogger(__name__)
STUNDE = 3600
TAG = 24 * STUNDE
@dataclass(frozen=True)
class Zeitplan:
unit: str # systemd-User-Timer ohne „.timer“
name: str
erlaubt_s: int # so lange darf er höchstens schweigen (Takt + Karenz)
takt: str # in Worten, für die Oberfläche
# Takt aus deploy/*.timer; Karenz: täglich 3 h, wöchentlich 6 h (RandomizedDelaySec, Neustart am Sonntag), monatlich
# fünf Wochen (erster Montag im Monat).
ZEITPLAENE: tuple[Zeitplan, ...] = (
Zeitplan("mc2-autoupdate", "Updates am Sonntag", 7 * TAG + 6 * STUNDE, "sonntags 04:30"),
Zeitplan("mc2-backup", "Sicherung der Box", TAG + 3 * STUNDE, "täglich 03:30"),
Zeitplan("pbs-backup", "Sicherung auf den PBS", TAG + 3 * STUNDE, "täglich 03:50"),
Zeitplan("mc2-morgenmeldung", "Morgenmeldung", TAG + 3 * STUNDE, "täglich 07:00"),
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
)
HERMES_KARENZ_S = STUNDE
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def _gemerkt(schluessel: str, holen):
with _lock:
eintrag = _cache.get(schluessel)
if eintrag and time.monotonic() - eintrag[0] < CACHE_S:
return eintrag[1]
wert = holen()
with _lock:
_cache[schluessel] = (time.monotonic(), wert)
return wert
def cache_leeren() -> None:
with _lock:
_cache.clear()
# --- systemd --------------------------------------------------------------------------------------------------------
def _unix(wert: str | None) -> float | None:
"""„@1790300009“ (systemctl --timestamp=unix) → Sekunden; leer oder „n/a“ → None."""
m = re.fullmatch(r"@(\d+(?:\.\d+)?)", (wert or "").strip())
return float(m.group(1)) if m else None
def _timer_zustand(unit: str) -> dict[str, str]:
"""Zustand eines User-Timers. Ohne systemd (Windows, Tests): leer."""
try:
out = subprocess.run(
["systemctl", "--user", "show", f"{unit}.timer", "--timestamp=unix", "-p",
"LoadState,ActiveState,UnitFileState,ActiveEnterTimestamp,LastTriggerUSec,NextElapseUSecRealtime"],
capture_output=True, text=True, timeout=10).stdout
except Exception:
return {}
return dict(zeile.split("=", 1) for zeile in out.splitlines() if "=" in zeile)
def bewerte_timer(z: Zeitplan, zustand: dict[str, str], jetzt: float) -> dict | None:
"""Ein Eintrag der Liste: Zustand in Worten und ob es ein Befund ist. None = gibt es hier nicht (kein systemd)."""
if not zustand or zustand.get("LoadState") in ("", "not-found"):
return None
letzter = _unix(zustand.get("LastTriggerUSec"))
naechster = _unix(zustand.get("NextElapseUSecRealtime"))
eintrag = {"id": f"timer:{z.unit}", "name": z.name, "art": "zeitplan", "takt": z.takt,
"letzter": letzter, "naechster": naechster, "status": "ok", "text": None}
aktiv = zustand.get("ActiveState") == "active"
eingetragen = zustand.get("UnitFileState") in ("enabled", "static", "enabled-runtime")
if not aktiv and not eingetragen:
return {**eintrag, "status": "aus", "text": "Bewusst abgeschaltet."}
if not aktiv:
return {**eintrag, "status": "gestoppt",
"text": f"Der Zeitplan ist eingetragen, läuft aber nicht — {z.name} startet so nie."}
# Nie gestartet: gezählt wird ab dem Moment, seit dem der Timer läuft (nach einer Neuinstallation ist das normal).
bezug = letzter or _unix(zustand.get("ActiveEnterTimestamp"))
if bezug is not None and jetzt - bezug > z.erlaubt_s:
seit = _dauer(jetzt - bezug)
text = (f"Zuletzt vor {seit} gestartet ({z.takt} erwartet)." if letzter
else f"Seit {seit} kein einziger Start ({z.takt} erwartet).")
return {**eintrag, "status": "ueberfaellig", "text": text}
return eintrag
def _dauer(s: float) -> str:
if s < 2 * TAG:
return f"{int(s // STUNDE)} Stunden"
return f"{int(s // TAG)} Tagen"
# --- Hermes ---------------------------------------------------------------------------------------------------------
def _zeit(wert) -> float | None:
if not wert:
return None
try:
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
except ValueError:
return None
return (dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)).timestamp()
def bewerte_job(job: dict, jetzt: float) -> dict | None:
if not job.get("enabled", True):
return None
naechster, letzter = _zeit(job.get("next_run_at")), _zeit(job.get("last_run_at"))
takt = str((job.get("schedule") or {}).get("display") or (job.get("schedule") or {}).get("expr") or "")
eintrag = {"id": f"hermes:{job.get('id')}", "name": str(job.get("name") or "Hermes-Job"), "art": "hermes",
"takt": f"Hermes-Cron {takt}".strip(), "letzter": letzter, "naechster": naechster, "status": "ok",
"text": None}
if naechster is not None and jetzt - naechster > HERMES_KARENZ_S:
return {**eintrag, "status": "ueberfaellig",
"text": f"Sollte vor {_dauer(jetzt - naechster)} laufen — der Planer von Hermes hat ihn nicht gestartet."}
return eintrag
# --- Gitea-Ampel ------------------------------------------------------------------------------------------------------
def _gitea_token() -> str | None:
"""Das Token aus ~/.git-credentials (Zeile für die Gitea-Adresse). Erscheint nie in einer Ausgabe."""
try:
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
except OSError:
return None
host = GITEA_URL.split("//", 1)[-1].split(":", 1)[0]
for z in zeilen:
m = re.match(r"https?://[^:/@]+:([^@]+)@([^/:]+)", z.strip())
if m and (m.group(2) == host or "ddnsfree" in m.group(2)):
return m.group(1)
return None
def _runner() -> list[dict] | None:
token = _gitea_token()
if not token:
return None
try:
r = httpx.get(f"{GITEA_URL}/api/v1/admin/actions/runners", headers={"Authorization": f"token {token}"},
timeout=10)
r.raise_for_status()
daten = r.json()
except Exception:
log.info("ablaeufe: Gitea-Runner nicht abfragbar", exc_info=True)
return None
return [x for x in (daten.get("runners") or []) if isinstance(x, dict)] if isinstance(daten, dict) else None
def bewerte_runner(runner: list[dict] | None) -> list[dict]:
if runner is None:
return []
if not runner:
return [{"id": "gitea:runner", "name": "Gitea-Ampel", "art": "ampel", "takt": "bei jedem Push",
"letzter": None, "naechster": None, "status": "gestoppt",
"text": "In Gitea ist kein Runner eingetragen — die Ampel prüft nichts."}]
liste = []
for x in runner:
online = x.get("status") == "online" and not x.get("disabled")
liste.append({"id": f"gitea:runner:{x.get('id')}", "name": f"Gitea-Ampel ({x.get('name')})", "art": "ampel",
"takt": "bei jedem Push", "letzter": None, "naechster": None,
"status": "ok" if online else "gestoppt",
"text": None if online else (f"Der Runner {x.get('name')} meldet sich in Gitea als "
f"„{x.get('status')}“ — Prüfläufe bleiben stehen.")})
return liste
# --- Zusammen -------------------------------------------------------------------------------------------------------
def _jobs() -> list[dict]:
from services import waechter # waechter importiert dieses Modul
return waechter._hermes_jobs()
def ablaeufe(jetzt: float | None = None) -> list[dict]:
"""Alle Abläufe mit Zustand, Probleme zuerst — für die Oberfläche und den Wächter."""
jetzt = time.time() if jetzt is None else jetzt
liste: list[dict] = []
for z in ZEITPLAENE:
if (e := bewerte_timer(z, _gemerkt(f"timer:{z.unit}", lambda u=z.unit: _timer_zustand(u)), jetzt)):
liste.append(e)
for job in _jobs():
if (e := bewerte_job(job, jetzt)):
liste.append(e)
liste += bewerte_runner(_gemerkt("gitea", _runner))
rang = {"ueberfaellig": 0, "gestoppt": 0, "ok": 1, "aus": 2}
return sorted(liste, key=lambda e: rang.get(e["status"], 1))
def pruefe_ueberfaellig():
"""Befunde für den Wächter: überfällige oder gestoppte Abläufe (gelb). Die Sonntags-Updates rot — deren Stillstand
kostete im September elf Tage."""
from services.waechter import Befund, _aktion
befunde = []
for e in ablaeufe():
if e["status"] not in ("ueberfaellig", "gestoppt"):
continue
wichtig = e["id"] == "timer:mc2-autoupdate"
titel = f"{e['name']} ist überfällig" if e["status"] == "ueberfaellig" else f"{e['name']} läuft nicht"
aktionen = []
if e["art"] == "zeitplan":
unit = e["id"].split(":", 1)[1]
aktionen = [_aktion("protokoll", "Protokoll", dienst=unit)]
befunde.append(Befund(id=f"ablauf:{e['id']}", stufe="rot" if wichtig else "gelb", titel=titel,
text=e["text"] or "", quelle=e["id"], aktionen=aktionen, nach_takten=5))
return befunde
+75 -116
View File
@@ -1,32 +1,22 @@
"""
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was").
"""
import logging
import os
import re
import httpx
import psutil
from config import (BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
from config import (
HERMES_API_URL,
HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME,
)
log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try:
@@ -102,74 +92,29 @@ def _reach(url: str, path: str = "") -> bool:
return False
def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
return {
"gateway_url": HERMES_API_URL,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
}
def set_agent_brain(model_id: str) -> dict:
def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
So bleibt das neue Hirn warm und der Agent nutzt es sofort.
hermes_umstellen=False: Schritt 3 macht der Aufrufer selbst — etwa das Radar, das erst nach dem
einen gesammelten Schreibvorgang der llama-swap-Config Hermes neu startet."""
from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models:
@@ -179,26 +124,30 @@ def set_agent_brain(model_id: str) -> dict:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try:
from services.llamaswap import set_ttl
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0)
with llamaswap.sammeln():
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try:
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
from services.llamaswap import DEFAULT_TTL, set_ttl
# Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle.
with llamaswap.sammeln():
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
update_brain_model("hermes") # 3) Config + Restart
if hermes_umstellen:
update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None
try:
@@ -214,49 +163,59 @@ def set_agent_brain(model_id: str) -> dict:
def update_brain_model(new_model: str) -> bool:
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
Hermes-Gateway neu.
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
import shutil
import time as _time
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {}
from ruamel.yaml import YAML
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
return False
r_yaml = YAML()
r_yaml.preserve_quotes = True
r_yaml.width = 100
r_yaml.indent(mapping=2, sequence=4, offset=2)
try:
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f)
except Exception:
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
return False
if not isinstance(cfg, dict):
cfg = {}
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
return False
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
# Beide setzen, sonst greift die Umschaltung nicht.
cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model
tmp = config_path.with_suffix(".yaml.neu")
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
with tmp.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
os.replace(tmp, config_path)
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
tmp.unlink(missing_ok=True)
return False
try:
from services import maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
+25 -10
View File
@@ -19,15 +19,22 @@ import threading
import time
from pathlib import Path
from config import MODELS_DIR
import httpx
from kern.einstellungen import einstellungen
log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
# (mc2-steward), darf er den Store NICHT direkt schreiben — Datei + In-Memory-Cache
# gehören exklusiv dem Steuerpult. Gesetzt (Unit-Env, z. B. http://127.0.0.1:9001)
# liefert add() die Meldung stattdessen per HTTP am /api/voice/announce-Endpunkt ab.
ANNOUNCE_HTTP = os.environ.get("MC_ANNOUNCE_HTTP", "").rstrip("/")
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
@@ -58,6 +65,22 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal"
text = (text or "").strip()
if not text:
raise ValueError("Leere Meldung.")
if ANNOUNCE_HTTP:
# Fremdprozess-Modus (mc2-steward): per HTTP beim Steuerpult abliefern.
payload = {"text": text[:4000], "subject": (subject or "").strip()[:120],
"source": (source or "").strip()[:60], "priority": priority}
try:
with httpx.Client(timeout=5.0) as c:
r = c.post(f"{ANNOUNCE_HTTP}/api/voice/announce", json=payload)
if r.status_code == 200:
return (r.json() or {}).get("item") or payload
except Exception:
pass
# MC2 down/Endpunkt weg: Alarm ist nicht verloren — der Telegram-Direktweg des
# Aufrufers (notify.sh) läuft separat; hier bleibt nur der Log-Nachweis.
log.warning("announce: HTTP-Abgabe an %s fehlgeschlagen — nur im Log: [%s] %.120s",
ANNOUNCE_HTTP, subject or "-", text)
return payload
with _lock:
state = _load()
item = {
@@ -89,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
+384
View File
@@ -0,0 +1,384 @@
"""
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", "mc2-messwerte", "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("hermes-alt-umgebung", "Alte Hermes-Umgebung (Python 3.11)",
("~/.hermes/hermes-agent/venv", "~/.hermes/hermes-agent/venv.bak-20260925-vor-pm"),
"Hermes' Umgebung von vor dem eigenen Paketmanager (hermes pm, 25.09.) und ihre Sicherung. Seit 25.09. "
"starten Gateway und Dashboard aus der neuen Umgebung; Selbstreparatur und PC-Pfad-Skill nutzen das "
"System-Python. Empfohlen: erst nach einem grünen Sonntagslauf (27.09.) löschen."),
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."}
-404
View File
@@ -1,404 +0,0 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _repo_ok(repo: str) -> bool:
r = REPOS.get(repo)
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
capture_output=True, text=True, timeout=timeout)
def _status_key(repo: str, branch: str) -> str:
return f"{REPOS[repo]['key']}{branch}"
def _fetch_throttled(repo: str) -> None:
now = time.time()
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return
_fetch_cache[repo] = now
try:
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(key: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _gutachten() -> dict:
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
try:
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _remote_branches(repo: str) -> list[str]:
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
items = []
for branch in _remote_branches(repo):
ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try:
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
# garantierter Fehllauf („refusing to merge unrelated histories").
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
ts_val = int(cts) if cts.strip().isdigit() else None
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
stempel = gutachten.get(f"{repo}:{branch}")
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
stempel = None
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"repo": repo,
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": ts_val,
"empfehlung": stempel,
"ahead": ahead,
"behind": behind,
"verwaist": verwaist,
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
"leer": not verwaist and ahead > 0 and not files_raw,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
})
except Exception:
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
return items
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
statuses = _statuses()
gutachten = _gutachten()
items = []
for repo in REPOS:
if not _repo_ok(repo):
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
_fetch_throttled(repo)
items.extend(_repo_items(repo, statuses, gutachten))
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
if not _repo_ok(repo):
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
key = _status_key(repo, branch)
state = (_statuses().get(key) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / REPOS[repo]["runner"]
if not src.is_file():
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
if not _repo_ok(repo):
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
grund = (grund or "").strip()[:400]
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
detail = "Vom Commander abgelehnt — Branch gelöscht"
if grund:
detail += f" (Grund: {grund})"
_set_status(_status_key(repo, branch), "abgelehnt", detail)
try:
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
except OSError:
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
try:
from services import announce
wo = "im Lucy-Repo " if repo == "lucy" else ""
warum = f" Grund: {grund}" if grund else ""
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
+6 -9
View File
@@ -1,7 +1,7 @@
"""
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config).
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BETRIEB.md (Sicherung).
"""
import subprocess
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try:
@@ -51,7 +46,7 @@ def backup_now() -> dict:
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
if r.returncode != 0:
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
latest = _latest()
@@ -70,9 +65,11 @@ def list_backups() -> list[dict]:
return []
out = []
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
st = p.stat()
out.append({
"snapshot": _ts(p),
"file": p.name,
"size_mb": round(p.stat().st_size / 1_000_000, 2),
"size_mb": round(st.st_size / 1_000_000, 2),
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
})
return out
+156
View File
@@ -0,0 +1,156 @@
"""Updates der KI-Box: Zwischenspeicher für den Start-Bildschirm und der Box-Adapter (Phase 2).
maintenance.updates() fragt GitHub, apt und git und braucht nach einem Neustart gern zehn Sekunden
und mehr. Der Start-Bildschirm wartet darauf nie: Er bekommt den letzten Stand (oder zunächst
nichts), frisch geholt wird im Hintergrund. Seit 24.09.2026 liegt das hier statt im Router, weil
auch der Box-Adapter (ki_box_ziel) davon liest.
ki_box_ziel() beschreibt die KI-Box im gemeinsamen Modell aus kern/ziele.py: vier Bausteine mit
Stand, Versionen und dem Knopf, der das jeweilige Update anstößt.
"""
import platform
import threading
import time
from kern.ziele import Aktion, BausteinStand, ZielStand
from services import maintenance, system, update_verlauf
_updates_lock = threading.Lock()
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
UPDATES_CACHE_S = 600
NAMEN = {"os": "Betriebssystem", "engine": "Motor (llama.cpp)", "swap": "llama-swap", "hermes": "Hermes"}
NAMEN_KURZ = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
AKTIONEN = {
"os": Aktion("POST", "/api/maintenance/os-update",
"Betriebssystem-Updates jetzt einspielen? Danach wird der Stack geprüft."),
"engine": Aktion("POST", "/api/maintenance/engine-update",
"Den Motor jetzt aktualisieren? Die Modelle sind dabei kurz weg; scheitert die Prüfung, "
"geht es zurück."),
"swap": Aktion("POST", "/api/maintenance/swap-update",
"llama-swap jetzt aktualisieren? Laufende Antworten brechen ab; scheitert die Prüfung, "
"geht es zurück."),
"hermes": Aktion("POST", "/api/maintenance/hermes-update",
"Hermes jetzt aktualisieren? Lucy ist dabei ein paar Minuten weg."),
}
def _holen() -> None:
stand = maintenance.update_stand
try:
daten = maintenance.updates()
except Exception:
daten = None
with _updates_lock:
if daten is not None or _updates_cache["daten"] is None:
_updates_cache["daten"] = daten or {}
_updates_cache["ts"] = time.time()
_updates_cache["stand"] = stand
_updates_cache["laeuft"] = False
def gecacht() -> dict:
"""Letzter Stand der Update-Prüfung; ist er veraltet, wird im Hintergrund neu geholt."""
with _updates_lock:
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
# statt bis zu 10 Minuten „Aktualisieren" für schon eingespielte Updates zu zeigen (24.09.2026).
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
or _updates_cache["stand"] != maintenance.update_stand)
if veraltet and not _updates_cache["laeuft"]:
_updates_cache["laeuft"] = True
threading.Thread(target=_holen, name="updates-holen", daemon=True).start()
return _updates_cache["daten"] or {}
def gelesen() -> bool:
"""Liegt schon ein Ergebnis vor (nach einem Neustart dauert die erste Prüfung)?"""
return _updates_cache["daten"] is not None
def bausteine(u: dict) -> list[dict]:
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
liste = []
if u.get("os"):
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
if u.get("engine"):
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
if u.get("swap"):
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
for c in u.get("components") or []:
if c.get("key") == "hermes_agent" and c.get("update"):
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
return liste
def unklar(u: dict) -> list[dict]:
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt" statt „aktuell"."""
return [{"id": k, "name": NAMEN_KURZ.get(k, k), "grund": grund}
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
# --- Box-Adapter ------------------------------------------------------------------------
def _ubuntu() -> str | None:
try:
info = platform.freedesktop_os_release()
except OSError:
return None
return " ".join(x for x in (info.get("NAME"), info.get("VERSION_ID")) if x) or None
def _versionen(u: dict) -> dict[str, tuple[str | None, str | None]]:
"""(installiert, verfügbar) je Baustein. „Verfügbar" nur, wenn es Neues gibt — die Abfrage
dafür ist bei GitHub zwischengespeichert (maintenance._github_json, 15 Minuten)."""
engine = maintenance._installed_engine_build()
swap = maintenance._installed_swap_version()
werte: dict[str, tuple[str | None, str | None]] = {
"os": (_ubuntu(), f"{u['os']} Pakete" if u.get("os") else None),
"engine": (f"b{engine}" if engine else None, None),
"swap": (f"v{swap}" if swap else None, None),
}
try:
if u.get("engine") and (rel := maintenance._latest_engine_asset_release()):
werte["engine"] = (werte["engine"][0], str(rel.get("tag_name") or "") or None)
if u.get("swap"):
rel = maintenance._github_json(f"repos/{maintenance.SWAP_REPO}/releases/latest")
tag = str(rel.get("tag_name") or "") if isinstance(rel, dict) else ""
werte["swap"] = (werte["swap"][0], tag or None)
except Exception:
pass
hermes = next((c for c in u.get("components") or [] if c.get("key") == "hermes_agent"), {})
git = system.find_hermes_agent_git() or {}
version = maintenance._hermes_version(git["path"]) if git.get("path") else None
werte["hermes"] = (version or hermes.get("current"),
f"+{hermes.get('behind')} Änderungen" if hermes.get("update") else None)
return werte
def ki_box_ziel() -> ZielStand:
"""Die KI-Box als Ziel mit ihren vier Bausteinen."""
u = gecacht()
ziel = ZielStand(id="ki-box", name="KI-Box", art="ki-box", instanz="box", erreichbar=True,
geprueft=_updates_cache["ts"] or None,
rueckweg="Sicherung vor jedem Sonntags-Lauf; Motor, llama-swap und Hermes rollen "
"bei rotem Check selbst zurück, das Betriebssystem nicht.")
if not gelesen():
ziel.bausteine = [BausteinStand(id=k, name=n, zustand="wird-geprueft") for k, n in NAMEN.items()]
return ziel
offen = {b["id"]: b["neu"] for b in bausteine(u)}
fehler = {b["id"]: b["grund"] for b in unklar(u)}
fest = {p["baustein"]: p for p in update_verlauf.festgehalten()}
versionen = _versionen(u)
for k, name in NAMEN.items():
installiert, verfuegbar = versionen.get(k, (None, None))
stand = BausteinStand(id=k, name=name, zustand="aktuell", installiert=installiert, verfuegbar=verfuegbar)
if k in fest:
p = fest[k]
stand.zustand, stand.grund = "festgehalten", f"Seit {p['seit']} auf {p['version']}: {p['grund']}"
elif k in fehler:
stand.zustand, stand.grund = "unbekannt", fehler[k]
elif k in offen:
stand.zustand, stand.kurz, stand.aktion = "neu", offen[k], AKTIONEN[k]
ziel.bausteine.append(stand)
return ziel
-34
View File
@@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
}
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
-160
View File
@@ -1,160 +0,0 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
"""
import json
import httpx
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
DEFAULT_HOST = "192.168.178.151"
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
PRIMARY_MODEL = "coder"
def _caps(tools: bool = True, images: bool = False) -> dict:
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
return {"tools": tools, "images": images, "parallel_tool_calls": False,
"prompt_cache_key": False, "chat_completions": True,
"interleaved_reasoning": False, "max_tokens_parameter": False}
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
IDE_MODELS = [
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
]
def _gw(host: str) -> str:
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
return f"http://{host}:{PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST,
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host)
mc_url = f"http://{host}:{PORT}"
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
kilo = json.dumps({
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": PRIMARY_MODEL,
}, indent=2)
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
hermes_desktop = (
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
f"#\n"\
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
f"# (MC2-Proxy auf dem Gateway)\n"\
f"#\n"\
f"# 2. Session-Token: auf der Box liegen in\n"\
f"# ~/.hermes/desktop-gateway-token\n"\
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
f"#\n"\
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
f"#\n"\
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
)
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
claude_code = (
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
f"# • claude-code-router (leichtgewichtig, npm)\n"
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
f"\n"
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
f'export ANTHROPIC_MODEL="coder"'
)
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return {
"host": host,
"gateway_url": gw,
"mc_url": mc_url,
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
# Claude Code (starke Sessions).
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
"tools": {
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · Ask/Debug→chat."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
},
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
}
def check_health() -> dict:
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/v1/models")
if r.status_code == 200:
n = len(r.json().get("data", []))
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
else:
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception: # noqa: BLE001
pass
memory = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{MEM0_SERVICE_URL}/health")
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get("http://127.0.0.1:9119/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
+286
View File
@@ -0,0 +1,286 @@
"""
Die Dienste der KI-Box für die Seite „Dienste und Protokolle“ (seit 25.09.2026 mit Kennzahlen).
Je Dienst steht hier, ob er antwortet (HTTP, wie bisher), in welchem Zustand systemd ihn sieht und was er gerade
braucht:
speicher_bytes MemoryCurrent — was systemd der Unit zurechnet (alle Prozesse, samt Datei-Zwischenspeicher)
grafik_bytes nur die Engine: belegter Grafikspeicher (GTT) der Box — dort liegen die geladenen Modelle, und
den rechnet systemd keinem Dienst zu (25.09.: Engine 4,3 GB laut systemd, Modelle 27,6 GB GTT)
cpu_prozent Anteil an allen Kernen der Box aus CPUUsageNSec zwischen zwei Abfragen (100 % = alle Kerne voll)
laeuft_seit_s seit dem letzten Start (ActiveEnterTimestampMonotonic)
aus_seit_s seit wann ein gestoppter oder gescheiterter Dienst aus ist (InactiveEnterTimestampMonotonic)
neustarts NRestarts — wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat
Die Zahlen kommen mit EINEM `systemctl show` je Bereich (User- und System-Dienste). Auf Windows (Entwicklung) oder
ohne systemd fehlen sie einfach (None); die Liste steht trotzdem, der Zustand dann allein nach „antwortet“.
Bis 25.09.2026 stand die Liste im Router (routers/system.py). Die Felder name, unit, url, ok, scope und schlaeft sind
geblieben — die MCP-Werkzeuge (mcp/mcp_mc.py, mcp/mcp_voice.py) lesen sie.
"""
import os
import subprocess
import threading
import time
import httpx
import psutil
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from services import system, waechter
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable
EIGENSCHAFTEN = (
"Id", "LoadState", "ActiveState", "UnitFileState", "MemoryCurrent", "CPUUsageNSec",
"ActiveEnterTimestampMonotonic", "InactiveEnterTimestampMonotonic", "NRestarts",
)
AKTIV = ("active", "reloading", "refreshing")
# Nur die Engine rechnet auf der Grafik (Stand 25.09.2026: alle GTT-Belegung gehört den llama-server-Prozessen in
# llama-swap.service). Deshalb bekommt allein sie den Grafikspeicher der Box zugeschrieben.
GRAFIK_DIENST = "llama-swap"
CPU_MIN_S = 2.0 # kürzere Abstände zweier Abfragen sind Rauschen: dann gilt die letzte Messung weiter
CPU_MAX_S = 120.0 # eine ältere Probe sagt nichts mehr über „gerade“: neu ansetzen
CPU_KURZ_S = 0.5 # erste Abfrage ohne brauchbare Probe: zweite Probe nach einer halben Sekunde
LAUFEND = ("laeuft", "antwortet_nicht", "startet", "stoppt")
# --- systemctl show lesen ------------------------------------------------------------------------------------------
def lies_show(text: str) -> list[dict[str, str]]:
"""Die Ausgabe von `systemctl show a b c -p …`: je Unit ein Block „Schlüssel=Wert“, Blöcke durch Leerzeilen
getrennt, in der Reihenfolge der Units."""
bloecke: list[dict[str, str]] = []
block: dict[str, str] = {}
for zeile in text.splitlines():
if not zeile.strip():
if block:
bloecke.append(block)
block = {}
continue
if "=" in zeile:
schluessel, wert = zeile.split("=", 1)
block[schluessel.strip()] = wert.strip()
if block:
bloecke.append(block)
return bloecke
def zuordnen(bloecke: list[dict[str, str]], units: list[str]) -> dict[str, dict[str, str]]:
"""Blöcke den Units zuordnen: nach Reihenfolge, wenn die Zahl stimmt (so kommen sie von systemctl), sonst nach Id."""
if len(bloecke) == len(units):
return dict(zip(units, bloecke, strict=True))
return {b["Id"].removesuffix(".service"): b for b in bloecke if b.get("Id")}
def _systemctl_show(units: list[str], system_dienst: bool) -> dict[str, dict[str, str]]:
"""Eigenschaften mehrerer Units mit einem Aufruf. Auf Windows (Entwicklung) oder ohne systemd: leer."""
if not units:
return {}
cmd = ["systemctl"] if system_dienst else ["systemctl", "--user"]
cmd += ["show", *units, "-p", ",".join(EIGENSCHAFTEN)]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
except Exception:
return {}
return zuordnen(lies_show(out), units)
def zahl(wert: str | None) -> int | None:
"""Ganze Zahl aus systemd — „[not set]“, „infinity“ und das alte UINT64_MAX („nicht gezählt“) werden None."""
try:
n = int(wert or "")
except ValueError:
return None
return n if 0 <= n < 2**63 else None
# --- Auswertung (rein, getestet) -----------------------------------------------------------------------------------
def zustand(e: dict[str, str], ok: bool, schlaeft: bool) -> str:
"""Der Zustand in einem Wort: laeuft | antwortet_nicht | startet | stoppt | gestoppt | gescheitert | schlaeft.
Ohne systemd-Angaben (Windows, Unit unbekannt) entscheidet allein, ob der Dienst antwortet."""
if schlaeft:
return "schlaeft"
aktiv = e.get("ActiveState", "")
if not aktiv or e.get("LoadState") in ("not-found", ""):
return "laeuft" if ok else "antwortet_nicht"
if aktiv == "failed":
return "gescheitert"
if aktiv == "activating":
return "startet"
if aktiv == "deactivating":
return "stoppt"
if aktiv == "inactive":
return "gestoppt"
return "laeuft" if ok else "antwortet_nicht" # active, reloading, refreshing
def kennzahlen(e: dict[str, str], jetzt_s: float) -> dict:
"""Speicher, Laufzeit und Neustarts einer Unit. jetzt_s = time.monotonic() beim Lesen — systemd führt seine
…Monotonic-Zeitstempel auf derselben Uhr (CLOCK_MONOTONIC), in Mikrosekunden."""
aktiv = e.get("ActiveState")
an = zahl(e.get("ActiveEnterTimestampMonotonic"))
aus = zahl(e.get("InactiveEnterTimestampMonotonic"))
return {
"speicher_bytes": zahl(e.get("MemoryCurrent")),
"laeuft_seit_s": max(0, int(jetzt_s - an / 1e6)) if aktiv in AKTIV and an else None,
"aus_seit_s": max(0, int(jetzt_s - aus / 1e6)) if aktiv in ("inactive", "failed") and aus else None,
"neustarts": zahl(e.get("NRestarts")),
}
def cpu_anteil(vorher: tuple[float, int], jetzt: tuple[float, int], kerne: int) -> float | None:
"""Anteil an allen Kernen in Prozent zwischen zwei Proben (Zeitpunkt in s, CPU-Zeit der Unit in ns). None, wenn
keine Zeit verging oder der Zähler kleiner wurde (die Unit ist dazwischen neu gestartet)."""
dauer = jetzt[0] - vorher[0]
if dauer <= 0 or jetzt[1] < vorher[1] or kerne < 1:
return None
return round(min(100.0, (jetzt[1] - vorher[1]) / 1e9 / dauer / kerne * 100), 1)
class CpuMessung:
"""Merkt sich je Unit die letzte Probe von CPUUsageNSec und den daraus gemessenen Anteil. Fragt die Oberfläche alle
paar Sekunden, ist das die Auslastung seit der letzten Abfrage — wie bei top, nur je Dienst."""
def __init__(self, kerne: int):
self.kerne = max(1, kerne)
self._proben: dict[str, tuple[float, int]] = {}
self._werte: dict[str, float] = {}
self._sperre = threading.Lock()
def probe(self, unit: str, t: float, ns: int | None) -> None:
with self._sperre:
if ns is None: # läuft nicht, oder systemd zählt ihre CPU-Zeit nicht
self._proben.pop(unit, None)
self._werte.pop(unit, None)
return
vorher = self._proben.get(unit)
if vorher and unit in self._werte and t - vorher[0] < CPU_MIN_S:
return # zu kurz nach der letzten Messung: deren Wert gilt weiter
wert = cpu_anteil(vorher, (t, ns), self.kerne) if vorher and t - vorher[0] <= CPU_MAX_S else None
self._proben[unit] = (t, ns)
if wert is None:
self._werte.pop(unit, None)
else:
self._werte[unit] = wert
def wert(self, unit: str) -> float | None:
with self._sperre:
return self._werte.get(unit)
_cpu = CpuMessung(os.cpu_count() or 1)
_uhr = time.monotonic # dieselbe Uhr wie systemds …Monotonic-Zeitstempel (Tests setzen eine eigene)
# --- Die Liste -----------------------------------------------------------------------------------------------------
def _voice_antwortet() -> bool:
try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _dienste() -> tuple[list[dict], dict]:
"""Die Dienste in ihrer Reihenfolge mit HTTP-Erreichbarkeit (ok) — None heißt: systemd entscheidet (Dienste ohne
eigenen HTTP-Port). `scope`: user|system (Weg für Neustart und Protokoll), `unit`: echter systemd-Name."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
zeilen: list[dict] = [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
"ok": engine_reachable(), "scope": "system"},
]
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
if V1_UPSTREAM:
zeilen.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
"ok": gateway_reachable(), "scope": "user"})
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
zeilen.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url,
"ok": True, "scope": "user"})
else:
zeilen.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
"ok": gateway_reachable(), "scope": "user"})
zeilen += [
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", "ok": None, "scope": "user"},
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"ok": a["hermes_ui_reachable"], "scope": "user"},
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
"ok": _voice_antwortet(), "scope": "user"},
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", "ok": None, "scope": "user"},
]
links = {"engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_ui": a["hermes_ui_url"]}
return zeilen, links
def _lesen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
"""systemd-Stand je Unit samt dem Zeitpunkt, zu dem er gelesen wurde."""
stand: dict[str, tuple[float, dict[str, str]]] = {}
for units, system_dienst in ((user, False), (system_units, True)):
gelesen = _systemctl_show(units, system_dienst)
t = _uhr()
for unit, e in gelesen.items():
stand[unit] = (t, e)
return stand
def _messen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
"""Einmal lesen und die CPU-Proben nachführen. Fehlt einem laufenden Dienst noch ein Wert (erste Abfrage nach dem
Start von MC2, Dienst neu gestartet, lange niemand geschaut), nach einer halben Sekunde ein zweites Mal."""
def nachfuehren(stand: dict[str, tuple[float, dict[str, str]]]) -> bool:
fehlt = False
for unit, (t, e) in stand.items():
# Gestoppte Units behalten den Zähler ihres letzten Laufs (25.09.: projekte-sync, mc2-backup) — der
# sagt nichts über „gerade“.
ns = zahl(e.get("CPUUsageNSec")) if e.get("ActiveState") in (*AKTIV, "activating", "deactivating") else None
_cpu.probe(unit, t, ns)
fehlt = fehlt or (ns is not None and _cpu.wert(unit) is None)
return fehlt
stand = _lesen(user, system_units)
if nachfuehren(stand):
time.sleep(CPU_KURZ_S)
stand = _lesen(user, system_units)
nachfuehren(stand)
return stand
def _speicher_gesamt() -> int | None:
try:
return psutil.virtual_memory().total
except Exception:
return None
def liste() -> dict:
"""Alle Dienste der Box mit Zustand und Kennzahlen — die EINE Quelle für die Seite „Dienste und Protokolle“ und
die MCP-Werkzeuge. speicher_gesamt (Bytes) und kerne sind der Bezug für die Balken der Oberfläche."""
zeilen, links = _dienste()
stand = _messen([z["unit"] for z in zeilen if z["scope"] == "user"],
[z["unit"] for z in zeilen if z["scope"] == "system"])
for z in zeilen:
t, e = stand.get(z["unit"], (0.0, {}))
if z["ok"] is None:
z["ok"] = e.get("ActiveState") == "active"
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
z["schlaeft"] = not z["ok"] and z["scope"] == "user" and waechter.schlaeft(e)
z["zustand"] = zustand(e, z["ok"], z["schlaeft"])
z.update(kennzahlen(e, t))
z["cpu_prozent"] = _cpu.wert(z["unit"]) if z["zustand"] in LAUFEND else None
z["grafik_bytes"] = None
if z["unit"] == GRAFIK_DIENST and z["speicher_bytes"] is not None:
z["grafik_bytes"] = (system._gpu_sysfs() or {}).get("gtt_used")
return {"services": zeilen, "links": links, "speicher_gesamt": _speicher_gesamt(), "kerne": _cpu.kerne}
+2 -5
View File
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import logging
import math
import os
import time
from datetime import datetime
import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
@@ -26,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
+415
View File
@@ -0,0 +1,415 @@
"""
Einstellungen der Oberfläche (seit 24.09.2026): was sich unter „Einstellungen“ wirklich einstellen lässt.
• Update-Zeitfenster Wochentag und Uhrzeit des wöchentlichen Update-Laufs (mc2-autoupdate.timer, Standard
So 04:30). Umgesetzt als Drop-in ~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf,
das deploy.sh nicht anfasst. Neu gestartet wird die Box nur in den drei Stunden ab der vollen
Stunde des Beginns; deploy/wartungsfenster.sh liest das Fenster aus der Einstellungsdatei.
• Modell-Radar mc2-radar.timer (täglich 00:30) an oder aus. deploy.sh respektiert den Schalter.
• Telegram-Test eine Testmeldung über den echten Meldeweg (deploy/notify.sh -d, also auch nachts sofort);
das Ergebnis samt Grund kommt aus dem Melde-Log. Geht auf beiden Instanzen.
Was eingestellt ist, steht in einer Datei im Datenordner (mc2-einstellungen.json; nur MC2 schreibt sie, deploy.sh
und autoupdate.sh lesen sie). Was gilt, sagt systemd. Die Oberfläche zeigt beides: den Wunsch aus der Datei und den
nächsten Lauf laut systemd, und ob beides zusammenpasst.
‼ Nachhol-Falle (24.09.2026): Beide Timer haben Persistent=true. Startet ein Timer mit neuem Plan, holt er einen
Termin, der seit seinem letzten Lauf „verpasst“ wurde, sofort nach — so startete die Box an einem Donnerstagnachmittag
neu. Darum wird der Update-Timer VOR dem daemon-reload angehalten (ein laufender Timer rechnet beim Reload mit seinem
letzten Lauf und dem neuen Plan), und vor jedem Start steht der Stempel ~/.local/share/systemd/timers/stamp-<timer>
auf jetzt: systemd nimmt dessen Zeit als letzten Lauf und plant den nächsten Termin von dort aus.
"""
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen as instanz
from kern.zeit import LOCAL_TZ
# Bewusst kein Import aus services.waechter: Der Telegram-Test läuft auch im Homelab-Teil, und der lädt nichts
# von der KI-Box (waechter zieht llamaswap und maintenance nach, siehe test_partner).
log = logging.getLogger(__name__)
UPDATE_TIMER = "mc2-autoupdate.timer"
UPDATE_DIENST = "mc2-autoupdate.service"
RADAR_TIMER = "mc2-radar.timer"
TAGE = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
SYSTEMD_TAGE = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
STANDARD_FENSTER = {"tag": 7, "uhrzeit": "04:30"}
NEUSTART_DAUER_MIN = 180 # wie WARTUNG_DAUER_MIN in deploy/wartungsfenster.sh
TEST_ABSTAND_S = 20 # höchstens ein Telegram-Test alle 20 s
AUF_DER_BOX = os.name == "posix" # am Windows-PC (Entwicklung) wird nichts geschaltet und nichts gesendet
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
_sperre = threading.Lock()
_test_sperre = threading.Lock()
_letzter_test = {"ts": 0.0}
# --- Orte (zur Laufzeit gelesen, damit Tests sie umlenken können) ------------------------------------
def datei() -> Path:
return Path(os.environ.get("MC_EINSTELLUNGEN", str(instanz().daten_dir / "mc2-einstellungen.json")))
def _user_units() -> Path:
return Path(os.environ.get("MC_SYSTEMD_USER_DIR", str(Path.home() / ".config" / "systemd" / "user")))
def _stempel_ordner() -> Path:
return Path(os.environ.get("MC_TIMER_STEMPEL_DIR", str(Path.home() / ".local" / "share" / "systemd" / "timers")))
def dropin() -> Path:
return _user_units() / f"{UPDATE_TIMER}.d" / "zeitfenster.conf"
def melde_log() -> Path:
return Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
# --- Die Datei -------------------------------------------------------------------------------------
def fenster_fehler(tag: object, uhrzeit: object) -> str | None:
"""Warum ein Zeitfenster ungültig ist — oder None."""
if not isinstance(tag, int) or isinstance(tag, bool) or not 1 <= tag <= 7:
return "Der Wochentag muss zwischen 1 (Montag) und 7 (Sonntag) liegen."
if not isinstance(uhrzeit, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", uhrzeit):
return "Die Uhrzeit braucht die Form HH:MM, etwa 04:30."
return None
def lesen() -> dict:
"""Was eingestellt ist. Fehlt die Datei oder ist ein Wert unsinnig, gilt der Standard (So 04:30, Radar an)."""
try:
roh = json.loads(datei().read_text(encoding="utf-8"))
except (OSError, ValueError):
roh = {}
roh = roh if isinstance(roh, dict) else {}
fenster = roh.get("update_fenster") if isinstance(roh.get("update_fenster"), dict) else {}
tag, uhrzeit = fenster.get("tag"), fenster.get("uhrzeit")
if fenster_fehler(tag, uhrzeit):
tag, uhrzeit = STANDARD_FENSTER["tag"], STANDARD_FENSTER["uhrzeit"]
radar = roh.get("radar") if isinstance(roh.get("radar"), dict) else {}
an = radar.get("an") if isinstance(radar.get("an"), bool) else True
return {"update_fenster": {"tag": tag, "uhrzeit": uhrzeit}, "radar": {"an": an}}
def _speichern(daten: dict) -> None:
"""Atomar über eine Nachbardatei — deploy.sh und autoupdate.sh lesen die Datei jederzeit."""
ziel = datei()
ziel.parent.mkdir(parents=True, exist_ok=True)
tmp = ziel.with_suffix(".json.tmp")
tmp.write_text(json.dumps({**daten, "geaendert": datetime.now(LOCAL_TZ).isoformat(timespec="seconds")},
ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ziel)
# --- Zeitfenster -----------------------------------------------------------------------------------
def neustart_fenster(tag: int, uhrzeit: str) -> str:
"""Wann die Box nach einem Update neu starten darf, in Worten (gleiche Regel wie deploy/wartungsfenster.sh):
drei Stunden ab der vollen Stunde des Beginns. So 04:30 → „Sonntag 04:00–06:59“."""
stunde = int(uhrzeit[:2])
ende = stunde * 60 + NEUSTART_DAUER_MIN - 1
tag_ende = tag
if ende >= 1440:
ende -= 1440
tag_ende = tag % 7 + 1
von, bis = f"{stunde:02d}:00", f"{ende // 60:02d}:{ende % 60:02d}"
if tag_ende == tag:
return f"{TAGE[tag - 1]} {von}–{bis}"
return f"{TAGE[tag - 1]} {von} – {TAGE[tag_ende - 1]} {bis}"
def dropin_text(tag: int, uhrzeit: str) -> str:
"""Das leere OnCalendar= löscht den Plan der Repo-Unit, statt einen zweiten danebenzustellen."""
return ("# Update-Zeitfenster aus den Einstellungen der Oberfläche (backend/services/einstellungen.py).\n"
"# Nicht von Hand ändern: Die Oberfläche schreibt diese Datei beim Speichern neu; deploy.sh lässt sie stehen.\n"
"[Timer]\n"
"OnCalendar=\n"
f"OnCalendar={SYSTEMD_TAGE[tag - 1]} *-*-* {uhrzeit}:00\n")
def _dropin_lesen() -> str | None:
try:
return dropin().read_text(encoding="utf-8")
except OSError:
return None
def _dropin_schreiben(inhalt: str | None) -> None:
"""None heißt: kein Drop-in (zurück auf den Plan der Repo-Unit)."""
pfad = dropin()
if inhalt is None:
pfad.unlink(missing_ok=True)
return
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".conf.tmp")
tmp.write_text(inhalt, encoding="utf-8")
os.replace(tmp, pfad)
# --- systemd ---------------------------------------------------------------------------------------
def _systemctl(*args: str) -> tuple[bool, str]:
"""systemctl --user … — die eine Schicht zu systemd, die Tests ersetzen. Gibt (ok, Ausgabe bzw. Grund)."""
try:
r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError) as exc:
return False, f"systemctl ging nicht ({exc.__class__.__name__}: {exc})"
if r.returncode == 0:
return True, (r.stdout or "").strip()
return False, ((r.stderr or r.stdout or "").strip() or f"systemctl endete mit Code {r.returncode}")[:300]
def _zustand(unit: str) -> dict[str, str]:
ok, ausgabe = _systemctl("show", unit, "-p", "LoadState,ActiveState,UnitFileState")
if not ok:
return {}
return dict(z.split("=", 1) for z in ausgabe.splitlines() if "=" in z)
def _naechster_lauf(timer: str) -> str | None:
ok, ausgabe = _systemctl("list-timers", "--all", "--output=json", timer)
if not ok:
return None
try:
timer_liste = json.loads(ausgabe or "[]")
except ValueError:
return None
for t in timer_liste if isinstance(timer_liste, list) else []:
if isinstance(t, dict) and t.get("unit") == timer and t.get("next"):
try:
return datetime.fromtimestamp(int(t["next"]) / 1_000_000, LOCAL_TZ).isoformat(timespec="seconds")
except (TypeError, ValueError, OverflowError, OSError):
return None
return None
def _stempel_setzen(timer: str) -> None:
"""Letzter Lauf = jetzt: So holt der Timer beim nächsten Start keinen „verpassten“ Termin nach (Persistent=true).
Scheitert das, startet der Timer trotzdem — ein nachgeholter Lauf ist besser als gar keiner mehr."""
try:
pfad = _stempel_ordner() / f"stamp-{timer}"
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.touch(exist_ok=True)
except OSError:
log.warning("einstellungen: Stempel für %s nicht setzbar — der Timer könnte nachholen", timer, exc_info=True)
def _fenster_anwenden(inhalt: str | None) -> tuple[bool, str]:
"""Drop-in schreiben (None: entfernen) und den Update-Timer ohne Nachholen neu planen. Lief der Timer nicht,
bleibt er aus — er übernimmt das Fenster beim nächsten Start."""
lief = _zustand(UPDATE_TIMER).get("ActiveState") == "active"
if lief:
ok, grund = _systemctl("stop", UPDATE_TIMER)
if not ok:
return False, grund
try:
_dropin_schreiben(inhalt)
ok, grund = _systemctl("daemon-reload")
except OSError as exc:
ok, grund = False, f"Drop-in {dropin()} nicht schreibbar: {exc}"
if lief: # auch nach einem Fehler wieder starten: ohne Timer gäbe es gar keine Updates mehr
_stempel_setzen(UPDATE_TIMER)
gestartet, grund_start = _systemctl("start", UPDATE_TIMER)
if ok and not gestartet:
ok, grund = False, grund_start
return ok, grund
# --- Für die Oberfläche ----------------------------------------------------------------------------
def schlaeft(z: dict[str, str]) -> bool:
"""Dieselbe Regel wie waechter.schlaeft (ein Test hält beide gleich): abgeschaltet und nicht mehr für den
Autostart eingetragen = bewusst schlafen gelegt."""
return z.get("ActiveState") == "inactive" and z.get("UnitFileState") == "disabled"
def _radar_zustand(z: dict[str, str]) -> str:
if not z or z.get("LoadState") in ("not-found", ""):
return "unbekannt"
if z.get("ActiveState") == "active":
return "an"
return "schlaeft" if schlaeft(z) else "aus"
def stand() -> dict:
"""Einstellungen samt dem, was systemd daraus gemacht hat."""
daten = lesen()
f = daten["update_fenster"]
update = _zustand(UPDATE_TIMER)
radar = _zustand(RADAR_TIMER)
ist = _dropin_lesen()
fenster_wirksam = (ist == dropin_text(f["tag"], f["uhrzeit"])) if ist is not None else (f == STANDARD_FENSTER)
radar_zustand = _radar_zustand(radar)
radar_an = daten["radar"]["an"]
return {
"schaltbar": AUF_DER_BOX and bool(update or radar),
"update_fenster": {
**f,
"tag_name": TAGE[f["tag"] - 1],
"neustart_fenster": neustart_fenster(f["tag"], f["uhrzeit"]),
"timer_aktiv": update.get("ActiveState") == "active",
"naechster_lauf": _naechster_lauf(UPDATE_TIMER) if update else None,
"wirksam": fenster_wirksam,
},
"radar": {
"an": radar_an,
"zustand": radar_zustand,
"naechster_lauf": _naechster_lauf(RADAR_TIMER) if radar_zustand == "an" else None,
# „aus“ (gestoppt, aber noch im Autostart) passt nicht zu „aus“: nach einem Neustart liefe es wieder.
"wirksam": radar_zustand == "unbekannt" or radar_zustand == ("an" if radar_an else "schlaeft"),
},
}
def update_fenster_setzen(tag: int, uhrzeit: str) -> dict:
"""Knopf „Speichern“ beim Update-Zeitfenster."""
if (fehler := fenster_fehler(tag, uhrzeit)):
return {"ok": False, "detail": fehler}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Das Update-Zeitfenster lässt sich nur auf der Box einstellen."}
with _sperre:
if _zustand(UPDATE_DIENST).get("ActiveState") in ("active", "activating", "deactivating"):
return {"ok": False, "detail": "Gerade läuft das Update. Das Zeitfenster lässt sich danach ändern."}
vorher = _dropin_lesen()
ok, grund = _fenster_anwenden(dropin_text(tag, uhrzeit))
if not ok:
zurueck, _ = _fenster_anwenden(vorher)
return {"ok": False, "detail": f"systemd hat das neue Zeitfenster nicht übernommen: {grund}"
+ ("" if zurueck else " Der Timer lässt sich auch nicht zurückstellen.")}
daten = lesen()
daten["update_fenster"] = {"tag": tag, "uhrzeit": uhrzeit}
try:
_speichern(daten)
except OSError as exc:
return {"ok": False, "detail": f"Der Timer ist umgestellt, die Einstellung ließ sich aber nicht speichern: {exc}"}
return {"ok": True, "text": f"Updates laufen ab jetzt {TAGE[tag - 1]} um {uhrzeit}. Neu gestartet wird die Box "
f"nur {neustart_fenster(tag, uhrzeit)}. Ein verpasster Termin wird nicht nachgeholt.",
"einstellungen": stand()}
def radar_schalten(an: bool) -> dict:
"""Knopf „Einschalten“/„Ausschalten“ beim Modell-Radar."""
if not AUF_DER_BOX:
return {"ok": False, "detail": "Das Radar lässt sich nur auf der Box schalten."}
with _sperre:
if an:
ok, grund = _systemctl("enable", RADAR_TIMER)
if ok:
_stempel_setzen(RADAR_TIMER)
ok, grund = _systemctl("start", RADAR_TIMER)
else: # ein Lauf, der gerade läuft (Nachttest), endet noch von selbst
ok, grund = _systemctl("disable", "--now", RADAR_TIMER)
if not ok:
return {"ok": False, "detail": f"systemd hat das Radar nicht {'eingeschaltet' if an else 'ausgeschaltet'}: "
f"{grund}"}
daten = lesen()
daten["radar"] = {"an": an}
try:
_speichern(daten)
except OSError as exc:
return {"ok": False, "detail": f"Das Radar ist geschaltet, die Einstellung ließ sich aber nicht speichern: "
f"{exc}. Der nächste Deploy könnte es zurückschalten."}
text = ("Das Radar ist an und sucht wieder jede Nacht ab 00:30. Ein verpasster Lauf wird nicht nachgeholt." if an
else "Das Radar ist aus und schläft. Es sucht und testet keine Modelle mehr, bis du es wieder einschaltest.")
return {"ok": True, "text": text, "einstellungen": stand()}
# --- Telegram-Test ---------------------------------------------------------------------------------
_KOPF = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ")
_FALLBACK = "FALLBACK (telegram fehlgeschlagen:"
def log_eintrag(pfad: Path, marke: str, max_bytes: int = 262_144) -> str | None:
"""Der jüngste Eintrag des Melde-Logs, der `marke` enthält — samt Folgezeilen, falls die Ausgabe von
`hermes send` mehrzeilig war (dann beginnt nur die erste Zeile mit dem Zeitstempel)."""
try:
with pfad.open("rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - max_bytes))
zeilen = f.read().decode("utf-8", "replace").splitlines()
except OSError:
return None
for i in range(len(zeilen) - 1, -1, -1):
if marke in zeilen[i]:
anfang = i
while anfang > 0 and not _KOPF.match(zeilen[anfang]):
anfang -= 1
return "\n".join(zeilen[anfang:i + 1])
return None
def deute_eintrag(eintrag: str | None, text: str) -> dict:
"""Was notify.sh mit der Meldung gemacht hat. Wortlaut der Zeilen: deploy/notify.sh."""
if eintrag is None:
return {"gesendet": False, "weg": None, "grund": None}
rest = _KOPF.sub("", eintrag, count=1)
if rest.startswith("OK telegram direkt:"):
return {"gesendet": True, "weg": "direkt", "grund": None}
if rest.startswith("OK telegram:"):
return {"gesendet": True, "weg": "hermes", "grund": None}
if rest.startswith("QUEUED"):
return {"gesendet": False, "weg": None,
"grund": "notify.sh hat sie für die Morgenmeldung zurückgelegt statt sie zu senden."}
if rest.startswith(_FALLBACK):
innen = rest[len(_FALLBACK):]
ende = innen.rfind("): " + text[:40])
grund = " ".join((innen[:ende] if ende >= 0 else innen).split())
return {"gesendet": False, "weg": None,
"grund": grund[:400] or "Telegram hat sie nicht angenommen, ohne Angabe eines Grundes."}
return {"gesendet": False, "weg": None, "grund": " ".join(rest.split())[:300]}
def _notify(bash: str, args: list[str]) -> tuple[int | None, str]:
"""deploy/notify.sh aufrufen — der echte Meldeweg. Lucys Briefkasten und wall bleiben beim Test außen vor."""
env = {**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1", "MC_NOTIFY_OHNE_WALL": "1"}
try:
r = subprocess.run([bash, NOTIFY_SH.as_posix(), *args], env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=90, stdin=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError) as exc:
return None, f"{exc.__class__.__name__}: {exc}"
return r.returncode, (r.stderr or r.stdout or "").strip()
def telegram_test() -> dict:
"""Knopf „Testmeldung senden“: eine kurze Meldung, dringend (-d), damit sie auch nachts sofort rausgeht."""
e = instanz()
jetzt = datetime.now(LOCAL_TZ)
basis = {"instanz": e.instanz, "zeit": jetzt.isoformat(timespec="seconds")}
with _test_sperre:
if time.time() - _letzter_test["ts"] < TEST_ABSTAND_S:
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
"detail": "Der letzte Test ist erst ein paar Sekunden her. Bitte kurz warten."}
_letzter_test["ts"] = time.time()
bash = shutil.which("bash") # voller Pfad: unter Windows fände „bash“ sonst womöglich die WSL-Hülle
if not AUF_DER_BOX or not bash:
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
"detail": "Der Telegram-Test geht nur auf der Box und im Homelab-Teil."}
kennung = secrets.token_hex(3)
text = (f"Testmeldung aus den Einstellungen ({e.instanz}, {jetzt:%d.%m. %H:%M} Uhr, Kennung {kennung}). "
"Kommt sie an, funktioniert der Meldeweg.")
code, ausgabe = _notify(bash, ["-d", "-s", "[Test]", text])
gedeutet = deute_eintrag(log_eintrag(melde_log(), f"Kennung {kennung}"), text)
basis["kennung"] = kennung
if gedeutet["gesendet"]:
weg = "über Hermes" if gedeutet["weg"] == "hermes" else "direkt an die Telegram-Bot-API"
return {**basis, "ok": True, **gedeutet,
"text": f"Die Testmeldung ist raus ({weg}). In Telegram steht sie mit der Kennung {kennung}."}
grund = gedeutet["grund"] or (f"Im Melde-Log {melde_log()} steht nichts zu dieser Testmeldung"
+ (f"; notify.sh endete mit Code {code}" if code is not None else "")
+ (f": {ausgabe[:200]}" if ausgabe else "."))
return {**basis, "ok": False, **gedeutet, "grund": grund, "detail": f"Die Testmeldung ging nicht raus: {grund}"}
-5
View File
@@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+16 -6
View File
@@ -1,10 +1,12 @@
"""
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
"""
from config import PORT
import httpx
from config import PORT, V1_UPSTREAM
from services.llamaswap import engine_reachable
from services.routing_policy import load_policy
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
p = load_policy()
coding_default = p["coder_lite"] or p["coder"]
return {
"mode": "builtin",
"mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
"lanes": [
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
def gateway_reachable() -> bool:
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
"""Erreichbarkeit des ECHTEN Denkpfads. Mit V1_UPSTREAM ist das der eigenständige
mc2-gateway-Prozess (:9010) — vorher meldete diese Funktion nur die Engine, d. h.
ein toter Gateway blieb in Health/Diensten/Cockpit unsichtbar (Review 15.07.)."""
if V1_UPSTREAM:
try:
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
except Exception:
return False
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
return engine_reachable()
+44 -1
View File
@@ -7,11 +7,52 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
import json
import logging
import threading
import time
from services.token_stats import increment_tokens
log = logging.getLogger(__name__)
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
_trunc_last: dict[str, float] = {}
_TRUNC_EVERY = 600.0 # s
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
_PING_MAXTOK = 16
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
return
now = time.time()
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
return
_trunc_last[model] = now
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
def _melde_abriss(model: str) -> None:
try:
from services import announce
announce.add(
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
"[Kontext-Limit]", "gateway", "silent")
except Exception:
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
@@ -23,9 +64,11 @@ def record_usage(usage: dict | None, model: str) -> None:
increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None:
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
warn_truncation(model, max_tokens)
if b'"usage"' not in chunk:
return
text = chunk.decode("utf-8", errors="ignore")
+100
View File
@@ -0,0 +1,100 @@
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
gleichbedeutend mit Root auf der Box gewesen.
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
erreicht — nicht, dass die Datei unlesbar wäre.
"""
import json
import logging
import os
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
ERLAUBT = frozenset({"hf_token"})
def _lesen() -> dict[str, str]:
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
try:
daten = json.loads(PFAD.read_text(encoding="utf-8"))
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
except (OSError, ValueError):
return {}
def _schreiben(daten: dict[str, str]) -> bool:
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
tmp = PFAD.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
tmp.replace(PFAD)
return True
except OSError as exc:
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
return False
def hf_token() -> str | None:
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
def setzen(schluessel: str, wert: str | None) -> bool:
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
if schluessel not in ERLAUBT:
return False
daten = _lesen()
if wert:
daten[schluessel] = wert
else:
daten.pop(schluessel, None)
return _schreiben(daten)
def status() -> dict[str, bool]:
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
daten = _lesen()
return {
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
"schreibbar": _schreibbar(),
}
def _schreibbar() -> bool:
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
return os.access(PFAD.parent, os.W_OK)
except OSError:
return False
+21
View File
@@ -0,0 +1,21 @@
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
"""
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
return True
if not origin or origin in ("null", "file://"):
return True
wirt = origin.split("://", 1)[-1].rstrip("/")
return wirt == (host or "") or wirt in ENTWICKLUNG
+25 -15
View File
@@ -17,10 +17,12 @@ def normalize_repo(s: str) -> str:
return s.strip("/")
def list_quants(repo: str) -> list[str]:
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
def list_quants(repo: str) -> list[dict]:
"""Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), ohne mmproj
als eigene Stufe. Ein Aufruf der Hugging-Face-API für alle Stufen."""
baum = _tree(repo)
quants: set[str] = set()
for e in _tree(repo):
for e in baum:
p = str(e.get("path", ""))
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
@@ -28,18 +30,18 @@ def list_quants(repo: str) -> list[str]:
quants.add(m.group(1).upper())
# gängige Reihenfolge zuerst
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
return sorted(quants, key=lambda q: (order.get(q, 99), q))
return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]}
for q in sorted(quants, key=lambda q: (order.get(q, 99), q))]
def search(q: str = "", limit: int = 24) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
url = (f"https://huggingface.co/api/models?filter=gguf"
f"&sort=downloads&direction=-1&limit={limit}")
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
if q and q.strip():
url += f"&search={q.strip()}"
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
data = c.get("https://huggingface.co/api/models", params=params).json()
except Exception:
return []
out = []
@@ -69,26 +71,34 @@ def _size(entry: dict) -> int:
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
"""Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl)."""
return auswahl(_tree(repo), quant)
def auswahl(tree: list[dict], quant: str = "Q4_K_M") -> dict:
"""GGUF-Auswahl aus dem Dateibaum eines Repos für einen Quant. Behandelt Split-GGUFs
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
"""
tree = _tree(repo)
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
q = quant.lower()
# mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# bevorzugt den gewünschten Quant
pref = [e for e in model if q in e["path"].lower()]
chosen = pref or model
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
chosen = [e for e in model if q in e["path"].lower()]
if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
split = any("-of-" in e["path"].lower() for e in chosen)
if split:
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
def _gruppe(pfad: str) -> str:
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
key=lambda e: e["path"])
files = [e["path"] for e in parts]
first = files[0]
total = sum(_size(e) for e in parts)
+12
View File
@@ -0,0 +1,12 @@
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
sammellauf.py „Alle aktualisieren“: alle Updates mit Knopf nacheinander, bei Rot Schluss
protokoll.py was wann geschah (Aufträge, Läufe, Hinweise, Meldungen, Pflege), ohne Geheimnisse
einstellungen.py Arcane-Schlüssel und Docker-echt-Schalter aus der Oberfläche, Stand für „Einstellungen“
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
"""
+108
View File
@@ -0,0 +1,108 @@
"""AdGuard Home: der Anteil geblockter Anfragen für die Kachel (Ausbauplan Welle 1, seit 25.09.2026).
Die Statistik (GET /control/stats) gibt AdGuard nur mit Anmeldung heraus. Den Zugang trägt der User selbst unter
Einstellungen ein; vor dem Speichern fragt dieser Teil AdGuard damit einmal — lehnt es ab oder antwortet nicht, wird
nichts gespeichert. Er liegt in <Datenordner>/adguard.json (0600) und wird bei jedem Zugriff gelesen: ein neuer gilt
ohne Neustart. Das Passwort steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Benutzernamen
zeigt die Oberfläche, damit man weiß, welcher hinterlegt ist. Am besten ein eigener Benutzer nur dafür.
"""
import json
import os
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from services.homelab import apps, kanal
ZEITLIMIT = httpx.Timeout(10.0, connect=5.0)
CACHE_S = 120
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict | None]] = {}
def _pfad() -> Path:
return einstellungen().daten_dir / "adguard.json"
def zugang() -> tuple[str, str] | None:
"""(Benutzer, Passwort) oder None."""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) and benutzer else None
def zugang_speichern(benutzer: str, passwort: str) -> None:
"""Atomar und von Anfang an nur für den Dienst lesbar (0600)."""
pfad = _pfad()
tmp = pfad.with_name(pfad.name + ".tmp")
with _lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"benutzer": benutzer, "passwort": passwort}, f)
os.replace(tmp, pfad)
_cache.clear()
def zugang_loeschen() -> bool:
with _lock:
pfad = _pfad()
da = pfad.exists()
pfad.unlink(missing_ok=True)
_cache.clear()
return da
def url() -> str | None:
"""Die Oberfläche von AdGuard aus dem Bericht des Ausführers."""
for gast in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if app and app.kennung == "adguard" and (adresse := apps.adresse(app, gast.get("ip"))):
return adresse.rstrip("/")
return None
def statistik(adresse: str, benutzer: str, passwort: str) -> dict:
"""{"ok": True, "anfragen", "geblockt", "anteil"} oder {"ok": False, "status", "art": abgelehnt|http|netz|antwort}.
Nichts davon enthält das Passwort."""
try:
r = httpx.get(f"{adresse}/control/stats", auth=(benutzer, passwort), timeout=ZEITLIMIT)
r.raise_for_status()
daten = r.json()
anfragen, geblockt = int(daten["num_dns_queries"]), int(daten["num_blocked_filtering"])
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
except httpx.HTTPError:
return {"ok": False, "status": None, "art": "netz"}
except (ValueError, KeyError, TypeError):
return {"ok": False, "status": None, "art": "antwort"}
return {"ok": True, "anfragen": anfragen, "geblockt": geblockt,
"anteil": round(geblockt / anfragen * 100, 1) if anfragen else None}
def geblockt() -> dict | None:
"""Für die Kachel: die Statistik mit dem hinterlegten Zugang, höchstens alle CACHE_S neu gefragt. None ohne Zugang,
ohne AdGuard im Bericht oder wenn AdGuard nicht antwortet."""
z, adresse = zugang(), url()
if not z or not adresse:
return None
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
ergebnis = statistik(adresse, *z)
wert = ergebnis if ergebnis["ok"] else None
with _lock:
_cache[adresse] = (jetzt, wert)
return wert
+56
View File
@@ -0,0 +1,56 @@
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen am 24.09. in ct/<app>.sh):
skript `update` im Gast spielt die App wirklich neu ein (Gitea: neues Programm von GitHub,
NetBird: apt aus dem NetBird-Repository, NPMplus: neues Docker-Image)
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class App:
kennung: str
name: str
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
https: bool = False
pfad: str = "/"
weg: str = "skript" # „skript“, „eigene-oberflaeche“ oder „pakete“ (siehe oben)
KATALOG: dict[str, App] = {a.kennung: a for a in (
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080, weg="eigene-oberflaeche"),
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
App("netbird", "NetBird", "netbirdio/netbird"),
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000,
weg="eigene-oberflaeche"),
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True, weg="pakete"),
)}
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
NACH_NAME: dict[str, App] = {
"arcane": App("arcane", "Arcane (Docker)", port=3552, weg="eigene-oberflaeche"),
}
def app_fuer(kennung: str | None, name: str | None) -> App | None:
if kennung and kennung in KATALOG:
return KATALOG[kennung]
return NACH_NAME.get(str(name or "").lower())
def adresse(app: App | None, ip: str | None) -> str | None:
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
if not app or not app.port or not ip:
return None
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
+253
View File
@@ -0,0 +1,253 @@
"""Arcane und Docker auf der Arcane-VM (Phase 3/4, 24.09.2026).
Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine
eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version
(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt.
Der Schlüssel (seit 24.09.2026 auch über die Oberfläche): MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor, sonst
<Datenordner>/arcane.key (0600, schreibt nur die Oberfläche nach einer Prüfung gegen Arcane, einstellungen.py).
Beides wird bei jedem Zugriff gelesen: Ein neuer Schlüssel gilt ohne Neustart. Er steht nie in einer Antwort,
einem Protokoll oder einer Fehlermeldung.
Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid
24.09.: zuerst nur als Probelauf (dryRun); echt erst, wenn der Schalter in den Einstellungen an ist
(<Datenordner>/homelab-einstellungen.json). Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie („1“ = echt).
"""
import json
import os
import threading
import time
from datetime import datetime
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
CACHE_S = 120
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
_datei_lock = threading.Lock()
def _gemerkt(schluessel: str, holen):
"""Arcane merkt sich den Update-Stand selbst; die Übersicht fragt ihn höchstens alle zwei Minuten ab."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(schluessel)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
wert = holen()
with _lock:
_cache[schluessel] = (jetzt, wert)
return wert
def cache_leeren() -> None:
"""Nach einem neuen oder gelöschten Schlüssel: Die Übersicht fragt Arcane gleich neu."""
with _lock:
_cache.clear()
# --- Schlüssel ----------------------------------------------------------------------------------------
def _schluessel_pfad() -> Path:
return einstellungen().daten_dir / "arcane.key"
def _schluessel_datei() -> str:
try:
return _schluessel_pfad().read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def schluessel() -> str:
"""Der geltende Schlüssel: MC_ARCANE_KEY, sonst die Datei, sonst leer."""
return os.environ.get("MC_ARCANE_KEY", "").strip() or _schluessel_datei()
def schluessel_quelle() -> str:
"""„umgebung“ (MC_ARCANE_KEY), „hinterlegt“ (Datei im Datenordner) oder „fehlt“."""
if os.environ.get("MC_ARCANE_KEY", "").strip():
return "umgebung"
return "hinterlegt" if _schluessel_datei() else "fehlt"
def schluessel_speichern(wert: str) -> None:
"""Atomar und von Anfang an nur für den Dienst lesbar (0600): erst eine Nachbardatei, dann ersetzen."""
pfad = _schluessel_pfad()
tmp = pfad.with_name(pfad.name + ".tmp")
with _datei_lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert.strip() + "\n")
os.replace(tmp, pfad)
cache_leeren()
def schluessel_loeschen() -> bool:
"""Die Datei weg. True, wenn es eine gab."""
with _datei_lock:
pfad = _schluessel_pfad()
da = pfad.exists()
pfad.unlink(missing_ok=True)
cache_leeren()
return da
# --- Echt statt Probelauf -----------------------------------------------------------------------------
def _einstellungen_pfad() -> Path:
return einstellungen().daten_dir / "homelab-einstellungen.json"
def _einstellungen_lesen() -> dict:
try:
daten = json.loads(_einstellungen_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def _echt_eingestellt() -> bool | None:
arcane = _einstellungen_lesen().get("arcane")
wert = arcane.get("echt") if isinstance(arcane, dict) else None
return wert if isinstance(wert, bool) else None
def _echt_umgebung() -> str:
return os.environ.get("MC_ARCANE_ECHT", "").strip()
def echt() -> bool:
"""Echte Docker-Updates statt Probelauf? Die Umgebung (MC_ARCANE_ECHT) gewinnt, dann der Schalter, sonst nein."""
if umgebung := _echt_umgebung():
return umgebung == "1"
return bool(_echt_eingestellt())
def echt_quelle() -> str:
"""„umgebung“, „einstellung“ oder „standard“ (Probelauf)."""
if _echt_umgebung():
return "umgebung"
return "einstellung" if _echt_eingestellt() is not None else "standard"
def echt_speichern(an: bool) -> None:
pfad = _einstellungen_pfad()
with _datei_lock:
daten = _einstellungen_lesen()
arcane = daten.get("arcane") if isinstance(daten.get("arcane"), dict) else {}
daten.update(arcane={**arcane, "echt": bool(an)},
geaendert=datetime.now(LOCAL_TZ).isoformat(timespec="seconds"))
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_name(pfad.name + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, pfad)
# --- Schnittstelle ------------------------------------------------------------------------------------
def app_version(url: str) -> dict | None:
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
return _gemerkt(f"version:{url}", lambda: _app_version(url))
def _app_version(url: str) -> dict | None:
try:
d = httpx.get(f"{url}/api/app-version", timeout=ZEITLIMIT).json()
except (httpx.HTTPError, ValueError):
return None
if not isinstance(d, dict) or not d.get("currentVersion"):
return None
return {"installiert": str(d["currentVersion"]).lstrip("v"), "neu": str(d.get("newestVersion") or "").lstrip("v") or None,
"update": bool(d.get("updateAvailable"))}
def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object:
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT)
r.raise_for_status()
return r.json()
def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]:
daten = _get(url, "/environments?limit=50", mit_schluessel)
return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None]
def images_mit_update(url: str) -> list[dict]:
"""Images mit einem neueren Stand in der Registry, über alle Umgebungen: {"name", "aktuell", "neu", "benutzt"}."""
return _gemerkt(f"images:{url}", lambda: _images_mit_update(url))
def _images_mit_update(url: str) -> list[dict]:
ergebnis = []
for umgebung in umgebungen(url):
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500")
for image in (daten or {}).get("data") or []:
info = image.get("updateInfo") or {}
if not info.get("hasUpdate"):
continue
name = f"{image.get('repo') or '?'}:{image.get('tag') or 'latest'}"
ergebnis.append({"name": name, "aktuell": info.get("currentVersion") or None,
"neu": info.get("latestVersion") or None, "umgebung": umgebung["id"],
"benutzt": [str(u.get("name") if isinstance(u, dict) else u) for u in image.get("usedBy") or []]})
return ergebnis
def container_zahlen(url: str) -> dict | None:
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
if not schluessel():
return None
return _gemerkt(f"container:{url}", lambda: _container_zahlen(url))
def _container_zahlen(url: str) -> dict | None:
laufend = gesamt = 0
try:
for umgebung in umgebungen(url):
daten = (_get(url, f"/environments/{umgebung['id']}/containers/counts") or {}).get("data") or {}
laufend += int(daten.get("runningContainers") or 0)
gesamt += int(daten.get("totalContainers") or 0)
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
return None
return {"laufend": laufend, "gesamt": gesamt}
def schluessel_pruefen(url: str, wert: str) -> dict:
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
HTTP-Code bzw. None, "art": "abgelehnt" | "http" | "netz" | "antwort"}. Nichts davon enthält den Schlüssel."""
try:
liste = umgebungen(url, wert)
images = mit_update = 0
for umgebung in liste:
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500", wert)
eintraege = [i for i in (daten or {}).get("data") or [] if isinstance(i, dict)]
images += len(eintraege)
mit_update += sum(1 for i in eintraege if (i.get("updateInfo") or {}).get("hasUpdate"))
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
except httpx.HTTPError:
return {"ok": False, "status": None, "art": "netz"}
except (ValueError, AttributeError, TypeError):
return {"ok": False, "status": None, "art": "antwort"}
return {"ok": True, "umgebungen": len(liste), "images": images, "mit_update": mit_update}
def updater(url: str, probelauf: bool) -> dict:
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
ergebnisse = {}
for umgebung in umgebungen(url):
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/updater/run", headers={"X-API-Key": schluessel()},
json={"dryRun": probelauf}, timeout=httpx.Timeout(600.0, connect=5.0))
r.raise_for_status()
ergebnisse[str(umgebung.get("name") or umgebung["id"])] = (r.json() or {}).get("data") or {}
return ergebnisse
+191
View File
@@ -0,0 +1,191 @@
"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
MC_ARCANE_ECHT in der Umgebung gewinnt.
• AdGuard-Zugang (seit 25.09.2026) Benutzer und Passwort für die Statistik („% geblockt“ auf der Kachel). Vor
dem Speichern fragt AdGuard einmal damit; lehnt es ab, wird nichts gespeichert (adguard.py).
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
Fehlermeldung enthält ihn, auch kein Stück davon.
"""
import json
import logging
import re
import time
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
def arcane_url() -> str | None:
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
eingang = kanal.bericht()
for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
return url.rstrip("/")
return None
def stand() -> dict:
"""GET /api/homelab/einstellungen."""
zuletzt = kanal.zuletzt()
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
return {
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
"echt_quelle": arcane.echt_quelle()},
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
"benutzer": z[0] if z else None},
"karenz_h": round(karenz.karenz_s() / 3600),
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
"sicherung_speicher": sicherung.get("speicher") or None},
}
def schluessel_aus_body(roh: bytes) -> str | None:
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
wiederholen würde. None, wenn der Body nicht passt."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
wert = daten.get("schluessel") if isinstance(daten, dict) else None
return wert if isinstance(wert, str) else None
def _abgelehnt(ergebnis: dict, url: str) -> str:
status, art = ergebnis.get("status"), ergebnis.get("art")
if art == "abgelehnt":
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
if status == 403:
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
return text
if art == "http":
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
if art == "netz":
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
def arcane_schluessel_setzen(wert: str | None) -> dict:
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
if arcane.schluessel_quelle() == "umgebung":
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
wert = (wert or "").strip()
if not _FORM.fullmatch(wert):
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
"Leerzeichen). Nichts gespeichert."}
url = arcane_url()
if not url:
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = arcane.schluessel_pruefen(url, wert)
if not ergebnis["ok"]:
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
try:
arcane.schluessel_speichern(wert)
except OSError as exc:
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
def arcane_schluessel_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
try:
da = arcane.schluessel_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
if arcane.schluessel_quelle() == "umgebung":
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
return {"ok": True, "text": text}
def arcane_echt_setzen(an: bool) -> dict:
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
if arcane.echt_quelle() == "umgebung":
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
"vor diesem Schalter."}
try:
arcane.echt_speichern(an)
except OSError as exc:
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
"startet die Container neu." if an else
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
# --- AdGuard-Zugang (seit 25.09.2026) --------------------------------------------------------------------------------
def zugang_aus_body(roh: bytes) -> tuple[str, str] | None:
"""(Benutzer, Passwort) aus dem Body {"benutzer", "passwort"} — ohne Pydantic, dessen 422 die Eingabe wiederholte."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) else None
def _adguard_abgelehnt(ergebnis: dict, adresse: str) -> str:
art, status = ergebnis.get("art"), ergebnis.get("status")
if art == "abgelehnt":
return f"AdGuard lehnt Benutzer oder Passwort ab (HTTP {status}). Nichts gespeichert."
if art == "http":
return f"AdGuard antwortet mit HTTP {status}. Nichts gespeichert."
if art == "netz":
return f"AdGuard ist nicht erreichbar ({adresse}). Nichts gespeichert."
return "AdGuard antwortet nicht wie erwartet. Nichts gespeichert."
def adguard_zugang_setzen(werte: tuple[str, str] | None) -> dict:
"""POST /api/homelab/einstellungen/adguard-zugang: erst bei AdGuard die Statistik holen, dann speichern."""
benutzer, passwort = (werte[0].strip(), werte[1]) if werte else ("", "")
if not benutzer or not passwort or len(benutzer) > 200 or len(passwort) > 500:
return {"ok": False, "detail": "Benutzer und Passwort eintragen. Nichts gespeichert."}
adresse = adguard.url()
if not adresse:
return {"ok": False, "detail": "AdGuard steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = adguard.statistik(adresse, benutzer, passwort)
if not ergebnis["ok"]:
log.info("homelab: AdGuard-Zugang nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _adguard_abgelehnt(ergebnis, adresse)}
try:
adguard.zugang_speichern(benutzer, passwort)
except OSError as exc:
return {"ok": False, "detail": f"AdGuard nimmt den Zugang an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
anteil = ergebnis["anteil"]
return {"ok": True, "text": (f"AdGuard nimmt den Zugang an: {ergebnis['anfragen']} Anfragen"
+ (f", davon {str(anteil).replace('.', ',')} % geblockt" if anteil is not None else "")
+ ". Die Kachel zeigt den Anteil ab sofort.")}
def adguard_zugang_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/adguard-zugang."""
try:
da = adguard.zugang_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Zugang ließ sich nicht löschen ({exc.__class__.__name__})."}
return {"ok": True, "text": "Der AdGuard-Zugang ist gelöscht." if da else "Es war kein AdGuard-Zugang hinterlegt."}
+345
View File
@@ -0,0 +1,345 @@
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
die Übersicht, dass der Ausführer schweigt. Ist das Update-Skript einer App frisch geändert, bleibt sie
„neu“, aber ohne Knopf (karenz.py). Rückweg und Rückfrage sagen, was vor dem Update geschieht: Snapshot,
wo keiner geht eine Sicherung, sonst keiner. Ohne Freigabe (Etikett) legt der Ausführer weder das eine noch
das andere an — das betrifft Arcane, dessen Docker-Updates auch ohne Etikett über Arcane selbst laufen.
"""
import re
import threading
import time
from datetime import datetime
import httpx
from kern.github import neueste_version
from kern.zeit import LOCAL_TZ
from kern.ziele import Aktion, BausteinStand, ZielStand
from services.homelab import apps, arcane, kanal, karenz
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
EIGENES_ETIKETT = "mc2"
ERREICHBAR_CACHE_S = 60
OHNE_FREIGABE = "Das Etikett „watcher“ fehlt, der Ausführer darf hier keinen Snapshot anlegen"
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
_erreichbar_lock = threading.Lock()
def erreichbar(url: str) -> bool:
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
jetzt = time.time()
with _erreichbar_lock:
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
return eintrag[1]
try:
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
except httpx.HTTPError:
ok = False
with _erreichbar_lock:
_erreichbar_cache[url] = (jetzt, ok)
return ok
def erreichbar_frisch(url: str) -> bool:
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
with _erreichbar_lock:
_erreichbar_cache.pop(url, None)
return erreichbar(url)
def _teile(version: str) -> tuple[int, ...]:
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
def neuer(verfuegbar: str, installiert: str) -> bool | None:
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
a, b = _teile(verfuegbar), _teile(installiert)
if not a or not b:
return None
return a > b
def _datum(ts: float | None) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
def _rueckweg(gast: dict) -> str:
"""Die Zeile „Rückweg“ der Karte: was vor jedem Update geschieht."""
if not gast.get("erlaubt"):
return f"Kein automatischer Rückweg: {OHNE_FREIGABE}."
if gast.get("snapshot_moeglich"):
return "Snapshot vor jedem Update"
grund = gast.get("snapshot_grund") or "kein Snapshot möglich"
if gast.get("sicherung_moeglich"):
return f"Sicherung auf „{gast.get('sicherung_speicher') or 'dem Host'}“ vor jedem Update ({grund})"
if gast.get("sicherung_grund"):
return f"Kein automatischer Rückweg: {grund}. Auch keine Sicherung: {gast['sicherung_grund']}"
return grund
def _rueckweg_art(gast: dict) -> str:
"""Dasselbe in einem Wort — die Oberfläche fasst den Rückweg in der Update-Liste kurz."""
if not gast.get("erlaubt"):
return "keiner"
if gast.get("snapshot_moeglich"):
return "snapshot"
return "sicherung" if gast.get("sicherung_moeglich") else "keiner"
def _rueckweg_frage(gast: dict) -> str:
"""Der Satz zum Rückweg in der Rückfrage vor „Jetzt updaten“."""
if not gast.get("erlaubt"):
return f"{OHNE_FREIGABE} — scheitert das Update, gibt es keinen automatischen Rückweg."
if gast.get("snapshot_moeglich"):
return "Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
if gast.get("sicherung_moeglich"):
return "Vorher wird eine Sicherung angelegt; ist die Prüfung danach rot, wird sie zurückgespielt."
satz = "Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg."
if gast.get("sicherung_grund"):
satz += f" Eine Sicherung geht auch nicht: {gast['sicherung_grund']}"
return satz
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
installiert = (gast.get("app") or {}).get("version")
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
if not app.quelle:
return stand
try:
neu = neueste_version(app.quelle)
except Exception:
neu = None
if not neu:
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
return stand
stand.verfuegbar = neu["version"]
if installiert and installiert.startswith("untagged-"):
# Manche Projekte veröffentlichen ohne Versions-Tag; die App kennt dann nur dieses Tag (PVE Scripts Local).
if installiert == neu.get("tag_roh"):
stand.installiert = neu["version"]
return stand
stand.zustand, stand.grund = "unbekannt", "Die installierte Fassung lässt sich keiner Version zuordnen."
return stand
if app.vergleich == "datum":
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
else:
ist_neuer = neuer(neu["version"], installiert) if installiert else None
if ist_neuer is None:
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
elif ist_neuer and app.weg != "skript":
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht."
elif ist_neuer:
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
wartezeit = karenz.pruefen(app.kennung)
if wartezeit["gesperrt"]:
stand.grund = wartezeit["gesperrt"]
return stand
frage = f"{app.name} jetzt aktualisieren? {_rueckweg_frage(gast)}"
if not wartezeit["geprueft"]:
frage += f" {karenz.NICHT_GEPRUEFT}"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", frage)
return stand
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
os_stand = gast.get("os_updates") or {}
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
label="Nach Updates suchen")
if anzahl is None:
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
elif listen and jetzt - listen > LISTEN_ALT_S:
stand.zustand, stand.aktion = "unbekannt", suchen
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
elif anzahl > 0:
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
f"{anzahl} Pakete im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
return stand
def _arcane_bausteine(basis: str | None, zid: str, gast: dict) -> list[BausteinStand]:
"""Arcane selbst (öffentliche Version) und die Docker-Images (mit API-Schlüssel)."""
app = arcane.app_version(basis) if basis else None
selbst = BausteinStand(id="arcane", name="Arcane", zustand="unbekannt",
installiert=app["installiert"] if app else None, verfuegbar=app["neu"] if app else None)
if not app:
selbst.grund = "Arcane antwortet nicht."
elif app["update"]:
selbst.zustand, selbst.kurz = "neu", f"{app['installiert']} → {app['neu']}"
selbst.grund = "Arcane spielt sein eigenes Update über die eigene Oberfläche ein (Einstellungen)."
else:
selbst.zustand = "aktuell"
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
if not arcane.schluessel():
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. "
"Eintragen unter Einstellungen (Arcane-API-Schlüssel).")
elif basis:
try:
images = arcane.images_mit_update(basis)
except Exception:
docker.grund = "Arcane verweigert die Auskunft oder antwortet nicht (Schlüssel prüfen)."
else:
# Nur Images, die ein Container benutzt: Arcanes Updater tauscht Container aus. Ein veraltetes Image ohne
# Container (Basis-Images für Builds, CI) bliebe sonst für immer „neu“ (25.09.2026: 9 von 11 nach dem Lauf).
ohne = [i for i in images if not i.get("benutzt")]
images = [i for i in images if i.get("benutzt")]
if ohne:
docker.grund = (f"{len(ohne)} ältere Images ohne Container ({', '.join(i['name'] for i in ohne[:4])}"
+ (" …" if len(ohne) > 4 else "") + ") betreffen nichts; aufräumen in Arcane unter Images.")
if images:
docker.zustand, docker.kurz = "neu", f"{len(images)} Images"
docker.grund = ", ".join(i["name"] for i in images[:6]) + (" …" if len(images) > 6 else "")
probe = not arcane.echt()
docker.aktion = Aktion(
"POST", f"/api/homelab/ziele/{zid}/docker",
("Arcane prüft im Probelauf, welche Container es aktualisieren würde — geändert wird noch nichts."
if probe else "Arcane aktualisiert jetzt die Container mit neuen Images; Daten-Volumes bleiben. "
+ _rueckweg_frage(gast)),
label="Probelauf" if probe else "Jetzt updaten")
else:
docker.zustand = "aktuell"
return [selbst, docker]
def _status_text(status: object) -> str:
"""Warum ein Gast keine Bausteine hat — auf Deutsch. „unknown“ kommt kurz nach dem Hochfahren des Hosts, bevor
Proxmox den Zustand kennt (der Ausführer berichtet dann nach einer Minute erneut)."""
if status == "stopped":
return "Der Gast ist gestoppt."
if status == "paused":
return "Der Gast ist angehalten."
return "Proxmox kennt seinen Zustand gerade nicht (etwa kurz nach dem Hochfahren des Hosts)."
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
art = "container" if gast["art"] == "lxc" else "vm"
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
url = apps.adresse(app, gast.get("ip"))
laeuft = gast.get("status") == "running"
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast), platte=gast.get("platte"))
if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=_status_text(gast.get("status"))))
return ziel
if app and app.kennung == "arcane":
# Arcane läuft über seine eigene Schnittstelle, nicht über den Ausführer: kein Etikett nötig — nur für den
# Snapshot vorher (Rückweg), den legt der Ausführer an.
ziel.bausteine += _arcane_bausteine(url.rstrip("/") if url else None, zid, gast)
return ziel
if not gast.get("erlaubt"):
ziel.bausteine.append(BausteinStand(
id="freigabe", name="Freigabe", zustand="unbekannt",
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
return ziel
if app and app.kennung in apps.KATALOG:
ziel.bausteine.append(_app_baustein(gast, app, zid))
if gast["art"] == "lxc":
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
return ziel
def _host_ziel(host: dict) -> ZielStand:
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.",
rueckweg_art="keiner")
updates = host.get("updates")
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
installiert=host.get("version"))
if updates is None:
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
elif updates:
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
ziel.bausteine.append(pakete)
if host.get("neustart_noetig"):
ziel.bausteine.append(BausteinStand(
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
installiert=host.get("kernel"), verfuegbar=host.get("kernel_neu"),
grund=(f"Neuer Kernel {host['kernel_neu']} ist installiert, es läuft noch {host.get('kernel')}."
if host.get("kernel_neu") else "Ein Update verlangt einen Neustart."),
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
label="Jetzt neu starten")))
return ziel
def ziele() -> dict:
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
eingang = kanal.bericht()
jetzt = time.time()
zuletzt = kanal.zuletzt()
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
if not eingang:
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
b = eingang["bericht"]
liste = [_host_ziel(b.get("host") or {})]
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
zahlen = _kennzahlen(b)
for z in liste:
z.geprueft = eingang["empfangen"]
z.kennzahl = zahlen.get(z.id)
return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer}
def _kennzahlen(bericht: dict) -> dict:
"""Die Live-Zahlen der Kacheln (kennzahlen.py). Scheitert etwas, zeigen die Kacheln eben keine — die Liste der
Ziele darf daran nie hängen."""
from services.homelab import kennzahlen # braucht zertifikate, adguard und arcane — erst hier
try:
return kennzahlen.fuer_bericht(bericht)
except Exception:
return {}
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
herauskommt, meldet der Lauf selbst (sonst käme ein zweiter Alarm samt „wieder ok“)."""
from services.homelab import updates # updates importiert dieses Modul
eingang = kanal.bericht()
im_update = updates.laufende_ziele()
ergebnis = []
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
url = apps.adresse(app, g.get("ip"))
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
if url and g.get("erlaubt") and zid not in im_update:
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
"laeuft": g.get("status") == "running",
"ok": g.get("status") == "running" and erreichbar(url)})
return ergebnis
def gast(zid: str) -> dict | None:
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
eingang = kanal.bericht()
if not m or not eingang:
return None
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
+242
View File
@@ -0,0 +1,242 @@
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
Erledigte Aufträge bleiben (seit 24.09.2026) bis zu BEHALTEN Stück stehen — das Protokoll der Oberfläche
(protokoll.py) liest sie. Die jüngsten VOLLTEXT behalten ihre ganze Ausgabe, ältere nur deren Ende (TEXT_KURZ
Zeichen, so viel zeigt das Protokoll): Die Datei wird bei jedem Auftrag gelesen und geschrieben.
"""
import hmac
import json
import os
import secrets
import threading
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from kern.einstellungen import einstellungen
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
"kernel_aufraeumen", "sicherung_probe"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen
BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag)
_lock = threading.Lock()
_kontakt: dict = {"zuletzt": None}
def _dir() -> Path:
return einstellungen().daten_dir
@contextmanager
def _sperre() -> Iterator[None]:
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
with _lock:
if fcntl is None:
yield
return
pfad = _dir() / "ausfuehrer-auftraege.lock"
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def _json_schreiben(pfad: Path, daten: object) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
def _json_lesen(pfad: Path, leer: object) -> object:
try:
return json.loads(pfad.read_text(encoding="utf-8"))
except (OSError, ValueError):
return leer
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
def token() -> str:
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
pfad = _dir() / "ausfuehrer.token"
try:
return pfad.read_text(encoding="utf-8").strip()
except OSError:
pass
wert = secrets.token_urlsafe(32)
pfad.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
return pfad.read_text(encoding="utf-8").strip()
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert + "\n")
return wert
def token_gueltig(wert: str | None) -> bool:
return bool(wert) and hmac.compare_digest(str(wert), token())
def kontakt() -> None:
_kontakt["zuletzt"] = time.time()
def zuletzt() -> float | None:
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
# --- Bericht ------------------------------------------------------------------------------
def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None:
"""Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag),
wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag
„bericht“ geholt (updates.py); dann steht er dort schon."""
jetzt = time.time()
with _lock:
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten})
if aus_auftrag:
return
host = daten.get("host") if isinstance(daten.get("host"), dict) else {}
gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)]
eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste),
"laufend": sum(1 for g in gaeste if g.get("status") == "running"),
"host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None}
pfad = _dir() / "ausfuehrer-berichte.json"
eingaenge = _json_lesen(pfad, [])
eingaenge = eingaenge if isinstance(eingaenge, list) else []
_json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:])
def berichte() -> list[dict]:
"""Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates."""
with _lock:
daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", [])
return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else []
def bericht() -> dict | None:
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
with _lock:
daten = _json_lesen(_dir() / "pve-bericht.json", None)
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
# --- Aufträge -------------------------------------------------------------------------------
def _pfad() -> Path:
return _dir() / "ausfuehrer-auftraege.json"
def _alle() -> list[dict]:
daten = _json_lesen(_pfad(), [])
return daten if isinstance(daten, list) else []
def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
for a in fertig[:-VOLLTEXT]:
text = a.get("text")
if isinstance(text, str) and len(text) > TEXT_KURZ:
a["text"] = "…" + text[-(TEXT_KURZ - 1):]
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
def anlegen(aktion: str, parameter: dict | None = None) -> str:
if aktion not in AKTIONEN:
raise ValueError(f"Unbekannte Aktion: {aktion}")
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
"code": None, "text": None}
with _sperre():
auftraege = _alle()
auftraege.append(auftrag)
_merken(auftraege)
return auftrag["id"]
def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle
fünf Sekunden."""
jetzt = time.time()
with _sperre():
auftraege = _alle()
geaendert = False
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
geaendert = True
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt)
geaendert = True
if geaendert:
_merken(auftraege)
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
with _sperre():
auftraege = _alle()
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
if auftrag is None or auftrag["status"] != "laeuft":
return False
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
text=str(text)[-20000:])
_merken(auftraege)
return True
def auftrag(auftrag_id: str) -> dict | None:
# Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen,
# solange ein anderer Thread die Datei offen hat.
with _lock:
return next((a for a in _alle() if a["id"] == auftrag_id), None)
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
ende = time.time() + zeitlimit_s
while time.time() < ende:
a = auftrag(auftrag_id)
if a and a["status"] not in ("wartet", "laeuft"):
return a
time.sleep(takt_s)
return None
def liste() -> list[dict]:
with _lock:
return list(reversed(_alle()))
+61
View File
@@ -0,0 +1,61 @@
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026).
„Jetzt updaten“ lässt im Gast `update` laufen. Das lädt ct/<kennung>.sh ungepinnt von GitHub
(community-scripts/ProxmoxVE, Zweig main) und führt es als root aus. Eine Änderung, die erst ein paar Stunden
alt ist, hat noch kaum jemand im Betrieb gesehen. Deshalb gilt für Apps mit dem Weg „skript“: Ist die letzte
Änderung an ct/<kennung>.sh jünger als MC_HOMELAB_KARENZ_H Stunden (Standard 48), bleibt der Baustein „neu“,
aber ohne Knopf, und updates.starten() lehnt ab — beide mit demselben Satz.
Wann das Skript zuletzt geändert wurde, sagt die GitHub-API (kern/github.py, 15 Minuten gemerkt). Lässt sich das
nicht abfragen, wird nicht blockiert; die Rückfrage vor dem Update sagt es dann (NICHT_GEPRUEFT).
"""
import os
import re
import time
from datetime import datetime
from kern.github import github_json
from kern.zeit import LOCAL_TZ
REPO = "community-scripts/ProxmoxVE"
STANDARD_H = 48.0
NICHT_GEPRUEFT = "Ob das Skript kürzlich geändert wurde, ließ sich nicht prüfen."
_KENNUNG = re.compile(r"^[a-z0-9-]+$")
def karenz_s() -> float:
"""Die Wartezeit in Sekunden (MC_HOMELAB_KARENZ_H; 0 schaltet sie ab)."""
try:
stunden = float(os.environ.get("MC_HOMELAB_KARENZ_H", "") or STANDARD_H)
except ValueError:
stunden = STANDARD_H
return max(0.0, stunden) * 3600
def skript_geaendert(kennung: str) -> float:
"""Wann ct/<kennung>.sh auf main zuletzt geändert wurde (Unix-Sekunden). Wirft, wenn es sich nicht sagen lässt."""
if not _KENNUNG.match(kennung or ""):
raise ValueError(f"ungültige Kennung {kennung!r}")
daten = github_json(f"repos/{REPO}/commits?path=ct/{kennung}.sh&sha=main&per_page=1")
# Das Datum, an dem die Änderung auf main landete (committer, nicht author: ein Pull-Request kann älter sein).
datum = str(daten[0]["commit"]["committer"]["date"])
return datetime.fromisoformat(datum.replace("Z", "+00:00")).timestamp()
def sperrtext(geaendert: float, frei_ab: float) -> str:
a, b = datetime.fromtimestamp(geaendert, LOCAL_TZ), datetime.fromtimestamp(frei_ab, LOCAL_TZ)
return f"Das Update-Skript wurde am {a:%d.%m.} geändert; zur Sicherheit erst ab {b:%d.%m. %H:%M}."
def pruefen(kennung: str, jetzt: float | None = None) -> dict:
"""{"gesperrt": Satz oder None, "geprueft": bool}. Gesperrt, solange die letzte Änderung am Skript jünger ist
als die Wartezeit. Scheitert die Abfrage: nicht gesperrt, aber geprueft=False."""
try:
geaendert = skript_geaendert(kennung)
except Exception:
return {"gesperrt": None, "geprueft": False}
frei_ab = geaendert + karenz_s()
if (time.time() if jetzt is None else jetzt) >= frei_ab:
return {"gesperrt": None, "geprueft": True}
return {"gesperrt": sperrtext(geaendert, frei_ab), "geprueft": True}
+68
View File
@@ -0,0 +1,68 @@
"""Eine Live-Zahl je Gerät für die Kacheln des Homelab-Cockpits und der Übersicht (Ausbauplan Welle 1, seit 25.09.2026).
Proxmox-Host wie viele Gäste laufen (aus dem Bericht)
PBS wann zuletzt gesichert wurde (jüngste Sicherung auf den Zielen der Aufträge)
NPMplus wie es um seine Zertifikate steht (Zertifikats-Wache, zertifikate.py)
Arcane laufende von allen Containern (Arcane-API mit dem Schlüssel aus den Einstellungen)
AdGuard Home Anteil geblockter Anfragen (nur mit hinterlegtem Zugang, Einstellungen; adguard.py)
Je Ziel-ID {"text", "zeit"?, "stufe"?}: „zeit“ setzt die Oberfläche als „vor …“ dahinter, „stufe“ (gelb/rot) färbt die
Zeile. Was sich nicht ermitteln lässt, fehlt — die Kachel zeigt dann nur ihren Stand. Arcane und AdGuard werden
höchstens alle zwei Minuten gefragt.
"""
import logging
from services.homelab import adguard, apps, arcane, zertifikate
log = logging.getLogger(__name__)
NPM = {"npmplus", "nginxproxymanager"}
def _komma(zahl: float) -> str:
return f"{zahl:.1f}".replace(".", ",") if zahl < 10 else str(round(zahl))
def _npm(vmid: int) -> dict | None:
eigene = [z for z in zertifikate.lage()["zertifikate"] if z["quelle"] == "npm" and z["gast"] == vmid]
if not eigene:
return None
probleme = [z for z in eigene if z["stufe"] != "ok"]
if not probleme:
return {"text": f"{len(eigene)} Zertifikat{'e' if len(eigene) != 1 else ''} gültig"}
stufe = "rot" if any(z["stufe"] == "rot" for z in probleme) else "gelb"
return {"text": f"{len(probleme)} von {len(eigene)} Zertifikaten mit Problem", "stufe": stufe}
def fuer_bericht(bericht: dict) -> dict[str, dict]:
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
zahlen: dict[str, dict] = {}
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g]
if gaeste:
laufend = sum(1 for g in gaeste if g.get("status") == "running")
zahlen["pve"] = {"text": f"{laufend} von {len(gaeste)} Gästen an"}
ziele = (bericht.get("host") or {}).get("sicherungsziele") or {}
for g in gaeste:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
if not app or g.get("status") != "running":
continue
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
try:
if app.kennung == "proxmox-backup-server":
zuletzt = max((t for z in ziele.values() if isinstance(z, dict)
for t in (z.get("neueste") or {}).values() if isinstance(t, int)), default=None)
if zuletzt:
zahlen[zid] = {"text": "letzte Sicherung", "zeit": zuletzt}
elif app.kennung in NPM:
if zahl := _npm(g["vmid"]):
zahlen[zid] = zahl
elif app.kennung == "arcane" and (url := apps.adresse(app, g.get("ip"))):
container = arcane.container_zahlen(url.rstrip("/"))
if container and container["gesamt"]:
zahlen[zid] = {"text": f"{container['laufend']} von {container['gesamt']} Containern an"}
elif app.kennung == "adguard":
statistik = adguard.geblockt()
if statistik and statistik.get("anteil") is not None:
zahlen[zid] = {"text": f"{_komma(statistik['anteil'])} % geblockt"}
except Exception:
log.warning("kennzahlen: %s ließ sich nicht ermitteln", zid, exc_info=True)
return zahlen
+249
View File
@@ -0,0 +1,249 @@
"""Messwerte des Homelabs mit Verlauf (Monitoring, seit 24.09.2026).
Der Ausführer auf dem Proxmox-Host schickt jede Minute POST /api/homelab/ausfuehrer/messwerte (eigener Faden, unabhängig
vom 10-min-Bericht und von Aufträgen):
{"zeit": Unix-Sekunden der Messung,
"host": {"cpu": 0–1 (Mittel der Minute), "speicher": {"belegt", "gesamt"}, "rootfs": {"belegt", "gesamt"}, "load1",
"uptime", "temp_cpu" (°C oder null), "netz": {"rx", "tx"} (kumulativ, Bytes)},
"gaeste": [{"vmid", "art" (lxc|qemu), "name", "etiketten", "status", "cpu" (0–1 der eigenen Kerne), "maxcpu", "mem",
"maxmem", "disk", "maxdisk", "netin", "netout" (kumulativ, Bytes), "uptime"}]}
Hier wird daraus je Gerät ein Punkt über kern/messreihen.py (Quellen „pve“, „ct-<vmid>“, „vm-<vmid>“ wie im Inventar):
CPU, RAM und Platte in %, Netz in Bytes/s aus der Differenz zum vorigen Zählerstand. Ist ein Zähler kleiner geworden
oder die Laufzeit (Neustart des Gasts oder Hosts), fehlt der vorige Stand (Neustart dieses Teils) oder liegt er mehr als
fünf Minuten zurück, bleibt die Rate dieser Minute null. Gestoppte Gäste haben keine Messwerte (null), keine Nullen.
Der eigene Container (Etikett „mc2“) wird wie im Inventar nicht erfasst. Die Platte von VMs sieht Proxmox nicht (null).
GET /api/homelab/messwerte liefert je Gerät die Reihen (1h, 24h, 7d) und den letzten Punkt; pruefe_host() meldet dem
Wächter, wenn der Host zehn Minuten lang über 95 % RAM oder 90 °C liegt.
"""
import os
import re
import threading
import time
from kern import messreihen
from services.homelab import apps, inventar, kanal, speicher
HOST = "pve"
_GAST = re.compile(r"(ct|vm)-\d+")
ZEITRAEUME = messreihen.ZEITRAEUME
HOST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu")
GAST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx")
AKTUELL = ("cpu", "ram", "ram_used", "ram_total", "platte", "netz_rx", "netz_tx", "temp_cpu", "load1", "uptime_s")
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Ausführer schickt gerade nichts)
ZEIT_TOLERANZ_S = 120 # die Messzeit des Ausführers gilt, wenn sie so nah an der eigenen Uhr liegt
# Wächter: der Host zehn Minuten lang über diesen Schwellen → gelber Hinweis.
WACHE_S = 600
WACHE_MIN_PUNKTE = 8 # so viele Minutenpunkte müssen in den zehn Minuten liegen (ein, zwei dürfen fehlen)
RAM_GELB = float(os.environ.get("MC_WAECHTER_HOST_RAM", "95"))
TEMP_GELB = float(os.environ.get("MC_WAECHTER_HOST_TEMP", "90"))
_lock = threading.Lock()
# Vorige Zählerstände je Gerät (für die Raten) und die Geräte der letzten Meldung (für Namen und die Geräteliste).
# Nur im Speicher: Nach einem Neustart dieses Teils fehlt für eine Minute die Netz-Rate, sonst nichts.
_zustand: dict = {"zaehler": {}, "geraete": {}}
def _zahl(wert: object) -> float | None:
return float(wert) if messreihen.ist_zahl(wert) else None
def _ganz(wert: object) -> int | None:
return int(wert) if messreihen.ist_zahl(wert) else None
def _prozent(anteil: object) -> float | None:
"""0–1 → %, eine Nachkommastelle, auf 0–100 begrenzt."""
return round(min(100.0, max(0.0, float(anteil) * 100)), 1) if messreihen.ist_zahl(anteil) else None
def _anteil(teil: object, ganz: object) -> float | None:
if not (messreihen.ist_zahl(teil) and messreihen.ist_zahl(ganz)) or float(ganz) <= 0:
return None
return _prozent(float(teil) / float(ganz))
def _dict(wert: object) -> dict:
return wert if isinstance(wert, dict) else {}
def gast_id(gast: dict) -> str | None:
"""ct-<vmid> bzw. vm-<vmid> wie im Inventar; None bei allem, was kein Gast ist."""
vmid, art = gast.get("vmid"), gast.get("art")
if not isinstance(vmid, int) or isinstance(vmid, bool) or not 100 <= vmid <= 999_999_999:
return None
return {"lxc": f"ct-{vmid}", "qemu": f"vm-{vmid}"}.get(art)
def _art(geraet_id: str) -> str:
return "proxmox-host" if geraet_id == HOST else "container" if geraet_id.startswith("ct-") else "vm"
# --- Zähler → Bytes/s ------------------------------------------------------------------------------
def raten(vorher: dict | None, jetzt: dict) -> tuple[int | None, int | None]:
"""Empfangen und gesendet in Bytes/s zwischen zwei Zählerständen {"t", "rx", "tx", "uptime", "laeuft"}. None, wenn
es keinen vorigen Stand gibt, einer der beiden nicht lief, die Laufzeit kleiner wurde (neu gestartet: die Zähler
begannen von vorn) oder messreihen.rate() den Zuwachs verwirft (Zähler kleiner, Lücke, Sprung)."""
if not vorher or not vorher.get("laeuft") or not jetzt.get("laeuft"):
return None, None
alt_lauf, neu_lauf = vorher.get("uptime"), jetzt.get("uptime")
if messreihen.ist_zahl(alt_lauf) and messreihen.ist_zahl(neu_lauf) and neu_lauf < alt_lauf:
return None, None
dt = jetzt["t"] - vorher["t"]
rx, tx = messreihen.rate(vorher.get("rx"), jetzt.get("rx"), dt), messreihen.rate(vorher.get("tx"), jetzt.get("tx"), dt)
return (None if rx is None else round(rx)), (None if tx is None else round(tx))
def _zaehlen(geraet_id: str, t: float, rx: object, tx: object, uptime: object, laeuft: bool) -> tuple:
"""Den neuen Zählerstand merken und die Raten gegen den vorigen liefern (unter _lock aufrufen)."""
stand = {"t": t, "rx": rx, "tx": tx, "uptime": uptime, "laeuft": laeuft}
ergebnis = raten(_zustand["zaehler"].get(geraet_id), stand)
_zustand["zaehler"][geraet_id] = stand
return ergebnis
def _host_punkt(host: dict, t: float) -> dict:
speicher, rootfs, netz = _dict(host.get("speicher")), _dict(host.get("rootfs")), _dict(host.get("netz"))
rx, tx = _zaehlen(HOST, t, netz.get("rx"), netz.get("tx"), host.get("uptime"), True)
temp, last = _zahl(host.get("temp_cpu")), _zahl(host.get("load1"))
return {"cpu": _prozent(host.get("cpu")),
"ram": _anteil(speicher.get("belegt"), speicher.get("gesamt")),
"ram_used": _ganz(speicher.get("belegt")), "ram_total": _ganz(speicher.get("gesamt")),
"platte": _anteil(rootfs.get("belegt"), rootfs.get("gesamt")),
"netz_rx": rx, "netz_tx": tx,
"temp_cpu": None if temp is None else round(temp, 1),
"load1": None if last is None else round(last, 2),
"uptime_s": _ganz(host.get("uptime"))}
def _gast_punkt(geraet_id: str, gast: dict, t: float) -> dict:
laeuft = gast.get("status") == "running"
rx, tx = _zaehlen(geraet_id, t, gast.get("netin"), gast.get("netout"), gast.get("uptime"), laeuft)
if not laeuft:
return {"cpu": None, "ram": None, "ram_used": None, "ram_total": _ganz(gast.get("maxmem")), "platte": None,
"netz_rx": None, "netz_tx": None, "uptime_s": _ganz(gast.get("uptime"))}
# Proxmox rechnet die CPU eines Gasts schon auf seine eigenen Kerne (1,0 = alle voll). Die Belegung der Platte
# kennt es nur bei Containern; bei VMs steht dort immer 0.
disk = gast.get("disk")
platte = _anteil(disk, gast.get("maxdisk")) if gast.get("art") == "lxc" and messreihen.ist_zahl(disk) and disk else None
return {"cpu": _prozent(gast.get("cpu")),
"ram": _anteil(gast.get("mem"), gast.get("maxmem")),
"ram_used": _ganz(gast.get("mem")), "ram_total": _ganz(gast.get("maxmem")),
"platte": platte, "netz_rx": rx, "netz_tx": tx, "uptime_s": _ganz(gast.get("uptime"))}
def annehmen(daten: dict, jetzt: float | None = None) -> int:
"""Einen Minutenpunkt des Ausführers speichern. Rückgabe: für wie viele Geräte. Kaputte Einträge fallen still weg."""
jetzt = time.time() if jetzt is None else jetzt
zeit = daten.get("zeit")
t = float(zeit) if messreihen.ist_zahl(zeit) and abs(float(zeit) - jetzt) <= ZEIT_TOLERANZ_S else jetzt
punkte: list[tuple[str, dict]] = []
with _lock:
if isinstance(daten.get("host"), dict):
punkte.append((HOST, _host_punkt(daten["host"], t)))
gaeste = daten.get("gaeste") if isinstance(daten.get("gaeste"), list) else []
geraete = {}
for gast in gaeste:
if not isinstance(gast, dict) or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
continue
if not (gid := gast_id(gast)):
continue
punkte.append((gid, _gast_punkt(gid, gast, t)))
geraete[gid] = str(gast.get("name") or gid)
if gaeste:
_zustand["geraete"] = geraete
for quelle, werte in punkte:
messreihen.schreiben(quelle, werte, t)
return len(punkte)
# --- Lesen ------------------------------------------------------------------------------------------
def _geraete(von: float, bis: float) -> list[dict]:
"""Die Geräte wie im Inventar: der Host und die Gäste aus dem letzten Bericht (Namen aus apps.py), dazu Gäste, die
erst die Messwerte kennen. Ohne beides: alle Quellen mit Dateien im Zeitraum."""
eingang = kanal.bericht()
geraete: dict[str, dict] = {}
eigene: set[str] = set()
if eingang:
geraete[HOST] = {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"}
for gast in (eingang or {}).get("bericht", {}).get("gaeste") or []:
if not (gid := gast_id(gast)):
continue
if inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
eigene.add(gid)
continue
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
geraete[gid] = {"id": gid, "name": app.name if app else str(gast.get("name") or gid), "art": _art(gid)}
with _lock:
gemeldet = dict(_zustand["geraete"])
host_gemeldet = HOST in _zustand["zaehler"]
if host_gemeldet:
geraete.setdefault(HOST, {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"})
for gid, name in gemeldet.items():
if gid not in geraete and gid not in eigene:
app = apps.app_fuer(None, name)
geraete[gid] = {"id": gid, "name": app.name if app else name, "art": _art(gid)}
if not geraete:
for quelle in messreihen.quellen(von, bis):
if quelle == HOST or _GAST.fullmatch(quelle):
geraete[quelle] = {"id": quelle, "name": "Proxmox-Host" if quelle == HOST else quelle,
"art": _art(quelle)}
# Reihenfolge wie im Inventar: der Host, dann die Gäste nach Nummer.
return sorted(geraete.values(), key=lambda g: (g["id"] != HOST, int(g["id"].split("-")[1]) if g["id"] != HOST else 0))
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
"""Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen",
"aktuell", "prognose"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei
Gästen auf null. prognose.platte: „voll in etwa N Tagen“ (kern/prognose.py, seit 25.09.2026)."""
dauer, schritt = messreihen.zeitraum_pruefen(zeitraum)
zeit = time.time() if jetzt is None else jetzt
geraete = []
for geraet in _geraete(zeit - dauer, zeit):
host = geraet["id"] == HOST
daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, zeit)
letzter = messreihen.letzter_punkt(geraet["id"], zeit, AKTUELL_MAX_S) or {}
aktuell = {name: letzter.get(name) for name in AKTUELL}
if not host:
aktuell["temp_cpu"] = aktuell["load1"] = None
geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell,
"prognose": {"platte": speicher.platten_vorhersage(geraet["id"], jetzt)}})
return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete}
# --- Wächter (Rolle homelab, registriert in services/waechter.py) -------------------------------------------
def _gb(anzahl: object) -> str:
return f"{float(anzahl) / 1e9:.1f}".replace(".", ",") if messreihen.ist_zahl(anzahl) else "?"
def pruefe_host(jetzt: float | None = None) -> list:
"""Gelb, wenn der Proxmox-Host zehn Minuten lang über RAM_GELB % Arbeitsspeicher oder TEMP_GELB °C CPU-Temperatur
liegt. Ohne genug Messungen (Ausführer schweigt, gerade erst gestartet) kein Befund — das Schweigen meldet
pruefe_ausfuehrer()."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
jetzt = time.time() if jetzt is None else jetzt
punkte = messreihen.punkte(HOST, jetzt - WACHE_S, jetzt + 1)
befunde = []
ram = [p["ram"] for p in punkte if messreihen.ist_zahl(p.get("ram"))]
if len(ram) >= WACHE_MIN_PUNKTE and min(ram) > RAM_GELB:
letzter = next(p for p in reversed(punkte) if messreihen.ist_zahl(p.get("ram")))
befunde.append(Befund(
id="host-ram", stufe="gelb", titel=f"Proxmox-Host: Arbeitsspeicher seit 10 Minuten über {RAM_GELB:.0f} %",
text=(f"Zuletzt {ram[-1]:.0f} % belegt ({_gb(letzter.get('ram_used'))} von {_gb(letzter.get('ram_total'))} "
"GB). Wird es noch enger, beendet der Kernel Prozesse, auch in den Gästen. Die Messwerte zeigen, "
"welcher Gast wie viel braucht; einem davon weniger Speicher geben oder ihn stoppen."),
quelle=HOST, sofort=True))
temp = [p["temp_cpu"] for p in punkte if messreihen.ist_zahl(p.get("temp_cpu"))]
if len(temp) >= WACHE_MIN_PUNKTE and min(temp) > TEMP_GELB:
befunde.append(Befund(
id="host-temp", stufe="gelb", titel=f"Proxmox-Host: CPU seit 10 Minuten über {TEMP_GELB:.0f} °C",
text=(f"Zuletzt {temp[-1]:.0f} °C. So heiß drosselt sich die CPU, und auf Dauer leidet die Hardware. "
"Lüfter und Luftwege prüfen (Staub) und in den Messwerten nachsehen, welcher Gast die Last macht."),
quelle=HOST, sofort=True))
return befunde
+202
View File
@@ -0,0 +1,202 @@
"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026).
Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst
(AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag
„suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts
älter als sieben Tage sind:
• nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal
• höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für
ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet
• bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da,
eben gleich
• keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus
ein gelber Hinweis des Wächters (gelb geht nicht an Telegram).
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). Seit 24.09.2026 steht dort
auch eine kurze Ereignisliste (angestoßen, gescheitert, wieder ok; die letzten EREIGNISSE_MAX) — das Protokoll der
Oberfläche liest sie (protokoll.py).
"""
import json
import logging
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.homelab import apps, inventar, kanal, updates
log = logging.getLogger(__name__)
LISTEN_ALT_S = 7 * 24 * 3600
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
FEHLSCHLAEGE_HINWEIS = 2
EREIGNISSE_MAX = 200
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-pflege.json"
def zustand() -> dict:
"""{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}"""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
if not isinstance(daten.get("gaeste"), dict):
daten["gaeste"] = {}
return daten
def _schreiben(daten: dict) -> None:
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(_pfad())
def faellig(gaeste: list[dict], jetzt: float) -> list[dict]:
"""Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage."""
ergebnis = []
for g in gaeste:
listen = (g.get("os_updates") or {}).get("listen_stand")
if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt")
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])
and listen and jetzt - listen > LISTEN_ALT_S):
ergebnis.append(g)
return ergebnis
def _fehlerzeile(text: str | None) -> str:
"""Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“)."""
zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))]
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
def _ereignis(daten: dict, ts: float, vmid: object, name: object, art: str, text: str) -> None:
"""Für das Protokoll der Oberfläche; die Liste bleibt kurz."""
try:
vmid = int(vmid)
except (TypeError, ValueError):
return
liste = daten["ereignisse"] if isinstance(daten.get("ereignisse"), list) else []
liste.append({"ts": ts, "vmid": vmid, "name": str(name or vmid), "art": art, "text": text})
daten["ereignisse"] = liste[-EREIGNISSE_MAX:]
def _einsammeln(daten: dict) -> bool:
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
geaendert = False
for vmid, eintrag in daten["gaeste"].items():
aid = eintrag.get("auftrag")
if not aid:
continue
a = kanal.auftrag(aid)
if a is not None and a.get("status") in ("wartet", "laeuft"):
continue
if a is not None and a.get("status") == "fertig":
if eintrag.get("fehlschlaege"):
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "wieder_ok",
"Die Paketlisten ließen sich wieder erneuern.")
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
elif a is not None: # fehler, verloren
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
eintrag["fehler"] = _fehlerzeile(a.get("text"))
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "gescheitert",
f"{eintrag['fehlschlaege']}. Fehlschlag in Folge: {eintrag['fehler']}")
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
geaendert = True
return geaendert
def _offene_gaeste() -> set:
"""Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf)."""
return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")}
def _befunde(daten: dict, faellige: list[dict]) -> list:
"""Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
befunde = []
for g in faellige:
eintrag = daten["gaeste"].get(str(g["vmid"])) or {}
anzahl = int(eintrag.get("fehlschlaege") or 0)
if anzahl < FEHLSCHLAEGE_HINWEIS:
continue
name = eintrag.get("name") or str(g.get("name") or g["vmid"])
befunde.append(Befund(
id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert",
text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). "
f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"),
quelle=f"ct-{g['vmid']}", sofort=True))
return befunde
def pruefe_paketlisten(jetzt: float | None = None) -> list:
"""Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter —
nur, wenn die Suche für einen Gast wiederholt scheitert (gelb)."""
from services import waechter
jetzt = time.time() if jetzt is None else jetzt
lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ)
heute = lokal.date().isoformat()
nachts = NACHT_VON <= lokal.hour < NACHT_BIS
# Die zuletzt begonnene Nacht: vor 02:00 die von gestern.
letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat()
eingang = kanal.bericht()
bericht = (eingang or {}).get("bericht") or {}
gaeste = bericht.get("gaeste") or []
zuletzt = kanal.zuletzt()
# Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab).
verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S
and not (bericht.get("host") or {}).get("nur_lesen"))
faellige = faellig(gaeste, jetzt)
with _lock:
daten = zustand()
geaendert = _einsammeln(daten)
faellig_ids = {str(g["vmid"]) for g in faellige}
for vmid, eintrag in daten["gaeste"].items():
# Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen.
if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids:
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
geaendert = True
if verbunden and nachts and daten.get("nacht") != heute:
daten["nacht"] = heute
geaendert = True
# Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg).
if verbunden and (nachts or daten.get("nacht") != letzte_nacht):
offen = _offene_gaeste()
im_update = updates.laufende_ziele()
for g in faellige:
vmid = g["vmid"]
eintrag = daten["gaeste"].setdefault(str(vmid), {})
if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \
or f"ct-{vmid}" in im_update:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
eintrag["name"] = app.name if app else str(g.get("name") or vmid)
if waechter.TROCKEN:
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
continue
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
listen = (g.get("os_updates") or {}).get("listen_stand")
_ereignis(daten, jetzt, vmid, eintrag["name"], "angestossen",
f"Die Paketlisten sind vom {datetime.fromtimestamp(listen, LOCAL_TZ):%d.%m.%Y}.")
geaendert = True
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
if geaendert:
_schreiben(daten)
return _befunde(daten, faellige)
+570
View File
@@ -0,0 +1,570 @@
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
Quellen, alle nur gelesen:
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
löschen — Läufe wie die Updates, mit eigenen Titeln
sammellauf „Alle aktualisieren“ (sammellauf.py)
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
"""
import json
import os
import re
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.einstellungen import melde_log
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
GRENZE_MAX = 1000
DETAILS_MAX = 4000
TEXT_MAX = 300
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
_AKTION = {
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
"Pakete einspielen"),
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
"Paketlisten erneuern"),
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
"Zurücksetzen"),
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
"Snapshot löschen"),
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
"Zurückspielen"),
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
"Sicherung löschen"),
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
"Host-Pakete"),
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
"Kernel aufräumen"),
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_GEHEIM = (
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
# Zugangsdaten in Adressen: https://nutzer:passwort@host
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
)
def _ausfuehrer_token() -> str:
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
try:
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _schwaerzer() -> Callable[[str | None], str | None]:
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
def schwaerzen(text: str | None) -> str | None:
if text is None:
return None
text = _ANSI.sub("", str(text))
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
zeilen = []
for zeile in text.split("\n"):
teile = [t for t in zeile.split("\r") if t.strip()]
zeilen.append(teile[-1] if teile else "")
text = _STEUER.sub("", "\n".join(zeilen))
for wert in bekannt:
text = text.replace(wert, "***")
for muster, ersatz in _GEHEIM:
text = muster.sub(ersatz, text)
return text
return schwaerzen
def schwaerzen(text: str | None) -> str | None:
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
return _schwaerzer()(text)
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
if not text or not text.strip():
return None
text = text.strip("\n")
if len(text) <= laenge:
return text
rest = text[-(laenge - 2):]
if "\n" in rest[:300]:
rest = rest.split("\n", 1)[1]
return "…\n" + rest
def _zeile(text: str | None) -> str | None:
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
if not text or not str(text).strip():
return None
ganz = str(text).strip()
erste = ganz.splitlines()[0].strip()
if len(erste) > TEXT_MAX:
return erste[:TEXT_MAX - 1].rstrip() + "…"
return erste + ("…" if "\n" in ganz else "")
# --- Geräte ---------------------------------------------------------------------------------------------
def _geraete() -> dict[int, tuple[str, str]]:
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
geraete: dict[int, tuple[str, str]] = {}
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
return geraete
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
details: str | None, schw: Callable) -> dict | None:
if not isinstance(zeit, (int, float)):
return None
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
"ziel": ziel, "details": schw(details)}
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
def _dauer(bis: object, von: object) -> str | None:
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
return None
s = round(bis - von)
if s < 60:
return f"{s} s"
if s < 3600:
return f"{s // 60} min"
return f"{s // 3600} h {s % 3600 // 60} min"
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
"""Worum es ging: der Snapshot bzw. die Sicherung."""
if aktion in ("zurueck", "snapshot_loeschen"):
return str(p.get("snapshot") or "") or None
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
return m.group(1)
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
return m.group(1).rsplit("/", 1)[-1]
return None
def _fehlerzeile(ausgabe: str) -> str | None:
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
zeile = (wichtig or zeilen or [None])[-1]
return zeile[:200] if zeile else None
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
return ", ".join(t for t in teile if t) + "."
def _bericht_kurz(ausgabe: str) -> str | None:
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
try:
b = json.loads(ausgabe)
except ValueError:
return None
if not isinstance(b, dict):
return None
host = b.get("host") if isinstance(b.get("host"), dict) else {}
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
"info", "pve", None, schw)
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
aktion = str(a.get("aktion") or "?")
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
aktion))
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
ziel, name = None, None
if aktion in _HOST_AKTIONEN:
ziel, name = "pve", "Proxmox-Host"
elif isinstance(p.get("vmid"), int):
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
status, code = a.get("status"), a.get("code")
ausgabe = str(a.get("text") or "")
gegenstand = _gegenstand(aktion, p, ausgabe)
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
f"Code {code}" if code is not None else None) if t)
if status == "fertig":
titel, stufe, text = erledigt, "ok", kopf or None
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
elif status == "fehler":
grund = _fehlerzeile(ausgabe)
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
elif status == "verloren":
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
elif status == "laeuft":
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
else:
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
f"{titel} · {name}" if name else titel, text, stufe, ziel,
details or _kuerzen(schw(ausgabe)), schw)
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
def _anzahl_pakete(n: object, wort: str) -> str:
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
_SNAPSHOT_TITEL = {
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
}
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
name = str(x.get("name") or x.get("ziel") or "?")
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
if status == "laeuft":
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
elif status == "unterbrochen":
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
"Bericht.")
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
else:
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
titel = f"{wort} · {name}"
zeilen = [str(s) for s in x.get("schritte") or []]
if status == "fertig" and x.get("gemeldet") is False:
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
"\n".join(zeilen) or None, schw)
def _lauf(x: dict, schw: Callable) -> dict | None:
if str(x.get("baustein")) in snapshots.LAEUFT:
return _snapshot_lauf(x, schw)
name = str(x.get("name") or x.get("ziel") or "?")
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
was = _LAUF_WAS.get(str(baustein), "Update")
if status == "laeuft":
titel, stufe = f"{was} läuft · {name}", "info"
elif status == "unterbrochen":
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
"nächste Bericht."
elif ergebnis == "eingespielt":
stufe = "ok"
if baustein == "app":
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
elif baustein == "docker":
titel = f"Docker-Images aktualisiert · {name}"
elif baustein == "pakete":
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
else:
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
elif ergebnis == "zurueckgerollt":
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
elif ergebnis == "fehler" and ("Update nicht begonnen" in str(text or "") or "Probe nicht begonnen" in str(text or "")):
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
elif ergebnis == "fehler":
titel, stufe = f"{was} gescheitert · {name}", "fehler"
elif ergebnis == "neustart":
titel, stufe = f"Neustart ausgelöst · {name}", "info"
elif ergebnis == "aufgeraeumt":
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
elif ergebnis == "probe-gruen":
titel, stufe = f"Rückweg-Probe grün · {name}", "ok"
elif ergebnis == "offen":
titel, stufe = f"Docker-Probelauf · {name}", "info"
else:
titel, stufe = f"{was} beendet · {name}", "info"
zeilen = [str(s) for s in x.get("schritte") or []]
if x.get("sammellauf"):
zeilen.append("Teil von „Alle aktualisieren“.")
if x.get("gemeldet") is False:
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
"\n".join(zeilen) or None, schw)
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
anzahl = len(schritte)
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
status = sl.get("status")
if status == "laeuft":
i = sl.get("aktuell")
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
stufe = "info"
elif status == "abgebrochen":
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
else:
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
stufe = "ok" if eingespielt == anzahl else "info"
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
details or None, schw)
# --- Hinweise des Wächters ------------------------------------------------------------------------------
def _waechter_pfad() -> Path:
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
def _ziel_aus(hinweis_id: str) -> str | None:
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
return f"{m.group(1)}-{m.group(2)}"
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
return f"ct-{m.group(1)}"
return "pve" if hinweis_id == "ausfuehrer" else None
def _hinweise(schw: Callable) -> list[dict]:
try:
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
daten = daten if isinstance(daten, dict) else {}
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
eintraege, erschienen = [], set()
for v in daten.get("verlauf") or []:
if not isinstance(v, dict):
continue
art, hid = v.get("art"), str(v.get("id") or "")
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
if art == "neu":
erschienen.add(hid)
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw)
elif art == "erledigt":
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw)
elif art == "auto":
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid),
None, schw)
else:
continue
eintraege.append(e)
for hid, h in aktuell.items():
if hid not in erschienen and isinstance(h, dict):
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
return eintraege
# --- Meldungen ------------------------------------------------------------------------------------------
def _meldelog() -> list[list]:
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
try:
with melde_log().open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - MELDELOG_MAX))
roh = f.read().decode("utf-8", "replace")
except OSError:
return []
if groesse > MELDELOG_MAX:
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
eintraege: list[list] = []
for zeile in roh.splitlines():
if m := _MELDUNG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
continue
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
eintraege.append([zeit, weg, m.group(3), m.group(4)])
elif eintraege:
eintraege[-1][3] += "\n" + zeile
return eintraege
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
bekannt = {}
for x in laeufe:
if x.get("text") and x.get("gemeldet") is not False:
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
bekannt[x["text"]] = x["betreff"]
continue
dringend = x.get("dringend")
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
for sl in sammellaeufe:
if sl.get("meldung"):
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
return bekannt
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
if text in bekannt:
return bekannt[text]
if text.endswith(LUCY_ZUSATZ):
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
if text.startswith("Testmeldung aus den Einstellungen"):
return BETREFF_TEST
if text.startswith("Guten Morgen, Commander."):
return BETREFF_MORGEN
return None
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
eintraege = []
for zeit, weg, grund, text in _meldelog():
betreff = _betreff(text, bekannt)
if weg == "FALLBACK":
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
else:
titel, stufe, wie = _WEG[weg]
kurz = _zeile(text)
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
details, schw))
return eintraege
# --- Pflege ---------------------------------------------------------------------------------------------
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
daten = pflege.zustand()
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
eintraege = []
for e in ereignisse:
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
continue
titel, stufe = _PFLEGE[e["art"]]
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
f"ct-{e['vmid']}", None, schw))
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
for vmid, g in (daten.get("gaeste") or {}).items():
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
continue
if (int(vmid), g["angestossen"]) not in schon:
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
return eintraege
# --- Zusammen -------------------------------------------------------------------------------------------
def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
grenze = max(1, min(int(grenze), GRENZE_MAX))
schw = _schwaerzer()
geraete = _geraete()
laeufe = updates.laeufe(100)
sammellaeufe = sammellauf.liste()
alle: list[dict | None] = []
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
if berichte:
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
alle += _hinweise(schw)
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
alle += _pflege(geraete, schw)
liste = [e for e in alle if e is not None]
liste.sort(key=lambda e: e["zeit"], reverse=True)
return liste[:grenze]
+344
View File
@@ -0,0 +1,344 @@
"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander.
Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist —
mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026):
1. die Gäste nach VMID, außer NPMplus und AdGuard Home
2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit
3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie
Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py)
oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur
dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts.
Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind
„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine
Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen
nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen
greift), wird er übersprungen, und es geht weiter.
Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE).
Stand in <Datenordner>/homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu,
gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start).
"""
import json
import logging
import threading
import time
import uuid
from pathlib import Path
from kern.einstellungen import einstellungen
from services import announce
from services.homelab import apps, arcane, inventar, kanal, updates
log = logging.getLogger(__name__)
NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge
GAST_BAUSTEINE = ("app", "os", "docker")
TAKT_S = 2.0
WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min)
BEHALTEN = 20
UNTERBROCHEN = "unterbrochen (Neustart)"
HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht."
# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts.
STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler",
"offen": "uebersprungen"}
# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll.
FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text")
SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text")
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
# --- Stand ------------------------------------------------------------------------------------------
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-sammellauf.json"
def _alle() -> list[dict]:
with _lock:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else []
def _merken(sl: dict) -> None:
with _lock:
alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad())
def oeffentlich(sl: dict | None) -> dict | None:
"""Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie)."""
if sl is None:
return None
return {**{k: sl.get(k) for k in FELDER},
"schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]}
def liste() -> list[dict]:
"""Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll)."""
return list(reversed(_alle()))
def aktueller() -> dict | None:
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf — solange ihn niemand quittiert hat (seit
25.09.2026: vorher stand der letzte für immer auf der Update-Seite)."""
alle = _alle()
if laufend := next((x for x in reversed(alle) if x.get("status") == "laeuft"), None):
return oeffentlich(laufend)
letzter = alle[-1] if alle else None
return None if not letzter or letzter.get("quittiert") else oeffentlich(letzter)
def quittieren(sl_id: str) -> bool:
"""Einen beendeten Sammellauf als gesehen markieren; das Protokoll behält ihn."""
with _lock:
sl = next((x for x in _alle() if x["id"] == sl_id), None)
if not sl or sl.get("status") == "laeuft":
return False
sl["quittiert"] = True
_merken(sl)
return True
def laeuft() -> bool:
return any(x.get("status") == "laeuft" for x in _alle())
def _aufzaehlung(namen: list[str]) -> str:
"""„A“, „A und B“, „A, B und C“."""
namen = list(dict.fromkeys(namen))
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
def _melden(text: str, dringend: bool = False) -> None:
"""Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py)."""
try:
announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text)
except Exception:
log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True)
# --- Plan -------------------------------------------------------------------------------------------
def _kennung(ziel_id: str) -> str | None:
gast = inventar.gast(ziel_id) or {}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
return app.kennung if app else None
def _rang(schritt: dict) -> tuple[int, int]:
"""Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge."""
kennung = _kennung(schritt["ziel"])
nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0
return nadel, int(schritt["ziel"].split("-", 1)[1])
def plan() -> dict:
"""Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat.
{"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}"""
gaeste: list[dict] = []
host: list[dict] = []
docker_probe = False
for z in inventar.ziele()["ziele"]:
for b in z.get("bausteine") or []:
if b.get("zustand") != "neu" or not b.get("aktion"):
continue
schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"],
"rueckweg_art": z.get("rueckweg_art") or "keiner"}
if z["id"] == "pve":
if b["id"] == "pakete": # der Neustart ist nie dabei
host.append({**schritt, "rueckweg_art": "keiner"})
elif b["id"] == "docker":
if not arcane.echt():
docker_probe = True # ein Probelauf ist kein Update
else:
gaeste.append(schritt) # Snapshot der VM nur mit Etikett watcher — rueckweg_art sagt es
elif b["id"] in GAST_BAUSTEINE:
gaeste.append(schritt)
gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine
schritte = gaeste + host
teile = []
ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"]
if ohne:
teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst "
"zurück.")
if host:
teile.append(HOST_HINWEIS)
if docker_probe:
teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.")
return {"schritte": schritte, "hinweis": " ".join(teile) or None}
# --- Starten ----------------------------------------------------------------------------------------
def _ausfuehrer_grund() -> str | None:
"""Warum der Ausführer gerade keine Updates annimmt — oder None."""
zuletzt = kanal.zuletzt()
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update."
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
if host.get("nur_lesen"):
return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab."
return None
def starten() -> dict:
"""Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}."""
if laeuft():
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
schritte = plan()["schritte"]
if not schritte:
return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()):
return {"ok": False, "detail": grund}
with updates.START_SPERRE:
if laeuft():
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]:
return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). "
"„Alle aktualisieren“ geht, sobald es fertig ist."}
sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None,
"schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"],
"status": "wartet", "lauf": None, "text": None} for s in schritte],
"text": None, "meldung": None, "dringend": False}
_merken(sl)
antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter
threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start()
log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte))
return {"ok": True, "sammellauf": antwort}
# --- Ablauf -----------------------------------------------------------------------------------------
def _warten(lauf_id: str) -> dict | None:
"""Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet."""
ende = time.time() + WARTEN_MAX_S
while time.time() < ende:
lauf = updates.lauf_lesen(lauf_id)
if lauf is not None and lauf.get("status") != "laeuft":
return lauf
time.sleep(TAKT_S)
return None
def _schritt(sl: dict, schritt: dict) -> None:
"""Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen."""
if schritt["baustein"] == "docker" and not arcane.echt():
schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.")
return
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]})
if not antwort.get("ok"):
schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt"))
return
schritt["lauf"] = antwort["lauf"]
_merken(sl)
lauf = _warten(antwort["lauf"])
if lauf is None:
schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht "
"zurückgemeldet.")
return
schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text"))
def _ablauf(sl: dict) -> None:
try:
for i, schritt in enumerate(sl["schritte"]):
sl["aktuell"] = i
schritt["status"] = "laeuft"
_merken(sl)
_schritt(sl, schritt)
_merken(sl)
if schritt["status"] in ("zurueckgerollt", "fehler"):
_abbrechen(sl, i)
return
_abschliessen(sl)
except Exception as exc:
# Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt.
log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id"))
_beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.",
f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).")
def _abschliessen(sl: dict) -> None:
eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"]
uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"]
anzahl = len(eingespielt)
if anzahl:
text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün."
else:
text = "Kein Update eingespielt."
if uebersprungen:
text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})"
for s in uebersprungen) + "."
sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False)
_merken(sl)
log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text)
_melden(sl["meldung"])
def _beenden(sl: dict, text: str, anlass: str) -> None:
"""Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung."""
rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")]
for s in rest:
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"]
meldung = f"Homelab: {anlass}"
if eingespielt:
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
if rest:
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True)
_merken(sl)
log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text)
_melden(meldung, dringend=True)
def _abbrechen(sl: dict, i: int) -> None:
schritt = sl["schritte"][i]
wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert"
_beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.",
f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).")
def unterbrochene_abschliessen() -> None:
"""Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen
Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet."""
for sl in _alle():
if sl.get("status") != "laeuft":
continue
bei = None
for s in sl["schritte"]:
if s.get("status") != "laeuft":
continue
bei = s["name"]
# War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah.
lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None
if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS:
s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text"))
else:
s.update(status="fehler", text=UNTERBROCHEN)
rest = [s for s in sl["schritte"] if s.get("status") == "wartet"]
for s in rest:
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"]
meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu "
"gestartet" + (f" (bei {bei})" if bei else "") + ".")
if eingespielt:
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
if rest:
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
meldung += " Bitte den Stand in der Übersicht prüfen."
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung,
dringend=True)
_merken(sl)
log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"])
threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start()
+594
View File
@@ -0,0 +1,594 @@
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
Zahl, die Seite sagt es so.
Herkunft eines Snapshots:
update Rückweg vor einem Update (Name mc2-…)
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
ohnehin ab, er fasst nur mc2-… an).
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
in die Quere. Neue Befehle braucht der Ausführer nicht:
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
es scheitert oder die Prüfung rot ist.
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw.
Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die
Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die
einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick
des Users hin — nie von selbst.
"""
import logging
import re
import threading
import time
from collections.abc import Callable
from datetime import datetime
from kern.zeit import LOCAL_TZ
from services import announce
from services.homelab import apps, inventar, kanal, sammellauf, updates
log = logging.getLogger(__name__)
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
BETREFF = "[Homelab-Snapshot]"
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
def _zid(g: dict) -> str:
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
def _wo(g: dict) -> str:
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
def _name(g: dict) -> str:
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
return app.name if app else str(g.get("name") or _zid(g))
def _ortszeit(m: re.Match | None) -> float | None:
if not m:
return None
try:
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
except ValueError:
return None
def _wann(ts: float | None) -> str:
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
def _groesse(n: int | None) -> str | None:
if not n:
return None
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
def _aktion(pfad: str, frage: str, label: str) -> dict:
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
def _grund(exc: Exception) -> str:
return str(exc).rstrip(".")
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
(updates._aufraeumen) — die löscht nur der User."""
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
for d in gast.get("snapshot_details") or []:
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
namen.add(str(d.get("name")))
return namen
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
details = g.get("snapshot_details")
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
else [{"name": n} for n in g.get("snapshots") or [] if n])
liste = []
for d in roh:
name = str(d["name"])
m = SNAPSHOT_NAME.match(name)
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
beschreibung = str(d.get("beschreibung") or "").strip() or None
if not m:
herkunft = "proxmox"
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
herkunft = "knopf"
else:
herkunft = "update"
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
"eigen": bool(m)})
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
def _sicherungen(g: dict) -> list[dict]:
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
details = g.get("sicherung_details")
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
liste = []
for d in roh:
volid = str(d["volid"])
datei = volid.rsplit("/", 1)[-1]
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
def _anlegen_grund(g: dict) -> str | None:
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
if not g.get("erlaubt"):
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
if "snapshot_moeglich" not in g:
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
if not g["snapshot_moeglich"]:
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
if g.get("sicherung_moeglich"):
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
return satz
return None
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
def _frage_anlegen(g: dict, name: str) -> str:
vm = g.get("art") != "lxc"
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
if g.get("status") == "running":
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
return " ".join(saetze)
def _frage_loeschen(name: str, s: dict) -> str:
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
vm = g.get("art") != "lxc"
wann = _wann(s["zeit"])
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
f"{name} ist ein paar Minuten nicht erreichbar.")]
if g.get("status") != "running":
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
f"{'sie' if vm else 'er'}.")
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
return " ".join(saetze)
def _frage_probe(g: dict, name: str) -> str:
vm = g.get("art") != "lxc"
snapshot = bool(g.get("snapshot_moeglich"))
return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt "
f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt "
f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar "
"Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. "
f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.")
def _frage_sicherung(name: str, e: dict) -> str:
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
"(Proxmox → Backup) bleiben, wie sie sind.")
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
def _laeuft_text(x: dict) -> str:
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
baustein = str(x.get("baustein"))
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
return "Zurückgesetzt, wird geprüft"
return LAEUFT.get(baustein, "Update läuft")
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
ende = (x or {}).get("ende") or (x or {}).get("start")
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
return None
if x.get("status") == "unterbrochen":
return {"ok": False, "ende": ende,
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
zid, name = _zid(g), _name(g)
grund = _anlegen_grund(g)
snapshots = _snapshots(g, zid, knopf)
for s in snapshots:
darf = s["eigen"] and bool(g.get("erlaubt"))
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
if darf else None)
sicherungen = _sicherungen(g)
for e in sicherungen:
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
"Sicherung löschen") if g.get("erlaubt") else None)
lauf = laufend.get(zid)
probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich"))
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
"Snapshot anlegen"),
"probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen")
if probe_geht else None),
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
"snapshots": snapshots, "sicherungen": sicherungen}
def liste(jetzt: float | None = None) -> dict:
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
jetzt = time.time() if jetzt is None else jetzt
eingang = kanal.bericht()
bericht = (eingang or {}).get("bericht") or {}
zuletzt = kanal.zuletzt()
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
laeufe = updates.laeufe(100) # neueste zuerst
laufend: dict[str, dict] = {}
fertig: dict[str, dict] = {}
for x in laeufe:
if x.get("status") == "laeuft":
laufend.setdefault(str(x.get("ziel")), x)
elif x.get("baustein") in LAEUFT:
fertig.setdefault(str(x.get("ziel")), x)
knopf = _knopf(laeufe)
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
alle = [s for g in geraete for s in g["snapshots"]]
sicherungen = [e for g in geraete for e in g["sicherungen"]]
empfangen = (eingang or {}).get("empfangen")
rueckwege = _rueckwege(geraete)
return {
"jetzt": jetzt,
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
"sperre": _sperre(),
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
"details": any("snapshot_details" in g for g in gaeste),
"geraete": geraete,
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
# „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft.
"aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege),
f"Alle {len(rueckwege)} Update-Rückwege löschen")
if rueckwege and not _AUFRAEUMEN.is_set() else None),
"aufraeumen_laeuft": _AUFRAEUMEN.is_set(),
}
# --- Alle Update-Rückwege auf einmal (User-Klick) ---------------------------------------------------------------------
_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet
AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden
def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]:
"""(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“
und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu."""
liste = []
for g in geraete:
liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"]
if s["herkunft"] == "update" and s.get("loeschen")]
liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")]
return liste
def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str:
snaps = sum(1 for r in rueckwege if r[2] == "snapshot")
sich = len(rueckwege) - snaps
teile = []
if snaps:
teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}")
if sich:
teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}")
geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege))
return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den "
"letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor "
"diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, "
"bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.")
def _warten_auf(lauf_id: str) -> dict | None:
ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S
while time.monotonic() < ende:
lauf = updates.lauf_lesen(lauf_id)
if lauf and lauf.get("status") != "laeuft":
return lauf
time.sleep(ABFRAGE_TAKT_S)
return None
ABFRAGE_TAKT_S = 1.0
def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None:
geloescht, fehler = 0, []
try:
for zid, name, art, eintrag in rueckwege:
antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag)
if not antwort.get("ok"):
fehler.append(f"{name} {eintrag}: {antwort.get('detail')}")
continue
lauf = _warten_auf(antwort["lauf"])
if lauf and lauf.get("ergebnis") == "geloescht":
geloescht += 1
else:
fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}")
finally:
_AUFRAEUMEN.clear()
text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht."
if fehler:
text += " Nicht gelöscht: " + "; ".join(fehler[:5])
try:
announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text)
except Exception:
log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True)
def rueckwege_loeschen() -> dict:
"""Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück."""
if _AUFRAEUMEN.is_set():
return {"ok": False, "detail": "Das Aufräumen läuft schon."}
if grund := _sperre():
return {"ok": False, "detail": grund}
rueckwege = _rueckwege(liste()["geraete"])
if not rueckwege:
return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."}
_AUFRAEUMEN.set()
threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start()
return {"ok": True, "anzahl": len(rueckwege)}
# --- Aktionen ------------------------------------------------------------------------------------------------------
def _sperre() -> str | None:
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
if sammellauf.laeuft():
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
if im_update:
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
zuletzt = kanal.zuletzt()
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
return None
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
if grund := _sperre():
return None, grund
if updates.laeuft(ziel_id):
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
"erst, wenn es fertig ist.")
gast = inventar.gast(ziel_id)
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
if not gast.get("erlaubt"):
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
return gast, None
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
schritte: Callable[[dict, dict], None]) -> dict:
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
with updates.START_SPERRE:
gast, grund = _vorab(ziel_id)
mehr: dict = {}
if gast is not None:
grund, mehr = vorbereiten(gast)
if grund or gast is None:
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
if not SNAPSHOT_NAME.match(name):
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
"angelegte verwaltest du in Proxmox."), {}
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
if s is None:
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
def anlegen(ziel_id: str) -> dict:
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
def loeschen(ziel_id: str, name: str) -> dict:
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
def zuruecksetzen(ziel_id: str, name: str) -> dict:
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
if volid is None:
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
return None, {"sicherung": volid}
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
betreff=betreff)
updates._merken(lauf)
if betreff:
try:
announce.notify_telegram(betreff, text)
except Exception:
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
try:
schritte(lauf, gast)
except Exception as exc:
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
def _anlegen(lauf: dict, gast: dict) -> None:
vmid, name = gast["vmid"], lauf["name"]
try:
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
return
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
lauf["snapshot"] = m.group(1) if m else None
nachher = updates._frischer_gast(lauf, vmid)
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
"er aber.", melden=True)
return
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
else f"{name}: Snapshot angelegt.")
def _loeschen(lauf: dict, gast: dict) -> None:
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
try:
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
return
updates._frischer_gast(lauf, vmid)
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
def _lage(name: str, gast: dict | None) -> str:
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
if gast is None:
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
if gast.get("status") == "running":
return f"{name} läuft."
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
def _zurueck(lauf: dict, gast: dict) -> None:
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
wann = _wann(lauf.get("snapshot_zeit"))
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
try:
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
except RuntimeError as exc:
nachher = updates._frischer_gast(lauf, vmid)
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
melden=True, dringend=True)
return
lauf["zurueckgesetzt"] = True
time.sleep(updates.WARTEN_NACH_UPDATE_S)
nachher = updates._frischer_gast(lauf, vmid)
fehler = updates.pruefen(gast, nachher, "zurueck")
if fehler:
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
melden=True, dringend=True)
return
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
datei = volid.rsplit("/", 1)[-1]
try:
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
return
updates._frischer_gast(lauf, vmid)
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
+631
View File
@@ -0,0 +1,631 @@
"""Speicher und Sicherungen des Homelabs (seit 25.09.2026, User-Wunsch).
Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfung, ob das NAS antwortet:
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots).
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
• Abdeckung (seit 25.09.2026): Gäste ohne Sicherungsauftrag (wie Proxmox sie unter Rechenzentrum → Backup meldet)
sofort gelb; je Gerät die jüngste Prüfung durch den PBS (beschädigt = rot) und die Probe-Wiederherstellung des
Ausführers (jede Woche die jüngste Sicherung probeweise öffnen; gescheitert = gelb). Prüft der PBS seit über
15 Tagen nichts mehr, obwohl es ältere Sicherungen gibt: gelb. Beim Sicherungsziel zählt die Schätzung des PBS,
wann es voll ist.
• Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und
alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen).
• Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen
rot — auch wenn der Füllstand selbst noch unauffällig ist. Den Verlauf dafür schreibt jeder Bericht in die
Messreihe VERLAUF (verlauf_merken); Host und Container haben ihn schon in den Minutenwerten.
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
"""
import logging
import re
import socket
import threading
import time
from datetime import datetime
from kern import messreihen, prognose
from kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal
from services.homelab.inventar import BERICHT_ALT_S, EIGENES_ETIKETT
log = logging.getLogger(__name__)
GELB, ROT = 0.80, 0.90
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit
TAEGLICH_S, SONST_S = 26 * 3600, 8 * 86400
PORTS = {"nfs": 2049, "nfs4": 2049, "cifs": 445, "smb3": 445}
ANTWORT_CACHE_S = 30
RANG = {"ok": 0, "gelb": 1, "rot": 2}
_antwort_cache: dict[tuple[str, int], tuple[float, bool]] = {}
_antwort_lock = threading.Lock()
def _stufe(anteil: float | None) -> str:
if anteil is None:
return "ok"
return "rot" if anteil >= ROT else "gelb" if anteil >= GELB else "ok"
def _schlimmer(*stufen: str) -> str:
return max(stufen, key=lambda s: RANG.get(s, 0))
def _gb(n: float | None) -> str:
return "–" if n is None else f"{n / 1e9:.1f}".replace(".", ",")
def _groesse(n: float | None) -> str:
"""„125 GB“, „2,3 TB“ — für Texte."""
if n is None:
return "–"
if n >= 1e12:
return f"{n / 1e12:.1f}".replace(".", ",") + " TB"
return f"{n / 1e9:.0f} GB" if n >= 10e9 else f"{_gb(n)} GB"
def _name(g: dict) -> str:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
return app.name if app else str(g.get("name") or g["vmid"])
def _wo(g: dict) -> str:
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
def _zid(g: dict) -> str:
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
def _datum(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
def _bericht() -> dict:
return (kanal.bericht() or {}).get("bericht") or {}
# --- Speicherpool ----------------------------------------------------------------------------------------------
def pools(bericht: dict) -> list[dict]:
ergebnis = []
for p in (bericht.get("host") or {}).get("pools") or []:
gesamt, belegt = int(p.get("gesamt") or 0), int(p.get("belegt") or 0)
if not gesamt:
continue
anteil, meta = belegt / gesamt, p.get("meta_anteil")
ergebnis.append({"speicher": p.get("speicher"), "belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt,
"anteil": anteil, "meta_anteil": meta, "aktiv": p.get("aktiv", True),
"stufe": _schlimmer(_stufe(anteil), _stufe(meta))})
return ergebnis
def empfehlungen(bericht: dict) -> list[dict]:
"""Was im Speicherpool Platz bringt, der größte Gewinn zuerst; Snapshots (Größe unbekannt) danach.
Je Eintrag: art, titel, text (was zu tun ist, mit Klickweg), kurz (für Meldungen), platz (Bytes oder None)."""
gaeste = bericht.get("gaeste") or []
liste: list[dict] = []
container: list[tuple[dict, int]] = []
for g in gaeste:
platte, im_pool = g.get("platte") or {}, g.get("pool_belegt")
if im_pool and platte.get("belegt") is not None:
gewinn = int(im_pool) - int(platte["belegt"])
if g.get("art") == "lxc":
if gewinn >= TRIM_AB:
container.append((g, gewinn))
elif gewinn >= EMPFEHLUNG_AB:
liste.append(_trim_vm(g, int(im_pool), int(platte["belegt"]), gewinn))
for u in g.get("ungenutzt") or []:
liste.append({
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
"kurz": f"ungenutzte Platte bei {_name(g)}", "ort": "pool",
"text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware "
"→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")})
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
container.sort(key=lambda t: -t[1])
einzeln = ", ".join(f"{_name(g)} {_gb(x)} GB" for g, x in container)
befehle = ", ".join(f"`pct fstrim {g['vmid']} --ignore-mountpoints`" for g, _ in container)
liste.append({
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
"kurz": f"{_groesse(summe)} per TRIM in den Containern", "ort": "pool",
"text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → "
f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")})
for p in (bericht.get("host") or {}).get("pools") or []:
for v in p.get("verwaist") or []:
liste.append({
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
"kurz": f"verwaiste Platte {v['volume']}", "ort": "pool",
"text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} "
"→ Datenträger → Entfernen (löscht sie endgültig).")})
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
liste.append(kernel)
liste.sort(key=lambda e: -(e["platz"] or 0))
if snapshots := _snapshot_vorschlag(gaeste):
liste.append(snapshots)
return liste
def _geraete_text(namen: list[str]) -> str:
"""„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind."""
teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)]
return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1]
def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None:
"""Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der
Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier.
Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt."""
eigene: list[str] = []
von_hand: list[str] = []
for g in gaeste:
if EIGENES_ETIKETT in (g.get("etiketten") or []):
continue
for s in g.get("snapshots") or []:
(eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g))
anzahl = len(eigene) + len(von_hand)
if not anzahl:
return None
if eigene:
titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter "
"Homelab → Snapshots")
text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite "
"Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.")
else:
titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} "
f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots")
text = f"Bei {_geraete_text(von_hand)}."
if eigene and von_hand:
text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})."
if von_hand:
text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox."
text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder "
"Änderung seitdem.")
return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text,
"kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"}
def _kernel_vorschlag(host: dict) -> dict | None:
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
alt = host.get("kernel_alt") or []
if not alt:
return None
platz = sum(int(k.get("groesse") or 0) for k in alt)
bleiben = ", ".join(str(v).removesuffix("-pve") for v in host.get("kernel_bleiben") or []) or "der laufende"
return {
"art": "kernel", "ort": "systemplatte", "platz": platz,
"titel": f"Proxmox-Host: {len(alt)} alte Kernel entfernen",
"kurz": f"{_groesse(platz)} alte Kernel auf dem Proxmox-Host",
"text": (f"Liegen auf der Systemplatte des Hosts, nicht im Speicherpool. Entfernt wird nur, was Proxmox selbst für "
f"entbehrlich hält; es bleiben {bleiben} — darunter der laufende und der für den nächsten Start. Die "
"Gäste laufen weiter, ein Neustart ist dafür nicht nötig."),
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/pve/kernel-aufraeumen", "label": "Alte Kernel entfernen",
"frage": (f"{len(alt)} alte Kernel vom Proxmox-Host entfernen (etwa {_groesse(platz)})? Es bleiben "
f"{bleiben}. Die Gäste laufen weiter, ein Neustart ist nicht nötig.")},
}
def systemplatte(bericht: dict) -> dict | None:
p = (bericht.get("host") or {}).get("systemplatte") or {}
gesamt, belegt = p.get("gesamt"), p.get("belegt")
if not gesamt or belegt is None:
return None
anteil = belegt / gesamt
return {"belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt, "anteil": anteil, "stufe": _stufe(anteil)}
def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB."
if g.get("discard"):
text = (f"{kopf} In der VM `sudo fstrim -av` ausführen; Linux-Gäste tun das meist ohnehin jede Woche "
"(fstrim.timer).")
else:
text = (f"{kopf} Discard ist aus, deshalb kommt Platz, den die VM freigibt, nie im Pool an. In Proxmox: "
f"VM {g['vmid']} → Hardware → Festplatte → Bearbeiten → „Discard“ anhaken, dann die VM einmal neu "
"starten (die Dienste darin sind kurz weg). Danach gibt sie freien Platz jede Woche von selbst zurück; "
"sofort mit `sudo fstrim -av` in der VM.")
return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben",
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
"ort": "pool",
"text": text}
# --- NAS --------------------------------------------------------------------------------------------------------
def antwortet(server: str, port: int) -> bool:
"""Nimmt das NAS auf dem Port seines Dienstes Verbindungen an? (30 s gemerkt: Wächter und Oberfläche fragen oft.)"""
jetzt = time.monotonic()
with _antwort_lock:
if (e := _antwort_cache.get((server, port))) and jetzt - e[0] < ANTWORT_CACHE_S:
return e[1]
try:
with socket.create_connection((server, port), timeout=3):
ok = True
except OSError:
ok = False
with _antwort_lock:
_antwort_cache[(server, port)] = (jetzt, ok)
return ok
def nas(bericht: dict, pruefen=None) -> list[dict]:
pruefen = pruefen or antwortet
ergebnis = []
for m in (bericht.get("host") or {}).get("netzlaufwerke") or []:
server, port = m.get("server"), PORTS.get(str(m.get("art")))
erreichbar = pruefen(server, port) if server and port else None
platz = m.get("platz") or {}
gesamt, belegt = platz.get("gesamt"), platz.get("belegt")
anteil = belegt / gesamt if gesamt and belegt is not None else None
eintrag = {"server": server, "ziel": m.get("ziel"), "quelle": m.get("quelle"), "art": m.get("art"),
"eingebunden": bool(m.get("eingebunden")), "zustand": m.get("zustand"), "erreichbar": erreichbar,
"belegt": belegt, "gesamt": gesamt, "anteil": anteil, "stufe": "ok", "problem": None}
if erreichbar is False:
eintrag.update(stufe="rot", problem=f"Das NAS ({server}) antwortet nicht.")
elif not m.get("eingebunden"):
eintrag.update(stufe="rot", problem=f"{m.get('ziel')} ist auf dem Proxmox-Host nicht eingebunden.")
elif m.get("zustand") == "haengt":
eintrag.update(stufe="rot", problem=f"Die Einbindung {m.get('ziel')} hängt: Der Proxmox-Host bekommt keine "
"Antwort vom NAS.")
elif m.get("zustand") == "fehler":
eintrag.update(stufe="gelb", problem=f"Die Belegung von {m.get('ziel')} ließ sich nicht lesen.")
else:
eintrag["stufe"] = _stufe(anteil)
ergebnis.append(eintrag)
return ergebnis
# --- Sicherungen ------------------------------------------------------------------------------------------------
def max_alter_s(zeitplan: object) -> int:
"""Wie alt die jüngste Sicherung höchstens sein darf: täglich (nur Uhrzeit, etwa „02:30“) 26 Stunden, bei
Wochentagen oder allem Ausgefalleneren 8 Tage."""
return TAEGLICH_S if re.fullmatch(r"\s*(\d{1,2}|\*)(:\d{2})?\s*", str(zeitplan or "")) else SONST_S
def _erfasst(job: dict, vmid: int) -> bool:
"""Sichert dieser Auftrag den Gast? (alle außer den ausgenommenen, oder die aufgezählten)"""
if vmid in (job.get("ausgenommen") or []):
return False
return bool(job.get("alle")) or vmid in (job.get("vmids") or [])
def sicherungen(bericht: dict, jetzt: float) -> dict:
host = bericht.get("host") or {}
jobs = [j for j in host.get("sicherung_jobs") or [] if j.get("an")]
ziele = host.get("sicherungsziele") or {}
probleme: list[str] = []
for name in sorted({j.get("speicher") for j in jobs if j.get("speicher")}):
z = ziele.get(name) or {}
if not z.get("aktiv"):
probleme.append(f"Das Sicherungsziel {name} ist nicht erreichbar.")
elif z.get("fehler"):
probleme.append(f"Die Sicherungen auf {name} ließen sich nicht lesen.")
erfasst, fehlend, zuletzt = 0, [], None
for g in bericht.get("gaeste") or []:
eigene = [j for j in jobs if _erfasst(j, g["vmid"])]
if not eigene:
continue
erfasst += 1
stand = [((ziele.get(j.get("speicher")) or {}).get("neueste") or {}).get(str(g["vmid"])) for j in eigene]
juengste = max((t for t in stand if t), default=None)
if juengste:
zuletzt = max(zuletzt or 0, juengste)
if not any(t and jetzt - t <= max_alter_s(j.get("zeitplan")) for t, j in zip(stand, eigene, strict=True)):
fehlend.append({"ziel": _zid(g), "name": _name(g), "zuletzt": juengste})
stufe = "keine" if not jobs else "ok"
if fehlend:
stufe = "rot" if len(fehlend) == erfasst else "gelb"
if probleme:
stufe = "rot"
frische = stufe
ohne = _nicht_erfasst(bericht)
probe = host.get("sicherung_probe") if isinstance(host.get("sicherung_probe"), dict) else None
geraete = _je_geraet(bericht, ziele, {f["ziel"] for f in fehlend}, ohne, probe)
pbs = [d for z in ziele.values() if z.get("art") == "pbs" for d in (z.get("details") or {}).values()]
aelteste = min((d["aelteste"] for d in pbs if d.get("aelteste")), default=None)
pruefung = {"zuletzt": max((g["geprueft"]["zeit"] for g in geraete if g["geprueft"]), default=None),
"noetig": bool(aelteste and jetzt - aelteste >= PRUEFUNG_AB_S)}
if stufe != "rot" and (any(g["defekt"] and g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in geraete)):
stufe = "rot"
elif stufe in ("ok", "keine") and (ohne & {g["vmid"] for g in bericht.get("gaeste") or []}
or any(g["probe"] and not g["probe"]["ok"] for g in geraete)):
stufe = "gelb"
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
"gesamt": z.get("gesamt"), "schaetzung": z.get("schaetzung")} for n, z in sorted(ziele.items())],
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe,
"stufe_frische": frische, "ohne_auftrag": [{"ziel": g["ziel"], "name": g["name"]} for g in geraete if not g["auftrag"]],
"geraete": geraete, "pruefung": pruefung, "probe": {"zeit": probe.get("zeit")} if probe else None}
# --- Abdeckung je Gerät (seit 25.09.2026) --------------------------------------------------------------------------
PRUEFUNG_AB_S = 8 * 86400 # gibt es Sicherungen, die so alt sind, hätte ein wöchentlicher Prüfauftrag sie gesehen …
PRUEFUNG_ALT_S = 15 * 86400 # … und so lange darf die jüngste Prüfung dann höchstens zurückliegen
def _nicht_erfasst(bericht: dict) -> set[int]:
"""Gäste ohne Sicherungsauftrag, wie Proxmox sie meldet (Rechenzentrum → Backup; im Bericht seit 25.09.2026). Ein
älterer Ausführer liefert das nicht: dann selbst ausgerechnet, ausgeschaltete Aufträge zählen mit."""
host = bericht.get("host") or {}
if isinstance(host.get("nicht_gesichert"), list):
return {int(v) for v in host["nicht_gesichert"] if str(v).isdigit()}
if not isinstance(host.get("sicherung_jobs"), list):
return set()
jobs = host["sicherung_jobs"]
return {g["vmid"] for g in bericht.get("gaeste") or [] if not any(_erfasst(j, g["vmid"]) for j in jobs)}
def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], probe: dict | None) -> list[dict]:
"""Je Gerät: ob ein Auftrag es erfasst, die jüngste Sicherung (über alle Ziele), ob sie frisch ist, die jüngste
Prüfung durch den PBS, beschädigte Sicherungen und das Ergebnis der Probe-Wiederherstellung."""
zeilen = []
for g in sorted(bericht.get("gaeste") or [], key=lambda g: g["vmid"]):
vmid = str(g["vmid"])
details = [d for z in ziele.values() if (d := (z.get("details") or {}).get(vmid))]
zuletzt = max([t for z in ziele.values() if (t := (z.get("neueste") or {}).get(vmid))], default=None)
pruefungen = [d["geprueft"] for d in details if d.get("geprueft")]
eigene_probe = ((probe or {}).get("gaeste") or {}).get(vmid)
zeilen.append({
"ziel": _zid(g), "name": _name(g), "vmid": g["vmid"], "auftrag": g["vmid"] not in ohne,
"zuletzt": zuletzt, "frisch": bool(zuletzt) and g["vmid"] not in ohne and _zid(g) not in fehlend,
"geprueft": max(pruefungen, key=lambda p: (p["zeit"], p["sicherung"]), default=None),
"defekt": sorted({t for d in details for t in d.get("defekt") or []}),
"probe": {**eigene_probe, "zeit": probe.get("zeit")} if eigene_probe else None})
return zeilen
def probe_starten() -> dict:
"""Knopf „Jetzt prüfen“: die Probe-Wiederherstellung als Auftrag an den Ausführer (nur lesend). Das Ergebnis
bringt der Bericht gleich danach."""
zuletzt = kanal.zuletzt()
if not zuletzt or time.time() - zuletzt > BERICHT_ALT_S:
return {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host meldet sich gerade nicht."}
if any(a.get("aktion") == "sicherung_probe" and a.get("status") in ("wartet", "laeuft") for a in kanal.liste()):
return {"ok": False, "detail": "Die Probe läuft schon."}
return {"ok": True, "auftrag": kanal.anlegen("sicherung_probe")}
def _aus_schaetzung(z: dict, jetzt: float) -> dict | None:
"""Die Schätzung des PBS als Vorhersage (wie kern/prognose.vorhersage, mit „quelle“: „pbs“)."""
s = z.get("schaetzung")
if not isinstance(s, dict):
return None
anteil = _prozent(s.get("belegt") or z.get("belegt"), s.get("gesamt") or z.get("gesamt"))
voll_am = s.get("voll_am")
if not messreihen.ist_zahl(voll_am) or voll_am <= jetzt:
return {"tage": None, "rate": 0.0, "anteil": anteil, "grund": "gleich", "quelle": "pbs"}
tage = (voll_am - jetzt) / 86400
rate = round((100 - anteil) / tage, 2) if anteil is not None and tage > 0 else None
return {"tage": round(tage, 1), "rate": rate, "anteil": anteil, "grund": None, "quelle": "pbs"}
# --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) --------------------------------------------------------
VERLAUF = "speicher" # Quelle in kern/messreihen.py: die Belegungen aus jedem Bericht (alle zehn Minuten)
def _prozent(belegt: object, gesamt: object) -> float | None:
if not (messreihen.ist_zahl(belegt) and messreihen.ist_zahl(gesamt)) or gesamt <= 0:
return None
return round(belegt / gesamt * 100, 2)
def verlauf_merken(bericht: dict, jetzt: float | None = None) -> None:
"""Die Belegungen eines Berichts als ein Punkt der Messreihe VERLAUF (in %): je Speicherpool Daten
(„pool:<name>“) und Metadaten („pool-meta:<name>“), je Netzlaufwerk („nas:<ziel>“), je Sicherungsziel
(„ziel:<name>“) und die Platte jeder VM („vm-<vmid>“ — die Minutenwerte kennen sie nicht). Ein kaputter Bericht
kostet nur diesen Punkt."""
try:
host = bericht.get("host") if isinstance(bericht.get("host"), dict) else {}
werte: dict[str, float | None] = {}
for p in pools(bericht):
werte[f"pool:{p['speicher']}"] = round(p["anteil"] * 100, 2)
if messreihen.ist_zahl(p["meta_anteil"]):
werte[f"pool-meta:{p['speicher']}"] = round(p["meta_anteil"] * 100, 2)
for m in host.get("netzlaufwerke") or []:
platz = m.get("platz") or {}
werte[f"nas:{m.get('ziel')}"] = _prozent(platz.get("belegt"), platz.get("gesamt"))
for name, z in (host.get("sicherungsziele") or {}).items():
werte[f"ziel:{name}"] = _prozent(z.get("belegt"), z.get("gesamt"))
for g in bericht.get("gaeste") or []:
if g.get("art") == "qemu":
platte = g.get("platte") or {}
werte[f"vm-{g['vmid']}"] = _prozent(platte.get("belegt"), platte.get("gesamt"))
werte = {name: wert for name, wert in werte.items() if wert is not None}
if werte:
messreihen.schreiben(VERLAUF, werte, jetzt)
except Exception:
log.warning("speicher: Verlauf der Belegungen nicht geschrieben", exc_info=True)
def platten_reihe(geraet_id: str) -> tuple[str, str, float] | None:
"""Wo der Verlauf der Platte eines Geräts liegt: (Quelle, Name, voll bei %). Host und Container in den
Minutenwerten; VMs im VERLAUF, denn ihre Platte kennt nur der Bericht (über den Gast-Agenten)."""
if geraet_id == "pve":
return "pve", "platte", prognose.VOLL_EXT4
if re.fullmatch(r"ct-\d+", geraet_id):
return geraet_id, "platte", prognose.VOLL_EXT4
if re.fullmatch(r"vm-\d+", geraet_id):
return VERLAUF, geraet_id, prognose.VOLL_EXT4
return None
def platten_vorhersage(geraet_id: str, jetzt: float | None = None) -> dict | None:
reihe = platten_reihe(geraet_id)
return prognose.aus_messreihe(*reihe, jetzt=jetzt) if reihe else None
def _vorhersage(name: str, jetzt: float | None) -> dict:
return prognose.aus_messreihe(VERLAUF, name, jetzt=jetzt)
def pool_vorhersage(speicher: str, jetzt: float | None = None) -> tuple[dict, bool]:
"""Die Vorhersage eines Pools — Daten oder Metadaten, was früher voll ist — und ob es die Metadaten sind."""
daten, meta = _vorhersage(f"pool:{speicher}", jetzt), _vorhersage(f"pool-meta:{speicher}", jetzt)
return (meta, True) if prognose.bis_voll(meta) < prognose.bis_voll(daten) else (daten, False)
# --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
def lage(jetzt: float | None = None) -> dict:
"""Für GET /api/homelab/speicher. Pools, Systemplatte, NAS und Sicherungsziele tragen seit 25.09.2026 je eine
„vorhersage“ (kern/prognose.vorhersage). jetzt nur für Tests: Dann wird die Vorhersage nicht gemerkt."""
b = _bericht()
zeit = time.time() if jetzt is None else jetzt
ergebnis = {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b),
"sicherungen": sicherungen(b, zeit), "empfehlungen": empfehlungen(b)}
for p in ergebnis["pools"]:
p["vorhersage"] = pool_vorhersage(p["speicher"], jetzt)[0]
if ergebnis["systemplatte"]:
ergebnis["systemplatte"]["vorhersage"] = platten_vorhersage("pve", jetzt)
for n in ergebnis["nas"]:
n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
for z in ergebnis["sicherungen"]["ziele"]:
z["vorhersage"] = _aus_schaetzung(z, zeit) or _vorhersage(f"ziel:{z['speicher']}", jetzt)
return ergebnis
def pruefe_speicher(jetzt: float | None = None) -> list:
"""Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer().
Pool, Systemplatte und NAS melden sich auch nach dem Tempo (siehe oben); bestimmt die Vorhersage die Stufe, steht
„in etwa N Tagen voll“ im Titel, sonst hängt der Text sie an."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
b = _bericht()
if not b:
return []
zeit = time.time() if jetzt is None else jetzt
befunde = []
tipps = empfehlungen(b)
platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3])
for p in pools(b):
v, meta = pool_vorhersage(p["speicher"], jetzt)
stufe, kommend = prognose.schlimmer(p["stufe"], v)
if stufe == "ok":
continue
rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.")
if kommend:
was = "Metadaten des Speicherpools" if meta else "Speicherpool"
titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll"
text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}"
else:
anteil = max(p["anteil"], p["meta_anteil"] or 0)
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
titel = f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll"
text = f"Noch {_groesse(p['frei'])} frei.{prognose.dazu(v)} {rest}"
befunde.append(Befund(id=f"pool:{p['speicher']}", stufe=stufe, titel=titel, text=text, quelle="pve"))
sp = systemplatte(b)
v = platten_vorhersage("pve", jetzt) if sp else None
stufe, kommend = prognose.schlimmer(sp["stufe"], v) if sp else ("ok", False)
if sp and stufe != "ok":
kernel = next((e for e in tipps if e["art"] == "kernel"), None)
befunde.append(Befund(
id="host-platte", stufe=stufe,
titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend
else f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll"),
text=((f"{prognose.tempo(v)} " if kommend else "") + f"Noch {_groesse(sp['frei'])} frei."
+ ("" if kommend else prognose.dazu(v))
+ " Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
+ (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)."
if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
quelle="pve"))
for n in nas(b):
v = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
stufe, kommend = prognose.schlimmer(n["stufe"], v)
if stufe == "ok":
continue
frei = _groesse((n["gesamt"] or 0) - (n["belegt"] or 0))
platz = ("Die Sicherungen brauchen jede Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder "
"alte Dateien auf dem NAS aufräumen.")
if n["problem"]:
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
elif kommend:
titel = f"NAS ({n['server']}) {prognose.in_tagen(v['tage'])} voll"
text = f"{prognose.tempo(v)} Noch {frei} frei. {platz}"
else:
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
s = sicherungen(b, zeit)
if s["stufe_frische"] in ("gelb", "rot"):
teile = list(s["probleme"])
if s["fehlend"]:
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
for f in s["fehlend"][:6])
teile.append(f"Keine frische Sicherung: {namen}.")
befunde.append(Befund(
id="sicherungen", stufe=s["stufe_frische"],
titel=("Sicherungen scheitern" if s["stufe_frische"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
+ ("en" if len(s["fehlend"]) != 1 else "")),
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
quelle="pve"))
befunde += _abdeckung_befunde(s, zeit)
for z in s["ziele"]:
v = _aus_schaetzung(z, zeit)
if prognose.stufe(v) != "ok":
befunde.append(Befund(
id=f"ziel:{z['speicher']}", stufe=prognose.stufe(v),
titel=f"Sicherungsziel {z['speicher']} {prognose.in_tagen(v['tage'])} voll",
text=(f"So schätzt es der PBS selbst. Noch {_groesse((z['gesamt'] or 0) - (z['belegt'] or 0))} frei. Im PBS "
"die Aufbewahrung kürzen (Datastore → Prune) oder Platz auf dem NAS schaffen."),
quelle="pve"))
return befunde
def _tag(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.")
def _abdeckung_befunde(s: dict, jetzt: float) -> list:
"""Die Hinweise zur Abdeckung: ohne Auftrag, beschädigt, lange nicht geprüft, Probe gescheitert."""
from services.waechter import Befund
befunde = []
if ohne := s["ohne_auftrag"]:
namen = ", ".join(g["name"] for g in ohne)
befunde.append(Befund(
id="sicherung:ohne-auftrag", stufe="gelb", sofort=True,
titel=(f"{namen} ist in keinem Sicherungsauftrag" if len(ohne) == 1
else f"{len(ohne)} Geräte sind in keinem Sicherungsauftrag"),
text=(("Proxmox sichert es nie. " if len(ohne) == 1 else f"Proxmox sichert sie nie: {namen}. ")
+ "In Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten und die Geräte anhaken (oder „Alle“)."),
quelle="pve"))
if defekt := [g for g in s["geraete"] if g["defekt"]]:
akut = any(g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in defekt)
teile = ", ".join(f"{g['name']} (Sicherung vom {_tag(g['defekt'][-1])})" for g in defekt[:6])
befunde.append(Befund(
id="sicherung:defekt", stufe="rot" if akut else "gelb",
titel=("Der PBS fand eine beschädigte Sicherung" if len(defekt) == 1
else f"Der PBS fand beschädigte Sicherungen bei {len(defekt)} Geräten"),
text=(f"Beim Prüfen stimmten Daten nicht: {teile}. Die nächste nächtliche Sicherung sollte wieder "
"vollständig sein — danach die beschädigte im PBS löschen (Datastore → Inhalt) und am NAS nach "
"Plattenfehlern sehen."),
quelle="pve"))
pruefung = s["pruefung"]
if pruefung["noetig"] and (not pruefung["zuletzt"] or jetzt - pruefung["zuletzt"] > PRUEFUNG_ALT_S):
befunde.append(Befund(
id="sicherung:pruefung", stufe="gelb",
titel=("Der PBS prüft die Sicherungen nicht" if not pruefung["zuletzt"]
else f"Der PBS hat seit {int((jetzt - pruefung['zuletzt']) // 86400)} Tagen keine Sicherung geprüft"),
text=("Ob eine Sicherung noch lesbar ist, zeigt erst die Prüfung. Im PBS: Datastore → Verify Jobs — läuft "
"dort ein Auftrag? Wenn keiner da ist: „Add“, etwa einmal die Woche."),
quelle="pve"))
if gescheitert := [g for g in s["geraete"] if g["probe"] and not g["probe"]["ok"]]:
befunde.append(Befund(
id="sicherung:probe", stufe="gelb",
titel=(f"Probe-Wiederherstellung gescheitert: {gescheitert[0]['name']}" if len(gescheitert) == 1
else f"Probe-Wiederherstellung bei {len(gescheitert)} Geräten gescheitert"),
text=(" ".join(f"{g['name']}: {g['probe']['fehler']}." for g in gescheitert[:4])
+ " Die Probe öffnet die jüngste Sicherung nur lesend und läuft in einer Stunde wieder."),
quelle="pve"))
return befunde
+459
View File
@@ -0,0 +1,459 @@
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
Ablauf für einen Gast:
1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen
Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert
dieser Schritt, beginnt das Update gar nicht.
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes
Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py).
3. 20 s warten, frischen Bericht holen
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der
Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“.
Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf,
danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit
25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg.
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
„Rückweg testen“ (Baustein „probe“, seit 25.09.2026): Snapshot bzw. Sicherung anlegen, die Prüfung absichtlich als
rot werten, zurückgehen und nachprüfen, ob das Gerät danach läuft und antwortet. Ein Update gibt es dabei nicht; so
lässt sich der Weg zurück jederzeit echt beweisen, ohne auf ein gescheitertes Update zu warten.
„Alle aktualisieren“ (sammellauf.py, seit 24.09.2026) stößt dieselben Läufe nacheinander an. Solange es läuft,
lehnt der einzelne Knopf ab (SAMMELLAUF_SPERRE), und die Erfolgsmeldung eines Schritts entfällt — die Sammelmeldung
am Ende sagt es; Fehler werden weiter sofort gemeldet.
"""
import json
import logging
import re
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60
ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf)
ARCANE_FRIST_S = 180 # so lange darf Arcane nach einem Docker-Update brauchen, bis es wieder antwortet
BETREFF = "[Homelab-Update]"
BETREFF_ALARM = "[Alarm] Homelab-Update"
SAMMELLAUF_SPERRE = "Es läuft gerade ‚Alle aktualisieren‘."
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
# Prüfen und Anlegen eines Laufs am Stück — auch „Alle aktualisieren“ legt sich unter ihr an. Sonst könnte ein
# einzelner Knopf genau zwischen Prüfung und Anlegen des Sammellaufs durchrutschen.
START_SPERRE = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-laeufe.json"
def _laeufe() -> list[dict]:
with _lock:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
return daten if isinstance(daten, list) else []
def _merken(lauf: dict) -> None:
with _lock:
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad())
def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl]
def lauf_lesen(lauf_id: str) -> dict | None:
return next((x for x in _laeufe() if x.get("id") == lauf_id), None)
def laufende_ziele() -> set[str]:
"""Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege)."""
return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"}
def laeuft(ziel_id: str) -> bool:
return ziel_id in laufende_ziele()
def unterbrochene_abschliessen() -> None:
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess — ebenso ein laufendes
„Alle aktualisieren“ (danach, damit es den unterbrochenen Lauf schon als solchen sieht)."""
for lauf in _laeufe():
if lauf.get("status") == "laeuft":
lauf.update(status="unterbrochen", ende=time.time())
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
_merken(lauf)
from services.homelab import sammellauf # sammellauf importiert dieses Modul
sammellauf.unterbrochene_abschliessen()
def _sammellauf_laeuft() -> bool:
from services.homelab import sammellauf
return sammellauf.laeuft()
def _melden(text: str, dringend: bool = False) -> None:
try:
announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text)
except Exception:
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
aid = kanal.anlegen(aktion, parameter)
lauf["schritte"].append(f"{aktion} …")
_merken(lauf)
a = kanal.warten(aid, zeitlimit_s)
if a is None:
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
if a["status"] != "fertig":
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
lauf["schritte"][-1] = f"{aktion}: ok"
return a
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
try:
a = _auftrag(lauf, "bericht", {}, 5 * 60)
bericht = json.loads(a.get("text") or "{}")
kanal.bericht_speichern(bericht, aus_auftrag=True)
except (RuntimeError, ValueError):
return None
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
"""Was nach dem Update nicht stimmt (leer = grün)."""
if nachher is None:
return ["Der Gast fehlt im neuen Bericht."]
fehler = []
if nachher.get("status") != "running":
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
url = apps.adresse(app, nachher.get("ip"))
if url and not inventar.erreichbar_frisch(url):
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
if baustein == "app":
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
if alt and neu and alt == neu:
fehler.append(f"Die App-Version ist unverändert ({alt}).")
return fehler
def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None:
"""Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError,
wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht."""
vmid = gast["vmid"]
if gast.get("snapshot_moeglich"):
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
return ("snapshot", m.group(1)) if m else None
if gast.get("sicherung_moeglich"):
a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S)
m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE)
if not m:
raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung")
return ("sicherung", m.group(1))
return None
def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str:
"""Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert."""
art, name = rueckweg
if art == "snapshot":
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
return f"automatisch zurück auf den Snapshot {name}"
_auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S)
return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})"
def _gast_lauf(lauf: dict, gast: dict) -> None:
vmid, baustein = gast["vmid"], lauf["baustein"]
try:
rueckweg = _rueckweg_anlegen(lauf, gast)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. "
"Am Gerät wurde nichts geändert.")
return
try:
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
time.sleep(WARTEN_NACH_UPDATE_S)
nachher = _frischer_gast(lauf, vmid)
if baustein == "app" and nachher:
lauf["version_neu"] = (nachher.get("app") or {}).get("version") # fürs Protokoll
fehler = pruefen(gast, nachher, baustein)
except RuntimeError as exc:
fehler = [str(exc)]
if not fehler:
_aufraeumen(lauf, vmid, rueckweg)
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
return
if rueckweg:
try:
wie = _zurueck(lauf, vmid, rueckweg)
_ende(lauf, "zurueckgerollt",
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.",
dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
if rueckweg[0] == "sicherung":
fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.")
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
+ ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, also keinen automatischen "
"Rückweg."),
dringend=True)
def _probe_lauf(lauf: dict, gast: dict) -> None:
"""Rückweg testen: anlegen, absichtlich rot werten, zurückgehen, nachprüfen. Der Snapshot bzw. die Sicherung
bleibt danach liegen (wie nach einem echten Rückweg) — wegräumen geht auf der Seite Snapshots."""
vmid = gast["vmid"]
try:
rueckweg = _rueckweg_anlegen(lauf, gast)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe nicht begonnen — {str(exc).rstrip('.')}. "
"Am Gerät wurde nichts geändert.")
return
if not rueckweg:
_ende(lauf, "fehler", f"{lauf['name']}: Für dieses Gerät gibt es keinen Rückweg (weder Snapshot noch "
"Sicherung) — die Probe ist nicht möglich.")
return
lauf["schritte"].append("Prüfung absichtlich auf Rot gesetzt (Probe, kein Update).")
_merken(lauf)
try:
wie = _zurueck(lauf, vmid, rueckweg)
time.sleep(WARTEN_NACH_UPDATE_S)
fehler = pruefen(gast, _frischer_gast(lauf, vmid), "probe")
except RuntimeError as exc:
wie, fehler = "", [str(exc)]
if fehler:
_ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe rot — {' '.join(fehler)}", dringend=True)
return
_ende(lauf, "probe-gruen", f"{lauf['name']}: Rückweg-Probe grün — {wie}, danach läuft das Gerät und antwortet.")
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per
Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User."""
from services.homelab import snapshots # snapshots importiert dieses Modul
art, behalten = rueckweg or (None, None)
gast = inventar.gast(lauf["ziel"]) or {}
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
for name in gast.get("snapshots") or []:
if name.startswith("mc2-") and name != behalten and name not in per_knopf:
try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
if art != "sicherung":
return
for volid in gast.get("sicherungen") or []:
if volid != behalten:
try:
_auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.")
def _antwortet_wieder(url: str) -> bool:
"""Nach dem Update starten Container neu: Arcane darf sich ARCANE_FRIST_S lang sammeln, bevor es rot heißt."""
ende = time.monotonic() + ARCANE_FRIST_S
while not inventar.erreichbar_frisch(url):
if time.monotonic() >= ende:
return False
time.sleep(10)
return True
def _docker_lauf(lauf: dict, basis: str, gast: dict) -> None:
"""Docker über Arcanes Updater — ein Probelauf, bis der Schalter in den Einstellungen auf „echt“ steht
(User-Entscheid 24.09.2026). Echt wie bei den Containern: vorher ein Snapshot der VM, wenn der Ausführer sie
anfassen darf (Etikett watcher); ist die Prüfung danach rot, geht es darauf zurück."""
probe = not arcane.echt()
rueckweg = None
if not probe and gast.get("erlaubt"):
try:
rueckweg = _rueckweg_anlegen(lauf, gast)
except RuntimeError as exc:
_ende(lauf, "fehler", f"Arcane: Update nicht begonnen — {str(exc).rstrip('.')}. "
"Am Gerät wurde nichts geändert.")
return
try:
ergebnisse = arcane.updater(basis, probelauf=probe)
except Exception as exc:
if probe:
_ende(lauf, "fehler", f"Arcane: Probelauf gescheitert — {exc}")
return
ergebnisse, fehler = {}, [f"Der Updater brach ab ({exc})."]
else:
fehler = []
gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")}
namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or []
if isinstance(i, dict) and i.get("updateAvailable")]
if probe:
_ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert"
+ (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.")
return
if gesamt["failed"]:
fehler.append(f"{gesamt['failed']} Container ließen sich nicht aktualisieren, {gesamt['updated']} schon.")
if not _antwortet_wieder(basis + "/"):
fehler.append("Arcane antwortet danach nicht.")
if not fehler:
if rueckweg:
_aufraeumen(lauf, gast["vmid"], rueckweg)
# Anders als bei den Containern bleibt auch dieser nicht stehen: Die VM schreibt laufend (Docker, Rippy),
# ein Snapshot wüchse im Thin-Pool bis zum nächsten Update mit. Sein Zweck war der Weg zurück bei Rot.
try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": gast["vmid"], "snapshot": rueckweg[1]}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Snapshot {rueckweg[1]} blieb stehen.")
_ende(lauf, "eingespielt", f"Arcane: {gesamt['updated']} Container aktualisiert, Prüfung grün.")
return
if rueckweg:
try:
wie = _zurueck(lauf, gast["vmid"], rueckweg)
_ende(lauf, "zurueckgerollt", f"Arcane: Update gescheitert ({' '.join(fehler)}) — {wie}. "
"Läuft wieder wie vorher.", dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
_ende(lauf, "fehler", f"Arcane: Update gescheitert — {' '.join(fehler)}"
+ ("" if rueckweg else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
dringend=True)
def _host_lauf(lauf: dict) -> None:
try:
if lauf["baustein"] == "kernel":
# Welche Kernel gehen, bestimmt der Ausführer selbst (nie der laufende oder der nächste).
a = _auftrag(lauf, "kernel_aufraeumen", {}, ZEITLIMIT_SICHERUNG_S)
werte = dict(z.split("=", 1) for z in str(a.get("text") or "").splitlines()[:2] if "=" in z)
anzahl, frei = int(werte.get("entfernt") or 0), int(werte.get("frei") or 0)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "aufgeraeumt", f"Proxmox-Host: {anzahl} alte Kernel entfernt, "
f"{frei / 1e9:.1f} GB frei geworden.".replace(".", ",", 1)
if anzahl else "Proxmox-Host: kein alter Kernel zu entfernen.")
return
if lauf["baustein"] == "neustart":
_auftrag(lauf, "host_neustart", {}, 60)
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
return
_auftrag(lauf, "host_update", {}, 60 * 60)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
except RuntimeError as exc:
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
_ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True)
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
# Schritt von „Alle aktualisieren“: Den Erfolg sagt die Sammelmeldung am Ende; Fehler gehen sofort raus.
still = bool(lauf.get("sammellauf")) and ergebnis == "eingespielt" and not dringend
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend,
gemeldet=not still)
_merken(lauf)
# Eine Rückweg-Probe ist kein Update: Sie steht im Protokoll, nicht im Update-Verlauf.
if lauf.get("baustein") != "probe":
try:
# Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ)
anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand"
update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text)
except Exception:
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
if not still:
_melden(text, dringend=dringend)
def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None = None, **mehr: object) -> dict:
lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft",
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr}
if sammellauf:
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
_merken(lauf)
return lauf
def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict:
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
der Knopf ab, solange ein Sammellauf läuft."""
if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe"):
return {"ok": False, "detail": "Unbekannter Baustein."}
with START_SPERRE:
if sammellauf is None and _sammellauf_laeuft():
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
if laeuft(ziel_id):
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
if ziel_id == "pve" and baustein == "probe":
return {"ok": False, "detail": "Für den Proxmox-Host gibt es keinen Rückweg zum Testen."}
if ziel_id == "pve":
name, gast = "Proxmox-Host", None
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {})
else:
gast = inventar.gast(ziel_id)
if not gast:
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
if baustein == "docker":
app = apps.app_fuer(None, gast.get("name"))
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf)
threading.Thread(target=_docker_lauf, args=(lauf, basis, gast), name=f"homelab-{ziel_id}",
daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
if not gast.get("erlaubt"):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
name = app.name if app else str(gast.get("name"))
if baustein == "probe":
if not (gast.get("snapshot_moeglich") or gast.get("sicherung_moeglich")):
return {"ok": False, "detail": f"{name} hat keinen Rückweg (weder Snapshot noch Sicherung)."}
lauf = _neuer_lauf(ziel_id, baustein, name, None)
threading.Thread(target=_probe_lauf, args=(lauf, gast), name=f"homelab-{ziel_id}",
daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
if baustein == "app" and (not app or app.weg != "skript"):
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
"(eigene Oberfläche oder Pakete)."}
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
return {"ok": False, "detail": sperre}
# Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete.
mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app"
else {"pakete": (gast.get("os_updates") or {}).get("anzahl")})
lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr)
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
+142
View File
@@ -0,0 +1,142 @@
"""Zertifikats-Wache (Ausbauplan Welle 1, seit 25.09.2026).
Der Ausführer liest höchstens alle 30 Minuten die https-Zertifikate (host.zertifikate im Bericht): die von NPMplus —
Let's Encrypt im kurzlebigen Profil, am 25.09.2026 gut 6,7 Tage Laufzeit — samt dem, was NPMplus für den Namen gerade
ausliefert, das des Proxmox-Hosts (vom Cluster ausgestellt, zwei Jahre) und das des PBS (selbst signiert). Eine feste
30-Tage-Grenze passt zu so verschiedenen Laufzeiten nicht; die Grenzen richten sich nach der Laufzeit:
• gelb „wird nicht erneuert“: Bei kurzlebigen Zertifikaten (bis 100 Tage) ist weniger als ein Drittel der Laufzeit
übrig — NPMplus erneuert lange vorher. Bei langlebigen ab 30 Tagen vor Ablauf.
• rot: weniger als ein Sechstel übrig, dabei höchstens 7 und mindestens 1 Tag vor Ablauf; abgelaufen sowieso.
• gelb: NPMplus liefert ein älteres Zertifikat aus, als es hat (nach der Erneuerung nicht neu geladen).
Hinweise gehen über den Wächter (pruefe_zertifikate), je Quelle einer; die Liste zeigt das Homelab-Cockpit.
"""
import time
from datetime import datetime
from kern.zeit import LOCAL_TZ
from services.homelab import kanal
TAG_S = 86400
KURZLEBIG_S = 100 * TAG_S
LANG_GELB_S = 30 * TAG_S
ROT_MAX_S, ROT_MIN_S = 7 * TAG_S, TAG_S
AUSGELIEFERT_TOLERANZ_S = 60
RANG = {"ok": 0, "gelb": 1, "rot": 2}
QUELLE = {"npm": "NPMplus", "pve": "Proxmox-Host", "pbs": "Proxmox Backup Server"}
WESSEN = {"pve": "des Proxmox-Hosts", "pbs": "des PBS"}
def _datum(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
def _zeitraum(sekunden: float) -> tuple[int, str, str]:
"""(Anzahl, Einheit im Akkusativ, Einheit im Dativ) — 20 Stunden, 5 Tage, 3 Monate, 2 Jahre."""
if sekunden < TAG_S:
return max(1, round(sekunden / 3600)), "Stunden", "Stunden"
tage = round(sekunden / TAG_S)
if tage < 60:
return tage, "Tage", "Tagen"
return (round(tage / 30), "Monate", "Monaten") if tage < 730 else (round(tage / 365), "Jahre", "Jahren")
def bis_ablauf(sekunden: float) -> str:
"""„in 20 Stunden“, „in einem Tag“, „in 5 Tagen“, „in 3 Monaten“."""
n, _, dativ = _zeitraum(sekunden)
if n == 1:
return {"Stunden": "in einer Stunde", "Tagen": "in einem Tag", "Monaten": "in einem Monat",
"Jahren": "in einem Jahr"}[dativ]
return f"in {n} {dativ}"
def dauer(sekunden: float) -> str:
"""„20 Stunden“, „einen Tag“, „5 Tage“ — für „noch … gültig“."""
n, akkusativ, _ = _zeitraum(sekunden)
if n == 1:
return {"Stunden": "eine Stunde", "Tage": "einen Tag", "Monate": "einen Monat", "Jahre": "ein Jahr"}[akkusativ]
return f"{n} {akkusativ}"
def bewerten(z: dict, jetzt: float) -> dict:
"""Ein Zertifikat aus dem Bericht mit Stufe, Art des Problems (abgelaufen, ablauf, haengt, alt) und einem Satz."""
von, bis = z.get("von"), z["bis"]
laufzeit = bis - von if von else None
rest = bis - jetzt
kurzlebig = laufzeit is not None and laufzeit <= KURZLEBIG_S
rot_ab = max(ROT_MIN_S, min(ROT_MAX_S, laufzeit / 6)) if laufzeit else ROT_MAX_S
ausgeliefert = z.get("ausgeliefert_bis")
alt = bool(ausgeliefert and ausgeliefert < bis - AUSGELIEFERT_TOLERANZ_S)
stufe, art, satz = "ok", None, None
if rest <= 0:
stufe, art, satz = "rot", "abgelaufen", f"abgelaufen am {_datum(bis)}"
elif rest < rot_ab or (not kurzlebig and rest < LANG_GELB_S):
stufe, art, satz = "rot" if rest < rot_ab else "gelb", "ablauf", f"läuft {bis_ablauf(rest)} ab ({_datum(bis)})"
elif kurzlebig and rest < laufzeit / 3:
stufe, art, satz = "gelb", "haengt", f"wird nicht erneuert, noch {dauer(rest)} gültig (bis {_datum(bis)})"
elif alt:
stufe, art, satz = "gelb", "alt", f"ausgeliefert wird noch das alte (bis {_datum(ausgeliefert)})"
namen = [n for n in z.get("namen") or [] if n]
# Anzeigename: bei Proxmox-Host und PBS der Rechnername (ihr erster Name ist „localhost“), sonst der erste Name.
eigener = z.get("inhaber") if z.get("quelle") in WESSEN else None
name = eigener or next((n for n in namen if n != "localhost"), None) or z.get("inhaber") or z.get("name")
return {"quelle": z.get("quelle"), "wo": QUELLE.get(z.get("quelle"), z.get("quelle")), "gast": z.get("gast"),
"name": name, "namen": namen, "datei": z.get("name"),
"aussteller": z.get("aussteller"), "von": von, "bis": bis, "rest_s": int(rest),
"laufzeit_s": int(laufzeit) if laufzeit else None, "stufe": stufe, "art": art, "problem": satz}
def lage(jetzt: float | None = None) -> dict:
"""Für GET /api/homelab/zertifikate: die Zertifikate, Probleme zuerst, dann das am frühesten ablaufende.
„gelesen“: ob der Ausführer schon Zertifikate meldet (einer von vor dem 25.09.2026 tut es nicht)."""
jetzt = time.time() if jetzt is None else jetzt
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
roh = host.get("zertifikate")
liste = [bewerten(z, jetzt) for z in roh or [] if isinstance(z, dict) and isinstance(z.get("bis"), int)]
liste.sort(key=lambda z: (-RANG[z["stufe"]], z["bis"]))
return {"gelesen": isinstance(roh, list), "zertifikate": liste}
RAT = {
"npm": ("In NPMplus: SSL Certificates → beim Zertifikat „…“ → Renew. Klappt das nicht, steht der Grund im Protokoll "
"von NPMplus (Container {gast}) — oft ist die Domain von außen nicht erreichbar."),
"pve": "In Proxmox: pve → System → Zertifikate. Das vom Cluster ausgestellte erneuert `pvecm updatecerts --force`.",
"pbs": "Im PBS: Konfiguration → Zertifikate — dort ein neues ausstellen oder hochladen.",
}
def _titel(quelle: str, faelle: list[dict], stufe: str) -> str:
if len(faelle) > 1:
return (f"{len(faelle)} Zertifikate von NPMplus laufen bald ab" if stufe == "rot"
else f"NPMplus erneuert {len(faelle)} Zertifikate nicht")
z = faelle[0]
wessen = f"für {z['name']}" if quelle == "npm" else WESSEN.get(quelle, z["name"])
if z["art"] == "alt":
return f"NPMplus liefert für {z['name']} noch das alte Zertifikat aus"
if z["art"] == "abgelaufen":
return f"Zertifikat {wessen} ist abgelaufen"
if z["art"] == "haengt":
return f"Zertifikat {wessen} wird nicht erneuert"
return f"Zertifikat {wessen} läuft {bis_ablauf(z['rest_s'])} ab"
def pruefe_zertifikate(jetzt: float | None = None) -> list:
"""Befunde für den Wächter: je Quelle (NPMplus, Proxmox-Host, PBS) einer mit der ernstesten Stufe."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
befunde = []
liste = lage(jetzt)["zertifikate"]
for quelle in QUELLE:
faelle = [z for z in liste if z["quelle"] == quelle and z["stufe"] != "ok"]
if not faelle:
continue
stufe = max((z["stufe"] for z in faelle), key=RANG.get)
nur_alt = all(z["art"] == "alt" for z in faelle)
rat = ("NPMplus einmal neu starten, dann liefert es das neue aus." if nur_alt
else "Läuft es ab, warnen Browser, und Apps verbinden sich nicht mehr. "
+ RAT[quelle].format(gast=faelle[0]["gast"]))
einzeln = "; ".join(f"{z['name']} {z['problem']}" for z in faelle[:6])
befunde.append(Befund(id=f"zertifikat:{quelle}", stufe=stufe, titel=_titel(quelle, faelle, stufe),
text=f"{einzeln}. {rat}",
quelle=f"ct-{faelle[0]['gast']}" if faelle[0]["gast"] else "pve"))
return befunde
-138
View File
@@ -1,138 +0,0 @@
"""
Ideen-Queue — DIE eine Sammelstelle für Ideen des Commanders (Entscheid 10.07.2026).
Die Queue selbst ist das NATIVE Hermes-Kanban (SQLite-Board, Dispatcher im Gateway):
Idee rein (triage) → der Specifier arbeitet sie aus → ein Worker-Profil setzt sie um →
Code-Ergebnisse kommen als Vorschlags-Branch zurück und erscheinen als Karte im
Auftragsbuch. Dieser Service ist nur die MC2-Tür dazu: anlegen + anzeigen + aufräumen,
alles über die Hermes-CLI (stabile --json-Schnittstelle, kein Griff in die kanban.db).
Türen insgesamt: Telegram/Desktop (natives kanban_create-Tool), Lucy-Voice
(mcp_voice.idee_notieren → POST /api/ideen) und die Zentrale (Auftragsbuch-Tab → hier).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import subprocess
import time
from pathlib import Path
log = logging.getLogger(__name__)
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
_TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$")
# Die CLI kostet pro Aufruf ein paar Sekunden Python-Start — die UI pollt aber.
_list_cache: dict = {"ts": 0.0, "data": None}
_LIST_EVERY = 15.0 # s
def _available() -> bool:
return os.name == "posix" and HERMES.is_file()
def _hermes(args: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run([str(HERMES), "kanban", *args],
capture_output=True, text=True, timeout=timeout)
def _parse_json(stdout: str):
"""CLI-Ausgabe kann Log-Zeilen vor dem JSON enthalten — ab der ersten Klammer parsen."""
text = stdout or ""
for opener in ("[", "{"):
idx = text.find(opener)
if idx >= 0:
try:
return json.loads(text[idx:])
except json.JSONDecodeError:
continue
return None
def list_queue() -> dict:
"""Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI."""
if not _available():
return {"available": False, "items": [], "offen": 0}
now = time.time()
if _list_cache["data"] is not None and now - _list_cache["ts"] < _LIST_EVERY:
return _list_cache["data"]
try:
r = _hermes(["list", "--json", "--sort", "created-desc"])
raw = _parse_json(r.stdout)
except Exception:
log.warning("ideen: kanban list fehlgeschlagen", exc_info=True)
raw = None
if not isinstance(raw, list):
# CLI kaputt/Timeout → ehrlich leer melden, aber nicht cachen (nächster Poll versucht's neu)
return {"available": True, "items": [], "offen": 0, "fehler": "Queue nicht lesbar"}
items = []
for t in raw[:60]:
try:
items.append({
"id": t.get("id", ""),
"titel": (t.get("title") or "(ohne Titel)")[:200],
"body": (t.get("body") or "")[:600],
"status": t.get("status", "?"),
"assignee": t.get("assignee"),
"erstellt": t.get("created_at"),
"fertig": t.get("completed_at"),
"von": t.get("created_by"),
})
except Exception:
continue
offen = sum(1 for i in items if i["status"] not in ("done",))
data = {"available": True, "items": items, "offen": offen}
_list_cache.update(ts=now, data=data)
return data
def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui") -> dict:
"""Idee in die Queue legen: triage — der Specifier der Box arbeitet sie selbst aus."""
if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
titel = (titel or "").strip()
if not (3 <= len(titel) <= 200):
return {"ok": False, "error": "Titel bitte zwischen 3 und 200 Zeichen."}
args = ["create", titel, "--triage", "--created-by", created_by, "--json"]
notiz = (notiz or "").strip()
if notiz:
args[2:2] = ["--body", notiz[:4000]]
try:
r = _hermes(args)
except Exception as exc:
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
task = _parse_json(r.stdout)
if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"):
return {"ok": False, "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]}
_list_cache["ts"] = 0.0 # nächster Poll zeigt die neue Idee sofort
try:
from services import announce
announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.",
"[Ideen-Queue]", "ideen-queue", "silent")
except Exception:
pass
return {"ok": True, "id": task["id"], "status": task.get("status")}
def archive_task(task_id: str) -> dict:
"""Erledigtes/Verworfenes aus der Sicht räumen (Kanban-Archiv, nichts wird gelöscht)."""
if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
if not _TASK_ID_RX.match(task_id or ""):
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
try:
r = _hermes(["archive", task_id])
except Exception as exc:
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
if r.returncode != 0:
return {"ok": False, "error": (r.stderr or r.stdout or "Archivieren fehlgeschlagen").strip()[:300]}
_list_cache["ts"] = 0.0
return {"ok": True}
+513 -117
View File
@@ -1,118 +1,388 @@
"""
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
Ursprünglich aus Mission Control v1 portiert.
Seit Phase 2 (24.09.2026) überleben Aufträge einen Neustart von MC2:
- Jeder Auftrag läuft als eigene systemd-Einheit „mc2-job-<id>“ (systemd-run), nicht mehr als
Kindprozess von MC2. Ein Deploy oder Absturz von MC2 würgt ihn nicht mehr ab.
- Akte (<id>.json), Protokoll (<id>.log) und Exit-Code (<id>.exit) liegen unter JOBS_DIR.
MC2 liest die Akten beim Start wieder ein (wiederaufnehmen()) und beobachtet weiter.
- Nacharbeiten („Rolle setzen, wenn der Download fertig ist“) sind benannt (@nacharbeit) und
stehen mit ihren Daten in der Akte — sie laufen auch, wenn MC2 zwischendurch neu gestartet hat.
- Geheimnisse (z. B. HF_TOKEN) gehen über eine Umgebungsdatei (nur für den Nutzer lesbar), die
der Auftrag beim Start liest und löscht — nicht über die Befehlszeile oder die Einheit.
- Ohne systemd (Entwicklungsrechner, Tests: MC_JOBS_ART=prozess) läuft der Auftrag als
Kindprozess; dann überlebt er einen Neustart nicht.
Weiterhin: „Abbrechen“ beendet den ganzen Auftrag samt Kindern, jede Auftragsart hat ein
Zeitlimit, start_job_exklusiv prüft und trägt unter EINER Sperre ein, beendete Aufträge
verschwinden nach einem Tag bzw. ab 40 Stück.
Nur MC2 selbst beobachtet Aufträge (wiederaufnehmen() im Lebenszyklus der App); andere Prozesse
starten keine.
"""
import glob
import json
import logging
import os
import re
import shlex
import shutil
import signal
import subprocess
import threading
import time
import uuid
from collections.abc import Callable
from pathlib import Path
from kern.einstellungen import einstellungen
log = logging.getLogger(__name__)
JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {}
_LOCK = threading.Lock()
_LOG_CAP = 400
_LOG_LESEN_MAX = 256_000 # so viel vom Protokollende wird für die Anzeige gelesen
BEHALTEN_MAX = 40 # so viele beendete Aufträge bleiben sichtbar
BEHALTEN_S = 24 * 3600 # beendete Aufträge verschwinden nach einem Tag
STANDARD_ZEITLIMIT_S = 3 * 3600
_ENDE = ("done", "failed", "canceled")
_TAKT_S = 1.0 # wie oft ein Beobachter nach dem Exit-Code schaut
_EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noch lebt
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
# Nicht an die Oberfläche: Verwaltung des Auftrags.
_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "abschluss", "abschluss_daten",
"abschluss_erledigt", "fortschritt", "zeitlimit_s"}
# Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt.
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
"_", "SHLVL", "PWD", "OLDPWD", "SHELLOPTS", "BASHOPTS", "BASH_VERSINFO", "EUID", "UID",
"PPID", "GROUPS"}
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Die Hülle um den eigentlichen Befehl: Umgebung lesen (und die Datei löschen), Ausgabe ins
# Protokoll, Exit-Code atomar ablegen. $1 = Pfadpräfix der Akte, danach der Befehl.
_HUELLE = ('akte="$1"; shift; '
'if [ -f "$akte.env" ]; then . "$akte.env"; rm -f "$akte.env"; fi; '
'exec >>"$akte.log" 2>&1 </dev/null; '
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
_ABSCHLUESSE: dict[str, Callable[[dict], None]] = {}
_geladen = False
def _append_log(job: dict, line: str) -> None:
job["log"].append(line)
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
_NACHARBEITEN[name] = fn
return fn
return anmelden
def _pump_output(job: dict, stream) -> None:
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
buf = b""
overwrite = False
pending_cr = False
def commit():
line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
job["log"][-1] = line
else:
_append_log(job, line)
while True:
ch = stream.read(1)
if not ch:
break
if pending_cr:
pending_cr = False
if ch == b"\n":
commit(); overwrite = False; buf = b""
continue
commit(); overwrite = True; buf = b""
if ch == b"\r":
pending_cr = True
elif ch == b"\n":
commit(); overwrite = False; buf = b""
else:
buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]:
"""Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende
(fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den
Update-Verlauf zu schreiben."""
def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]:
_ABSCHLUESSE[name] = fn
return fn
return anmelden
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
job = JOBS[job_id]
job["state"] = "running"
# --- Ablage ------------------------------------------------------------------------
def _jobs_dir() -> Path:
return Path(os.environ.get("MC_JOBS_DIR", str(einstellungen().daten_dir / "mc2-jobs")))
def _pfad(job_id: str, endung: str) -> Path:
return _jobs_dir() / f"{job_id}{endung}"
def _speichern(job: dict) -> None:
"""Akte ohne Laufzeitfelder atomar schreiben."""
daten = {k: v for k, v in job.items() if k not in _LAUFZEIT and not k.startswith("_")}
try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
_jobs_dir().mkdir(parents=True, exist_ok=True)
tmp = _pfad(job["id"], ".json.tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad(job["id"], ".json"))
except OSError:
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None,
bufsize=0,
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
def _protokoll(job_id: str, zeile: str) -> None:
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
try:
_jobs_dir().mkdir(parents=True, exist_ok=True)
with _pfad(job_id, ".log").open("a", encoding="utf-8", newline="\n") as f:
f.write(zeile.rstrip("\n") + "\n")
except OSError:
pass
_pump_output(job, proc.stdout)
proc.wait()
job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
cb = job.pop("_on_done", None)
if cb and job["state"] == "done":
try:
cb()
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
def zeilen_aus(text: str) -> list[str]:
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
zeilen = []
for roh in text.split("\n"):
if "\r" in roh:
teile = [t for t in roh.split("\r") if t]
roh = teile[-1] if teile else ""
zeilen.append(roh)
if zeilen and zeilen[-1] == "":
zeilen.pop()
return zeilen[-_LOG_CAP:]
def _protokoll_lesen(job_id: str) -> list[str]:
try:
with _pfad(job_id, ".log").open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - _LOG_LESEN_MAX))
roh = f.read()
except OSError:
return []
text = roh.decode("utf-8", "replace")
if groesse > _LOG_LESEN_MAX:
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
return zeilen_aus(text)
def protokoll(job_id: str) -> list[str]:
"""Das Protokoll eines Auftrags (Ende, Fortschrittszeilen zusammengefasst)."""
return _protokoll_lesen(job_id)
def _exit_code(job_id: str) -> int | None:
try:
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def _exit_ablegen(job_id: str, code: int) -> None:
try:
tmp = _pfad(job_id, ".exit.tmp")
tmp.write_text(str(code), encoding="utf-8")
tmp.replace(_pfad(job_id, ".exit"))
except OSError:
pass
def _umgebungsdatei(job_id: str, env: dict | None) -> None:
"""Umgebung des Auftrags (MC2-Umgebung + env) als nur für den Nutzer lesbare Datei."""
werte = {**os.environ, **(env or {})}
zeilen = [f"export {k}={shlex.quote(str(v))}" for k, v in werte.items()
if _NAME.match(k) and k not in _UMGEBUNG_OHNE]
fd = os.open(_pfad(job_id, ".env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(zeilen) + "\n")
# --- Ausführung ----------------------------------------------------------------------
def _art() -> str:
"""„systemd“, wenn Aufträge als eigene Einheiten laufen können, sonst „prozess“."""
gewuenscht = os.environ.get("MC_JOBS_ART", "").strip().lower()
if gewuenscht in ("systemd", "prozess"):
return gewuenscht
if os.name != "posix" or not shutil.which("systemd-run"):
return "prozess"
if os.geteuid() != 0 and not os.environ.get("XDG_RUNTIME_DIR"):
return "prozess"
return "systemd"
def _systemd(programm: str, *args: str) -> list[str]:
"""systemctl/systemd-run für den richtigen Manager: als root der System-, sonst der Nutzer-Manager."""
als_root = os.name == "posix" and os.geteuid() == 0
return [programm, *(() if als_root else ("--user",)), *args]
def _einheit(job_id: str) -> str:
return f"mc2-job-{job_id}"
def _einheit_lebt(job_id: str) -> bool:
try:
r = subprocess.run(_systemd("systemctl", "is-active", _einheit(job_id)),
capture_output=True, text=True, timeout=10)
except Exception:
return True # Im Zweifel weiter beobachten statt einen laufenden Auftrag totzusagen.
return r.stdout.strip() in ("active", "activating", "deactivating", "reloading")
def _starte_systemd(job: dict, args: list[str], env: dict | None) -> None:
job_id = job["id"]
_umgebungsdatei(job_id, env)
befehl = _systemd("systemd-run", "--unit", _einheit(job_id), "--collect", "--quiet",
"--working-directory", os.getcwd(),
"--property", f"RuntimeMaxSec={int(job['zeitlimit_s'])}",
"--", "/bin/bash", "-c", _HUELLE, "mc2-job", str(_jobs_dir() / job_id), *args)
r = subprocess.run(befehl, capture_output=True, text=True, timeout=30)
if r.returncode != 0:
_pfad(job_id, ".env").unlink(missing_ok=True)
raise RuntimeError((r.stderr or r.stdout or "systemd-run scheiterte").strip()[:300])
threading.Thread(target=_beobachten, args=(job_id,), daemon=True).start()
def _starte_prozess(job: dict, args: list[str], env: dict | None) -> None:
"""Rückfall ohne systemd: Kindprozess, Ausgabe ins Protokoll; ein Warte-Thread schließt ab."""
job_id = job["id"]
with _pfad(job_id, ".log").open("ab") as protokoll:
proc = subprocess.Popen(list(args), stdout=protokoll, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
env={**os.environ, **(env or {})}, start_new_session=(os.name == "posix"))
_PROCS[job_id] = proc
grenze = int(job["zeitlimit_s"])
def zu_lang() -> None:
job["zeitlimit"] = True
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
_beende_gruppe(proc)
wecker = threading.Timer(grenze, zu_lang)
wecker.daemon = True
wecker.start()
def warten() -> None:
code = proc.wait()
wecker.cancel()
_exit_ablegen(job_id, code)
_abschliessen(job, code)
threading.Thread(target=warten, daemon=True).start()
def _beende_gruppe(proc: subprocess.Popen) -> None:
"""Den Auftragsprozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
try:
if os.name == "posix":
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
else:
proc.terminate()
except (ProcessLookupError, PermissionError, OSError):
pass
def _abschliessen(job: dict, code: int | None) -> None:
"""Endzustand festhalten (genau einmal). Bei Erfolg läuft die Nacharbeit VOR dem Endzustand:
Wer „done“ sieht, sieht auch schon ihre Wirkung (gesetzte Rolle, geleerte Zwischenspeicher)."""
with _LOCK:
if job["state"] in _ENDE or job.get("_schliesst"):
return
job["_schliesst"] = True
felder: dict = {"returncode": code}
if job.get("canceled"):
felder["state"] = "canceled"
elif job.get("zeitlimit") or (code is None and time.time() - job["started_at"] >= job["zeitlimit_s"] - 5):
felder.update(state="failed", zeitlimit=True, error="Zeitlimit überschritten")
elif code is None:
felder.update(state="failed", error=job.get("error") or "Der Auftrag endete ohne Rückmeldung.")
else:
felder["state"] = "done" if code == 0 else "failed"
if felder["state"] == "done":
_nacharbeit_ausfuehren(job)
with _LOCK:
job.update(felder, finished_at=time.time())
job.pop("_schliesst", None)
_PROCS.pop(job["id"], None)
_speichern(job)
_abschluss_ausfuehren(job)
def _nacharbeit_ausfuehren(job: dict) -> None:
name = job.get("nacharbeit")
if not name or job.get("nacharbeit_erledigt"):
return
job["nacharbeit_erledigt"] = True
_speichern(job)
try:
fn = _NACHARBEITEN.get(name)
if fn is None:
raise RuntimeError(f"Nacharbeit „{name}“ ist in dieser Instanz unbekannt")
fn(**(job.get("nacharbeit_daten") or {}))
except Exception as exc:
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
def _abschluss_ausfuehren(job: dict) -> None:
name = job.get("abschluss")
if not name or job.get("abschluss_erledigt"):
return
job["abschluss_erledigt"] = True
_speichern(job)
try:
fn = _ABSCHLUESSE.get(name)
if fn is None:
raise RuntimeError(f"Abschluss „{name}“ ist in dieser Instanz unbekannt")
fn({k: v for k, v in job.items() if not k.startswith("_")})
except Exception as exc:
_protokoll(job["id"], f"[mc] Abschluss-Fehler: {exc}")
log.warning("jobengine: Abschluss %s von %s gescheitert", name, job["id"], exc_info=True)
def _beobachten(job_id: str) -> None:
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
Absturz), schließt er den Auftrag trotzdem ab."""
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
while True:
job = JOBS.get(job_id)
if job is None or job["state"] in _ENDE:
return
if (code := _exit_code(job_id)) is not None:
_abschliessen(job, code)
return
if time.time() >= naechste_pruefung:
if not _einheit_lebt(job_id):
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden
_abschliessen(job, _exit_code(job_id))
return
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
time.sleep(_TAKT_S)
def _starten(job: dict, args: list[str], env: dict | None) -> None:
job["state"] = "running"
_speichern(job)
try:
if job["art"] == "systemd":
_starte_systemd(job, args, env)
else:
_starte_prozess(job, args, env)
except Exception as exc:
_protokoll(job["id"], f"[mc] Fehler: {exc}")
job["error"] = str(exc)
_abschliessen(job, -1)
# --- Fortschritt (Downloads) ------------------------------------------------------------
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
job["total_bytes"] = total_bytes
if job is None or not total_bytes or total_bytes <= 0:
return
job["fortschritt"] = {"ordner": local_dir, "gesamt": total_bytes}
job["total_bytes"] = total_bytes
job["progress"] = 0
_speichern(job)
_fortschritt_beobachten(job_id)
def _fortschritt_beobachten(job_id: str) -> None:
ziel = (JOBS.get(job_id) or {}).get("fortschritt") or {}
local_dir, total_bytes = ziel.get("ordner"), ziel.get("gesamt")
if not local_dir or not total_bytes:
return
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
@@ -120,7 +390,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
rate = 0.0
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
if not j or j["state"] in _ENDE:
break
try:
inc = glob.glob(pat, recursive=True)
@@ -136,7 +406,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
except Exception:
pass
time.sleep(1.0)
j = JOBS.get(job_id)
@@ -147,49 +417,175 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
threading.Thread(target=_watch, daemon=True).start()
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
sudo_password: str | None = None, group: str | None = None) -> str:
# --- Verwaltung -------------------------------------------------------------------
def _laden() -> None:
"""Akten von der Platte einlesen (einmal je Prozess; unter _LOCK aufrufen)."""
global _geladen
if _geladen:
return
_geladen = True
for datei in sorted(_jobs_dir().glob("*.json")):
try:
akte = json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if isinstance(akte, dict) and akte.get("id") and akte["id"] not in JOBS:
JOBS[akte["id"]] = akte
def _aufraeumen() -> None:
"""Alte beendete Aufträge samt Dateien entfernen (unter _LOCK aufrufen)."""
jetzt = time.time()
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
key=lambda j: j.get("finished_at") or 0, reverse=True)
for i, j in enumerate(fertig):
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
JOBS.pop(j["id"], None)
for endung in (".json", ".log", ".exit", ".env", ".exit.tmp", ".json.tmp"):
_pfad(j["id"], endung).unlink(missing_ok=True)
def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None,
nacharbeit_name: str | None, nacharbeit_daten: dict | None,
abschluss_name: str | None = None, abschluss_daten: dict | None = None) -> dict:
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
if abschluss_name and abschluss_name not in _ABSCHLUESSE:
raise ValueError(f"Unbekannter Abschluss: {abschluss_name}")
job_id = uuid.uuid4().hex[:12]
# Mask password in log if present in args
log_args = list(args)
JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group,
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
job = {
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
"returncode": None, "started_at": time.time(), "finished_at": None,
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
"abschluss": abschluss_name, "abschluss_daten": abschluss_daten or {},
}
if on_done:
JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
return job_id
JOBS[job_id] = job
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
_speichern(job)
return job
def active_in_group(group: str) -> dict | None:
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
for j in JOBS.values():
def _aktiv_in(group: str) -> dict | None:
for j in list(JOBS.values()):
if j.get("group") == group and j.get("state") in ("running", "queued"):
return j
return None
def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None,
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
abschluss_daten: dict | None = None) -> str:
with _LOCK:
_laden()
_aufraeumen()
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
abschluss, abschluss_daten)
_starten(job, list(args), env)
return job["id"]
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
abschluss_daten: dict | None = None) -> tuple[str | None, dict | None]:
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
with _LOCK:
_laden()
if (laeuft := _aktiv_in(group)) is not None:
return None, laeuft
_aufraeumen()
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
abschluss, abschluss_daten)
_starten(job, list(args), env)
return job["id"], None
def active_in_group(group: str) -> dict | None:
"""Erster laufender/wartender Auftrag einer Gruppe (z. B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
with _LOCK:
_laden()
return _aktiv_in(group)
def wiederaufnehmen() -> int:
"""Beim Start von MC2: Akten einlesen und laufende Aufträge weiter beobachten.
Rückgabe: Zahl der wieder aufgenommenen Aufträge."""
with _LOCK:
_laden()
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
and not j.get("nacharbeit_erledigt")]
abschluss_offen = [j for j in JOBS.values() if j["state"] in _ENDE and j.get("abschluss")
and not j.get("abschluss_erledigt")]
for job in offen:
if job.get("art") == "systemd":
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
_fortschritt_beobachten(job["id"])
elif (code := _exit_code(job["id"])) is not None:
_abschliessen(job, code)
else:
# Kindprozess des vorigen MC2: sein Warte-Thread ist mit ihm gegangen.
_protokoll(job["id"], "[mc] MC2 wurde neu gestartet; der Auftrag lief als Kindprozess mit.")
job["error"] = "MC2 wurde während des Auftrags neu gestartet."
_abschliessen(job, None)
for job in nacharbeit_offen:
_nacharbeit_ausfuehren(job)
for job in abschluss_offen:
_abschluss_ausfuehren(job)
if offen:
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
return len(offen)
def cancel_job(job_id: str) -> bool:
job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"):
return False
job["canceled"] = True
_append_log(job, "[mc] Abbruch angefordert…")
proc = _PROCS.get(job_id)
if proc is not None:
with _LOCK:
_laden()
job = JOBS.get(job_id)
if not job or job["state"] in _ENDE:
return False
job["canceled"] = True
_speichern(job)
_protokoll(job_id, "[mc] Abbruch angefordert…")
if job["state"] == "queued":
_abschliessen(job, None)
elif job.get("art") == "systemd":
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
capture_output=True, timeout=15)
except Exception:
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True)
elif (proc := _PROCS.get(job_id)) is not None:
_beende_gruppe(proc)
else:
job["state"] = "canceled"
job["finished_at"] = time.time()
_abschliessen(job, None)
return True
def public_jobs() -> list[dict]:
"""Jobs ohne interne Felder (_on_done) für die API."""
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
def quittieren(job_id: str) -> bool:
"""Einen beendeten Auftrag als gesehen markieren — die Oberfläche blendet ihn dann aus (seit 25.09.2026;
vorher blieben beendete Aufträge 24 Stunden stehen, ohne dass man sie wegklicken konnte)."""
with _LOCK:
_laden()
job = JOBS.get(job_id)
if not job or job["state"] not in _ENDE:
return False
job["quittiert"] = True
_speichern(job)
return True
def public_jobs(mit_log: bool = True) -> list[dict]:
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
with _LOCK:
_laden()
_aufraeumen()
jobs = [{k: v for k, v in j.items() if k not in _INTERN and not k.startswith("_")} for j in JOBS.values()]
for j in jobs:
j["log"] = _protokoll_lesen(j["id"]) if mit_log else []
return sorted(jobs, key=lambda j: j.get("started_at") or 0)
+282 -96
View File
@@ -6,17 +6,32 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""
import functools
import logging
import os
import re
import threading
from contextlib import contextmanager
from pathlib import Path
import httpx
from config import (
CMD_TEMPLATE,
CONFIG_PATH,
DEFAULT_TTL,
DRAFTS_DIR,
LLAMA_SWAP_URL,
MODELS_DIR,
SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX,
SPEC_TYPE,
)
from ruamel.yaml.scalarstring import LiteralScalarString
from config import (
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
)
try:
import fcntl
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
fcntl = None
log = logging.getLogger(__name__)
@@ -48,8 +63,77 @@ def _gguf_total_size(path: str) -> int | None:
return None
# --- Sperre ------------------------------------------------------------------
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
_SPERRE = threading.RLock()
_SPERR_TIEFE = threading.local()
@contextmanager
def config_sperre():
with _SPERRE:
tiefe = getattr(_SPERR_TIEFE, "n", 0)
_SPERR_TIEFE.n = tiefe + 1
datei = None
try:
if tiefe == 0 and fcntl is not None:
try:
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
fcntl.flock(datei, fcntl.LOCK_EX)
except OSError:
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
yield
finally:
_SPERR_TIEFE.n = tiefe
if datei is not None:
fcntl.flock(datei, fcntl.LOCK_UN)
datei.close()
# Sammel-Schreiben (24.09.2026): Jeder Schreibvorgang lässt llama-swap neu laden und ALLE Modelle
# entladen. Ein Modelltausch bestand aus bis zu sieben Schreibvorgängen (Eintragen, Befehl, Zwilling,
# Alias, Gruppe, ttl …). Innerhalb von sammeln() lesen alle Änderungen dieselbe Config aus dem
# Speicher, und geschrieben wird einmal am Ende — oder gar nicht, wenn etwas scheitert.
_SAMMEL = threading.local()
@contextmanager
def sammeln():
with config_sperre():
if getattr(_SAMMEL, "aktiv", False): # verschachtelt: der äußere schreibt
yield
return
_SAMMEL.aktiv, _SAMMEL.cfg = True, None
try:
yield
cfg = _SAMMEL.cfg
finally:
_SAMMEL.aktiv, _SAMMEL.cfg = False, None
if cfg is not None:
_schreiben(cfg)
def _mit_sperre(fn):
@functools.wraps(fn)
def huelle(*args, **kwargs):
with config_sperre():
return fn(*args, **kwargs)
return huelle
# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
if getattr(_SAMMEL, "aktiv", False):
if _SAMMEL.cfg is None:
_SAMMEL.cfg = _lesen()
return _SAMMEL.cfg
return _lesen()
def _lesen() -> dict:
if not CONFIG_PATH.exists():
return {"models": {}}
from ruamel.yaml import YAML
@@ -143,19 +227,36 @@ def model_id_from_path(model_path: str) -> str:
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
d = os.path.basename(os.path.dirname(model_path))
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
return name or "modell"
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
PROTECTED_ALIASES = {"hermes", "embed"}
def protected_alias_of(model_id: str) -> str | None:
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
spec = (read_config().get("models") or {}).get(model_id)
if isinstance(spec, dict):
for a in spec.get("aliases") or []:
if str(a).lower() in PROTECTED_ALIASES:
return str(a).lower()
return None
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
@@ -169,8 +270,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
keep = [a for a in (spec.get("aliases") or [])
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
new = keep + ([role] if role and role != model_id.lower() else [])
if new:
spec["aliases"] = new
else:
spec.pop("aliases", None)
@@ -187,7 +291,15 @@ def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
def write_config(cfg: dict) -> None:
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung. Innerhalb von sammeln()
wird nur vorgemerkt; geschrieben wird am Ende einmal."""
if getattr(_SAMMEL, "aktiv", False):
_SAMMEL.cfg = cfg
return
_schreiben(cfg)
def _schreiben(cfg: dict) -> None:
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
@@ -202,7 +314,7 @@ def write_config(cfg: dict) -> None:
try:
from services import warmer
warmer.nudge()
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
except Exception:
pass
except PermissionError as exc:
raise PermissionError(
@@ -211,12 +323,15 @@ def write_config(cfg: dict) -> None:
) from exc
@_mit_sperre
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str:
jinja: bool = False, set_alias: bool = True) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
minutenlang auf eine Datei, die noch gar nicht existiert)."""
cfg = read_config()
model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
@@ -233,7 +348,7 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
cmd += " --cache-reuse 256 -cram 16384"
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/archiv/2026-06-30-optimierungsplan.md §9.4 V6).
if role_lower == "coder" and "--parallel" not in cmd:
cmd += " --parallel 2"
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
@@ -242,11 +357,19 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = {
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
old_aliases = (cfg.get("models") or {}).get(model_id, {})
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
entry: dict = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
if old_aliases:
entry["aliases"] = old_aliases
cfg.setdefault("models", {})[model_id] = entry
if set_alias:
set_role_alias(cfg, model_id, role)
write_config(cfg)
return model_id
@@ -332,70 +455,8 @@ def spec_draft_flags(target_path: str) -> str:
return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
"drafts": drafts,
}
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
abgelehnt (idiotensicher). Returns {ok, reason}."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
if not os.path.isabs(draft_path) and "/" not in draft_path:
draft_path = str(DRAFTS_DIR / draft_path)
if not os.path.exists(draft_path):
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
from services import gguf_meta
target = ""
if (mt := _PATH_RE.search(cmd)):
target = mt.group(1).replace("'", "").replace('"', "")
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
if comp is not True:
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
"würde beim Laden scheitern."
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)
return {"ok": True, "reason": ""}
# --- Groups (Ko-Residenz) ----------------------------------------------------
@_mit_sperre
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
@@ -408,6 +469,10 @@ def set_group(group: str, members: list[str], swap: bool = False, persist: bool
cfg = read_config()
groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
# Eine immer-warme Gruppe darf nichts verdrängen. llama-swap macht Gruppen sonst
# `exclusive`, und JEDE Anfrage ans Hirn entlud den Coder samt Prompt-Cache
# (live gefunden 24.09.2026: Lucy-Anfrage → OpenChamber-Coder weg).
**({"exclusive": False} if persist else {}),
"members": list(members)}
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
@@ -427,6 +492,7 @@ def list_groups() -> dict:
return read_config().get("groups") or {}
@_mit_sperre
def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
@@ -438,22 +504,23 @@ def set_role(model_id: str, role: str | None) -> bool:
return True
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
@_mit_sperre
def add_role(model_id: str, role: str) -> bool:
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
models = cfg.get("models") or {}
rolle = (role or "").strip().lower()
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
models[model_id]["aliases"] = bisher + [rolle]
write_config(cfg)
return True
@_mit_sperre
def set_ttl(model_id: str, ttl: int) -> bool:
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
@@ -466,6 +533,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
return True
@_mit_sperre
def im_modellordner(pfad: str) -> bool:
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
try:
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
except (OSError, ValueError):
return False
def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
@@ -477,8 +553,17 @@ def delete_model(model_id: str) -> bool:
model_spec = models[model_id] or {}
cmd = str(model_spec.get("cmd", "")).strip()
if (m := _PATH_RE.search(cmd)):
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
# den Coder mit. Dann wird nur der Eintrag entfernt.
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
m = None
if m:
path = m.group(1).replace("'", "").replace('"', "")
if path and not im_modellordner(path):
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
path = ""
if path:
# 1. Haupt-GGUF-Datei löschen
if os.path.exists(path):
@@ -506,7 +591,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match:
m_path = mmproj_match.group(1)
if os.path.exists(m_path):
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
try:
os.remove(m_path)
except Exception:
@@ -514,7 +599,7 @@ def delete_model(model_id: str) -> bool:
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
try:
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
os.rmdir(dirname)
except Exception:
pass
@@ -574,3 +659,104 @@ def get_running_models() -> list[str]:
except Exception:
log.warning("get_running_models fehlgeschlagen", exc_info=True)
return []
def modell_zustaende() -> dict[str, str] | None:
"""Zustand je Modell aus /running: Name → "ready" | "starting" | "stopping". Wer fehlt, ist nicht geladen.
llama-swap v257 listet dort jedes Modell, das weder „stopped“ noch „shutdown“ ist (internal/router/base.go,
RunningModels) — also auch eines, das gerade lädt. Ältere Fassungen lieferten nur Namen; die gelten als bereit.
None = /running nicht lesbar."""
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code != 200:
return None
eintraege = r.json().get("running") or []
except Exception:
log.warning("modell_zustaende: /running nicht lesbar", exc_info=True)
return None
zustaende: dict[str, str] = {}
for x in eintraege:
if isinstance(x, dict):
if x.get("model"):
zustaende[str(x["model"])] = str(x.get("state") or "ready")
elif x:
zustaende[str(x)] = "ready"
return zustaende
def hirn_zustand() -> dict:
"""Wie steht Lucys Hirn (Modell mit dem Alias/der Rolle „hermes“) in llama-swap? Gezielt dieses eine Modell —
bis 24.09.2026 galt das Hirn als bereit, sobald IRGENDEIN Modell lief (ein abgestürztes Hirn neben einem
geladenen Coder blieb unbemerkt).
Rückgabe {"modell": Name oder None, "zustand": "bereit" | "laedt" | "fehlt" | "unbekannt"};
„unbekannt“ heißt: /running antwortet nicht. Trägt kein Modell die Rolle, ist modell None und zustand "fehlt"."""
name = brain_model_name()
zustaende = modell_zustaende()
if zustaende is None:
return {"modell": name, "zustand": "unbekannt"}
zustand = zustaende.get(name or "")
if zustand == "ready":
return {"modell": name, "zustand": "bereit"}
if zustand == "starting":
return {"modell": name, "zustand": "laedt"}
return {"modell": name, "zustand": "fehlt"}
# Wie lange „Jetzt laden“ auf die Engine wartet. Große Modelle brauchen eine Weile (Lesen + Hochladen in den Speicher);
# was danach noch lädt, meldet lade_modell als „lädt noch“ statt als Fehler.
LADEN_WARTE_S = float(os.environ.get("MC_LADEN_WARTE_S", "90"))
def _engine_grund(r: httpx.Response) -> str:
"""Die Fehlermeldung der Engine aus ihrer Antwort (OpenAI-Format {"error": {"message"}}, {"error": "…"},
{"detail": "…"} oder reiner Text), auf eine Zeile gekürzt."""
text = ""
try:
daten = r.json()
except ValueError:
daten = None
if isinstance(daten, dict):
fehler = daten.get("error")
if isinstance(fehler, dict):
text = str(fehler.get("message") or "")
elif fehler:
text = str(fehler)
elif daten.get("detail"):
text = str(daten["detail"])
text = " ".join((text or r.text or "").split())
return text[:200] or "ohne Begründung"
def lade_modell(model_id: str, warte_s: float | None = None) -> dict:
"""Ein Modell laden und das ECHTE Ergebnis melden (seit 24.09.2026 — vorher hieß jede Antwort der Engine „ok“).
llama-swap lädt ein Modell mit der ersten Anfrage; dafür reicht eine Mini-Anfrage mit einem Token. Ob das Modell
danach wirklich geladen ist, entscheidet /running — so zählen auch Modelle, die gar nicht chatten (embed,
reranker), und ein Fehler der Anfrage selbst macht ein geladenes Modell nicht zum Fehlschlag.
Rückgabe: {"ok": True, "text": …} (bei "laedt": True lädt es nach der Wartezeit noch) oder
{"ok": False, "detail": deutscher Grund, mit der Meldung der Engine}."""
warte = LADEN_WARTE_S if warte_s is None else warte_s
anfrage = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
try:
with httpx.Client(timeout=warte) as c:
r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=anfrage)
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine (llama-swap) ist nicht "
f"erreichbar ({exc.__class__.__name__})."}
except httpx.TimeoutException:
if (modell_zustaende() or {}).get(model_id) == "starting":
return {"ok": True, "laedt": True,
"text": f"{model_id} lädt noch. Große Modelle brauchen etwas; der Stand erscheint gleich unter Modelle."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine hat nach {warte:.0f} Sekunden "
"nicht geantwortet, und das Modell lädt auch nicht."}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: {exc.__class__.__name__}: {exc}"[:300]}
if r.status_code == 200:
return {"ok": True, "text": f"{model_id} ist geladen."}
zustand = (modell_zustaende() or {}).get(model_id)
if zustand == "ready":
return {"ok": True, "text": f"{model_id} ist geladen."}
if zustand == "starting":
return {"ok": True, "laedt": True, "text": f"{model_id} lädt noch."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden. Die Engine meldet (HTTP {r.status_code}): "
f"{_engine_grund(r)}"}
+264 -264
View File
@@ -11,15 +11,31 @@ import re
import subprocess
import time
from datetime import datetime
from pathlib import Path
import httpx
import psutil
from kern.github import github_json
from kern.zeit import LOCAL_TZ
from services import catalog, discover, jobengine, llamaswap, system
from services import jobengine, system, update_verlauf
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
"projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate",
"pbs-backup", "mc2-probe-wiederherstellung"}
# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die
# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
# „aktuell“, obwohl gar nicht geprüft werden konnte.
PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None}
# Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn,
# damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten.
update_stand = 0
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -34,13 +50,16 @@ _engine_cache = {"ts": 0.0, "avail": False}
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
# GitHub-Abfragen mit 15 Minuten Zwischenspeicher (kern/github.py, seit 24.09.2026 für beide Rollen).
_github_json = github_json
def _latest_engine_asset_release() -> dict | None:
try:
rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
rels = _github_json(f"repos/{ENGINE_REPO}/releases?per_page=15")
for rel in (rels if isinstance(rels, list) else []):
if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])):
return rel
@@ -72,8 +91,9 @@ def _installed_engine_build() -> int | None:
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
timeout=20, env=env)
txt = (out.stderr or "") + (out.stdout or "")
# Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: <hash> (9821)", "b9821".
if (m := re.search(r"version:\s*(\d{3,})", txt)) \
# Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: <hash> (9821)", "b9821".
if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \
or (m := re.search(r"version:\s*(\d{3,})", txt)) \
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
or (m := re.search(r"\bb(\d{3,})\b", txt)):
return int(m.group(1))
@@ -82,19 +102,21 @@ def _installed_engine_build() -> int | None:
return None
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _os_upgradable() -> int:
"""Wie viele Pakete `apt-get upgrade` jetzt wirklich einspielen würde (die Zeilen „Inst“ der Simulation). Nicht
mitgezählt ist, was Ubuntu gestaffelt verteilt (phasing) oder zurückhält (kept back) — sonst stand das
Betriebssystem nie auf „aktuell“ (25.09.2026: 5 Pakete, die das Update gar nicht einspielen durfte).
LC_ALL=C: englische Ausgabe, damit „Inst“ auch auf einer deutschen Box so heißt."""
try:
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
# grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0.
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10)
return int((out.stdout or "0").strip() or 0)
except Exception:
out = subprocess.run(["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=60)
if out.returncode != 0:
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar (apt-get endete mit {out.returncode})"
return 0
PRUEF_FEHLER["os"] = None
return sum(1 for zeile in (out.stdout or "").splitlines() if zeile.startswith("Inst "))
except Exception as exc:
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})"
return 0
@@ -103,8 +125,12 @@ def _engine_update_available() -> bool:
if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"]
avail = False
fehler = None
try:
rel = _latest_engine_asset_release() or {}
rel = _latest_engine_asset_release()
if rel is None:
fehler = "Neueste Engine-Version bei GitHub nicht abrufbar"
rel = rel or {}
tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
installed = _installed_engine_build()
@@ -113,8 +139,12 @@ def _engine_update_available() -> bool:
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
except Exception:
elif fehler is None:
fehler = "Installierte Engine-Version nicht lesbar"
except Exception as exc:
avail = False
fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})"
PRUEF_FEHLER["engine"] = fehler
_engine_cache.update(ts=now, avail=avail)
return avail
@@ -138,16 +168,22 @@ def _swap_update_available() -> bool:
if now - _swap_cache["ts"] < 3600:
return _swap_cache["avail"]
avail = False
fehler = None
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
rel = _github_json(f"repos/{SWAP_REPO}/releases/latest", timeout=6)
tag = str(rel.get("tag_name", "")) if isinstance(rel, dict) else ""
latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
installed = _installed_swap_version()
if latest is not None and installed is not None:
avail = latest > installed
except Exception:
elif latest is None:
fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar"
else:
fehler = "Installierte llama-swap-Version nicht lesbar"
except Exception as exc:
avail = False
fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})"
PRUEF_FEHLER["swap"] = fehler
_swap_cache.update(ts=now, avail=avail)
return avail
@@ -170,7 +206,7 @@ def _hermes_agent_update() -> dict:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
capture_output=True, text=True, timeout=60)
info["reachable"] = (fetch.returncode == 0)
if fetch.returncode == 0:
cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"],
@@ -192,109 +228,12 @@ def _components_cached() -> list[dict]:
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
return _comp_cache["data"]
data = [_hermes_agent_update()]
PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)"
if data[0].get("reachable") is False else None)
_comp_cache.update(ts=now, data=data)
return data
def _params_of(m: dict) -> float:
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
from services.fit import QUANT_BYTES_PER_PARAM
caps = m.get("capabilities") or {}
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
def _gen_key(name: str):
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
low = (name or "").lower()
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
"-coder" if ("coder" in low or "-code" in low) else ""
for fam, pat in _FAM_PATS:
m = re.search(pat, low)
if m:
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
return (fam + sub, ver)
return None
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
cm = catalog.meta_for_name(name)
if cm:
return {"family": cm.get("family"), "gen": cm.get("generation"),
"total": float(cm.get("total_params_b") or 0),
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
d = model_dict or {}
g = _gen_key(name)
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
return {"family": (d.get("family") or (g[0] if g else None)),
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
def model_upgrades() -> list[dict]:
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
disc = discover.safe_discover(_ram_gb())
if not disc:
return []
installed = llamaswap.list_models()
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
for c in disc.get("categories", []):
role = c["role"]
im = inst_by_role.get(role)
if im is None:
continue
rec = c.get("recommended")
if not rec:
continue
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
i = _meta(im["name"], im=im)
r = _meta(rec, model_dict=rec_model)
if not i["family"] or not r["family"] or i["family"] != r["family"]:
continue # andere/unbekannte Familie → kein Upgrade
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
continue # ältere Generation → niemals
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
if same_gen:
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
continue # gleiche Gen, nicht größer → kein Upgrade
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
continue
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
continue
base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base
if base in cmds or (stem and stem in cmds):
continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec})
return out
def _last_apt_update() -> float | None:
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
if os.path.exists(path):
@@ -306,11 +245,58 @@ def _last_apt_update() -> float | None:
def updates() -> dict:
ups = model_upgrades()
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"swap": 1 if _swap_update_available() else 0,
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
"components": _components_cached()}
"""Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden
konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das
Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr."""
daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"swap": 1 if _swap_update_available() else 0,
"last_check": _last_apt_update(), "components": _components_cached()}
daten["pruef_fehler"] = dict(PRUEF_FEHLER)
return daten
# „════════ [2/3] Router (llama-swap) ════════" — so markiert update_all_job jeden Teil der Kette.
_TEIL_MARKE = re.compile(r"^═+ \[(\d+)/(\d+)\] ")
@jobengine.abschluss("wartung:verlauf")
def _update_im_verlauf(job: dict) -> None:
"""Updates per Knopf landen im strukturierten Update-Verlauf (seit 24.09.2026). Vorher sah man sie
dort gar nicht: Nur der Sonntags-Lauf schrieb Meldungen, aus denen der Verlauf gelesen wurde."""
bausteine = list((job.get("abschluss_daten") or {}).get("bausteine") or [])
if not bausteine:
return
lauf = datetime.fromtimestamp(job["started_at"], LOCAL_TZ).isoformat(timespec="seconds")
zustand, code = job.get("state"), job.get("returncode")
# Scheitert „Alle aktualisieren“, zeigt die letzte Teil-Marke im Protokoll, wo die Kette stand.
erreicht = len(bausteine)
if zustand != "done" and len(bausteine) > 1:
marken = [int(m.group(1)) for z in jobengine.protokoll(job["id"]) if (m := _TEIL_MARKE.match(z.strip()))]
erreicht = marken[-1] if marken else 1
for nr, baustein in enumerate(bausteine, start=1):
if zustand == "done" or nr < erreicht:
ergebnis, text = "eingespielt", "Per Knopf eingespielt, Prüfung grün."
elif nr > erreicht:
ergebnis, text = "offen", "Nicht mehr gelaufen, weil ein Teil davor scheiterte."
elif zustand == "canceled":
ergebnis, text = "offen", "Abgebrochen."
elif job.get("zeitlimit"):
ergebnis, text = "fehler", "Zeitlimit überschritten."
elif baustein in ("engine", "swap") and code == 1:
ergebnis, text = "zurueckgerollt", "Prüfung rot, automatisch zurückgerollt."
else:
ergebnis, text = "fehler", f"Fehlgeschlagen (Exit {code}). Das Protokoll steht beim Auftrag."
update_verlauf.eintragen(lauf, "Update von Hand", baustein, ergebnis, text)
@jobengine.nacharbeit("wartung:nach_update")
def _nach_update() -> None:
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
global update_stand
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
update_stand += 1
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
@@ -330,7 +316,7 @@ def _os_held_back() -> list[dict]:
try:
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=25)
capture_output=True, text=True, timeout=60)
reason: str | None = None
for line in (out.stdout or "").splitlines():
hdr = sections.get(line.strip())
@@ -342,7 +328,7 @@ def _os_held_back() -> list[dict]:
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
except Exception:
pass
held.sort(key=lambda p: p["name"])
return held
@@ -365,9 +351,12 @@ def os_update_details() -> dict:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()})
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001
# Was Ubuntu zurückhält, spielt das Update nicht ein — es steht getrennt da, nicht in der Liste und der Zahl.
held = _os_held_back()
zurueck = {p["name"] for p in held}
out_pkgs = [p for p in out_pkgs if p["name"] not in zurueck]
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "held_back": held}
except Exception as exc:
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
@@ -392,7 +381,7 @@ def engine_update_details() -> dict:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -403,8 +392,7 @@ def swap_update_details() -> dict:
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
rel = _github_json(f"repos/{SWAP_REPO}/releases/latest")
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
@@ -417,7 +405,7 @@ def swap_update_details() -> dict:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -495,7 +483,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
if text:
cache.update(key=key, data=data)
return data
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
except Exception as exc:
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
@@ -503,11 +491,32 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
def _summarize_hermes_commits(commits: list[dict]) -> dict:
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
"terminal-/web-Tools, approvals, cron).")
"Strix Halo; genutzt werden: api_server/Gateway, Telegram, eigenes Gedächtnis, "
"terminal-/web-Tools, Plugins, cron).")
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def _hermes_version(path: str) -> str | None:
"""Versionsnummer des Hermes-Checkouts: bis 0.21.x aus der pyproject.toml (z. B. „0.21.3“). Seit dem neuen
Paketmanager (Update vom 25.09.2026) steht dort „0.0.0“, und die Veröffentlichungen heißen nach dem Datum
(v2026.8.31) — dann das Datum des ausgecheckten Commits im selben Stil („2026.9.25“)."""
try:
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
except OSError:
return None
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if m and m.group(1) != "0.0.0":
return m.group(1)
try:
datum = subprocess.run(["git", "-C", path, "log", "-1", "--format=%cs"], capture_output=True, text=True,
timeout=5).stdout.strip()
except (OSError, subprocess.SubprocessError):
datum = ""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", datum):
return ".".join(str(int(t)) for t in datum.split("-"))
return m.group(1) if m else None
def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
@@ -516,12 +525,14 @@ def hermes_update_details() -> dict:
info["error"] = "Hermes-Agent-Repo nicht gefunden."
return info
path = git["path"]
# Vor dem Abruf: scheitert der (Zeitlimit, Netz), zeigt die Seite trotzdem die laufende Version.
info["installed_version"] = _hermes_version(path)
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
info["branch"] = branch
subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
capture_output=True, text=True, timeout=60)
log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr",
f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10)
commits = []
@@ -533,7 +544,7 @@ def hermes_update_details() -> dict:
info["behind"] = len(commits)
if commits:
info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -544,59 +555,59 @@ def update_details(kind: str) -> dict:
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
actual_cmd = list(cmd)
has_sudo = False
def _run(cmd: list[str]) -> dict:
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
ganze Pfad war also totes Risiko ohne Gegenwert.
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
actual_cmd = list(cmd)
if cmd and cmd[0] == "sudo":
has_sudo = True
# If we have a password, use -S instead of -n
if sudo_password is not None:
if "-n" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-n"]
if "-S" not in actual_cmd:
actual_cmd.insert(1, "-S")
else:
# Force -n to fail cleanly if password is required
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
try:
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
err_msg = p.stderr or ""
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
if sudo_password is not None:
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
return {"ok": False, "status": "password_required", "out": p.stdout or "",
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"ok": False, "out": "", "err": str(exc)}
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
res = _run(["sudo", "true"], sudo_password=sudo_password)
def check_sudo_needs_password() -> dict | None:
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
res = _run(["sudo", "true"])
if not res["ok"]:
return res
return None
def restart_service(name: str, sudo_password: str | None = None) -> dict:
def restart_service(name: str) -> dict:
if name in SYSTEM_SERVICES:
if err := check_sudo_needs_password(sudo_password):
if err := check_sudo_needs_password():
return err
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
return _run(["sudo", "systemctl", "restart", name])
if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
def logs(service: str, lines: int = 200) -> dict:
lines = max(1, min(lines, 1000))
if service in USER_SERVICES:
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
@@ -607,38 +618,37 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
if r["ok"]:
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
if err := check_sudo_needs_password(sudo_password):
if err := check_sudo_needs_password():
return err
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
sudo_password=sudo_password)
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
return {"ok": r["ok"], "text": r["out"] or r["err"]}
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
def check_updates_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
def _starten(args: list[str], label: str, zeitlimit_s: int | None = None,
bausteine: list[str] | None = None) -> dict:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen
passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und
Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten."""
job_id, laeuft = jobengine.start_job_exklusiv(
"maintenance", args, label, zeitlimit_s=zeitlimit_s, nacharbeit="wartung:nach_update",
abschluss="wartung:verlauf" if bausteine else None, abschluss_daten={"bausteine": bausteine or []})
if job_id is None:
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
"detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
return {"ok": True, "job_id": job_id}
def _maintenance_busy() -> dict | None:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts)."""
if j := jobengine.active_in_group("maintenance"):
return {"ok": False, "status": "busy", "running": j.get("label")}
return None
def check_updates_job() -> dict:
if err := check_sudo_needs_password():
return err
# apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit
# der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf.
return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen",
zeitlimit_s=15 * 60)
def os_update_job(sudo_password: str | None = None) -> dict:
if busy := _maintenance_busy():
return busy
if err := check_sudo_needs_password(sudo_password):
def os_update_job() -> dict:
if err := check_sudo_needs_password():
return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
@@ -646,49 +656,26 @@ def os_update_job(sudo_password: str | None = None) -> dict:
cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
group="maintenance", sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60, bausteine=["os"])
def engine_update_job(sudo_password: str | None = None) -> dict | None:
def engine_update_job() -> dict | None:
if not ENGINE_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
# neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben.
return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)",
zeitlimit_s=60 * 60, bausteine=["engine"])
def swap_update_job(sudo_password: str | None = None) -> dict | None:
def swap_update_job() -> dict | None:
if not SWAP_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
"Router-Update (llama-swap)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
# und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version.
return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)",
zeitlimit_s=30 * 60, bausteine=["swap"])
def _hermes_update_cmd() -> str:
@@ -716,55 +703,77 @@ def _hermes_update_cmd() -> str:
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
# Seit dem neuen Paketmanager von Hermes (Update vom 25.09.2026):
# • Hermes startet über seinen eigenen Starter (.hermes/bin/hermes, eigene Umgebung mit Python 3.14); das alte
# venv bleibt nur für den Rückweg. Der Updater schreibt dabei die Unit neu → daemon-reload vor dem Neustart,
# sonst lief das Gateway weiter aus dem alten venv (neuer Code, alte Pakete).
# • python-olm (Matrix-Extra) baut nur mit gcc (das mitgelieferte Python ist mit clang gebaut) und mit der
# CMake-Kompatibilität für CMake 4.
# • `doctor` endet mit 1, sobald es Hinweise gibt (fehlende optionale API-Schlüssel) — das ist kein Fehler.
# • hermes-plugin-deps.sh legt die Pakete unserer Plugins (trafilatura für mc2-web-lesen) nach.
starter = os.path.join(path, ".hermes", "bin", "hermes")
hermes = starter if os.path.exists(starter) else f"{py} -m hermes_cli.main"
bauen = "CC=gcc CXX=g++ CMAKE_POLICY_VERSION_MINIMUM=3.5"
plugin_deps = os.path.join(_REPO_ROOT, "deploy", "hermes-plugin-deps.sh")
return ("RC=0; "
f"ALT=$(git -C {path} rev-parse HEAD 2>/dev/null); "
f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
f"{{ cd {path} && {bauen} {hermes} update --yes --no-gateway-restart "
f"&& {{ {bauen} {hermes} doctor || echo '(doctor meldet Hinweise — Richter bleibt der Gehirn-Check)'; }} "
f"&& {build_cmd}; }} || RC=1; "
f"bash {plugin_deps} || true; "
f"systemctl --user daemon-reload; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; "
f"sleep 6; bash {postcheck} || RC=1; "
f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; "
f"if [ $RC -ne 0 ]; then if [ \"$(git -C {path} rev-parse HEAD 2>/dev/null)\" != \"$ALT\" ]; then "
f"echo '✗ HERMES-UPDATE UNVOLLSTÄNDIG (neuer Code, der Rest scheiterte — Dienste laufen, Protokoll prüfen)'; "
f"else echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; fi; "
f"exit $RC")
def hermes_update_job() -> dict:
def hermes_update_job(verlauf: bool = True) -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
if busy := _maintenance_busy():
return busy
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
(alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern).
verlauf=False: autoupdate.sh startet den Job und trägt das Ergebnis selbst in seinen Lauf ein."""
return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
zeitlimit_s=45 * 60, bausteine=["hermes"] if verlauf else None)
def update_all_job(sudo_password: str | None = None) -> dict:
def update_all_job() -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy():
return busy
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
if laeuft := jobengine.active_in_group("maintenance"):
return {"ok": False, "status": "busy", "running": laeuft.get("label"),
"detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."}
upd = updates()
parts: list[tuple[str, str]] = []
bausteine: list[str] = []
if upd.get("engine") and ENGINE_UPDATE_CMD:
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
bausteine.append("engine")
if upd.get("swap") and SWAP_UPDATE_CMD:
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
bausteine.append("swap")
if any(c.get("update") is True for c in upd.get("components") or []):
parts.append(("Hermes-Agent", _hermes_update_cmd()))
bausteine.append("hermes")
os_pending = (upd.get("os") or 0) > 0
if os_pending:
if err := check_sudo_needs_password(sudo_password):
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
if err := check_sudo_needs_password():
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
# den Fehler ehrlich durchreichen.
if not parts:
return err
os_pending = False
@@ -773,6 +782,7 @@ def update_all_job(sudo_password: str | None = None) -> dict:
"sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")))
bausteine.append("os")
if not parts:
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
@@ -782,19 +792,9 @@ def update_all_job(sudo_password: str | None = None) -> dict:
if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = " → ".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done,
sudo_password=sudo_password)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
return _run(["sudo", "reboot"], sudo_password=sudo_password)
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
zeitlimit_s=3 * 3600, bausteine=bausteine)
if ergebnis.get("ok"):
ergebnis["parts"] = [label for label, _ in parts]
return ergebnis
-177
View File
@@ -1,177 +0,0 @@
"""
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
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 asyncio
import logging
import os
import re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
log = logging.getLogger(__name__)
CATEGORIES = ("identity", "knowledge", "rules", "events")
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
def _get(path: str, **params) -> list | dict:
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict) -> dict:
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]:
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
try:
return _put(f"/memory/{mid}", {"content": content, "category": category})
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
def delete_memory(mid: str) -> bool:
try:
_delete(f"/memory/{mid}")
return True
except httpx.HTTPStatusError as exc:
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 graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
return _get("/graph", min_score=min_score, top_k=top_k)
def export_text() -> dict:
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r.get("category") != current:
current = r.get("category", "")
lines.append(f"\n## {current}\n")
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)}
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a.get("content", ""))
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b.get("category") != a.get("category"):
continue
nb = _norm(b.get("content", ""))
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
if delete_memory(d["id"]):
removed += 1
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
async def auto_dedupe_loop() -> None:
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
while True:
try:
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
if res.get("removed"):
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
except Exception:
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
+186
View File
@@ -0,0 +1,186 @@
"""Messwerte der KI-Box mit Verlauf (Monitoring, seit 24.09.2026).
Der Ereignisstrom (/api/stream) zeigt nur den Augenblick. Für den Verlauf schreibt der Steward (Rolle box, eigener
Prozess, übersteht MC2-Neustarts) jede Minute zur halben Minute einen Punkt über kern/messreihen.py (Quelle „box“):
cpu Mittel der Minute in % (Rechnung wie psutil.cpu_percent(interval=None), aber aus eigenen
cpu_times-Ständen: psutil merkt sich den letzten Aufruf je Thread, und die Messung läuft in wechselnden
Threads des Event-Loops)
ram, platte Belegung in % beim Messen (platte = das Modell-Laufwerk, wie im Cockpit)
gpu Mittel der Proben alle PROBE_S Sekunden in %: gpu_busy_percent zeigt nur den Augenblick, eine Probe je
Minute träfe die kurzen Antworten der Modelle kaum
temp_cpu/_gpu Mittel derselben Proben in °C
netz_rx/_tx Bytes/s über die Minute, alle Schnittstellen außer lo
tokens Prompt- plus Antwort-Tokens pro Minute (Differenz der Gesamtzähler aus services.token_stats)
Ein Zähler, der kleiner wird (Neustart des Gateways, neue Token-Datei), ergibt für diese Minute keine Rate (null).
GET /api/messwerte (routers/messwerte.py) liefert daraus die Reihen für 1h, 24h und 7d und den letzten Punkt.
"""
import asyncio
import logging
import os
import time
from collections.abc import Callable
import psutil
from config import MODELS_DIR
from kern import messreihen, prognose
from services import system, token_stats
log = logging.getLogger(__name__)
QUELLE = "box"
REIHEN = ("cpu", "ram", "gpu", "temp_cpu", "temp_gpu", "platte", "netz_rx", "netz_tx", "tokens")
# Ein Trocken- oder Probelauf neben dem echten Steward schreibt nicht mit (sonst stünden zwei Punkte je Minute da).
ENABLED = (os.environ.get("MC_MESSWERTE_ENABLED", "1") != "0" and os.environ.get("MC_WAECHTER_TROCKEN", "") != "1"
and os.environ.get("MC_PROBELAUF", "") != "1")
PROBE_S = float(os.environ.get("MC_MESSWERTE_PROBE_S", "5"))
PHASE_S = 30 # gemessen wird zur halben Minute: jeder 60-s-Schritt bekommt genau einen Punkt
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Steward schreibt gerade nicht)
# --- Rohwerte ------------------------------------------------------------------------------------
def _cpu_zeiten() -> tuple[float, float] | None:
"""(beschäftigt, gesamt) in Sekunden seit dem Start — gezählt wie psutil.cpu_percent (guest steckt in user)."""
try:
z = psutil.cpu_times()
except Exception:
return None
gesamt = sum(z) - getattr(z, "guest", 0.0) - getattr(z, "guest_nice", 0.0)
return gesamt - z.idle - getattr(z, "iowait", 0.0), gesamt
def cpu_prozent(vorher: tuple[float, float] | None, jetzt: tuple[float, float] | None) -> float | None:
if not vorher or not jetzt or jetzt[1] - vorher[1] <= 0:
return None
anteil = (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1])
return round(min(100.0, max(0.0, anteil * 100)), 1)
def _netz() -> tuple[int, int] | None:
"""Empfangene und gesendete Bytes aller Schnittstellen außer lo (Gesamtzähler)."""
try:
zaehler = psutil.net_io_counters(pernic=True)
except Exception:
return None
return (sum(z.bytes_recv for name, z in zaehler.items() if name != "lo"),
sum(z.bytes_sent for name, z in zaehler.items() if name != "lo"))
def _tokens() -> int | None:
"""Prompt- plus Antwort-Tokens seit Beginn der Zählung (schreibt der Gateway-Prozess)."""
try:
stand = token_stats.get_stats()
return int(stand.get("prompt_tokens") or 0) + int(stand.get("completion_tokens") or 0)
except Exception:
return None
def _platte() -> float | None:
try:
return round(psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent, 1)
except Exception:
return None
def _mittel(werte: list[float]) -> float | None:
return round(sum(werte) / len(werte), 1) if werte else None
def _runden(wert: float | None, stellen: int | None = None) -> float | int | None:
return None if wert is None else round(wert, stellen)
# --- Die Minute --------------------------------------------------------------------------------------
class Messer:
"""Was zwischen zwei Minutenpunkten mitläuft: die Stände der Zähler und die Proben."""
def __init__(self, uhr: Callable[[], float] = time.monotonic) -> None:
self._uhr = uhr
self._seit = uhr()
self._cpu = _cpu_zeiten()
self._netz = _netz()
self._tokens = _tokens()
self._proben: dict[str, list[float]] = {"gpu": [], "temp_cpu": [], "temp_gpu": []}
def probe(self) -> None:
"""GPU-Last und Temperaturen einmal ablesen (sysfs, Bruchteile einer Millisekunde)."""
gpu = system._gpu_sysfs() or {}
temp = system._temps() or {}
for name, wert in (("gpu", gpu.get("busy_percent")), ("temp_cpu", temp.get("cpu")),
("temp_gpu", temp.get("gpu"))):
if messreihen.ist_zahl(wert):
self._proben[name].append(float(wert))
def punkt(self) -> dict[str, float | int | None]:
"""Der Punkt dieser Minute; danach beginnt die nächste."""
self.probe()
jetzt = self._uhr()
dt = jetzt - self._seit
cpu, netz, tokens = _cpu_zeiten(), _netz(), _tokens()
try:
ram = round(psutil.virtual_memory().percent, 1)
except Exception:
ram = None
tok_s = messreihen.rate(self._tokens, tokens, dt)
werte = {
"cpu": cpu_prozent(self._cpu, cpu),
"ram": ram,
"gpu": _mittel(self._proben["gpu"]),
"temp_cpu": _mittel(self._proben["temp_cpu"]),
"temp_gpu": _mittel(self._proben["temp_gpu"]),
"platte": _platte(),
"netz_rx": _runden(messreihen.rate(self._netz[0], netz[0], dt)) if self._netz and netz else None,
"netz_tx": _runden(messreihen.rate(self._netz[1], netz[1], dt)) if self._netz and netz else None,
"tokens": None if tok_s is None else round(tok_s * 60, 1),
}
self._seit, self._cpu, self._netz, self._tokens = jetzt, cpu, netz, tokens
for proben in self._proben.values():
proben.clear()
return werte
def naechste_messung(jetzt: float) -> float:
"""Die nächste halbe Minute, mindestens eine Sekunde entfernt."""
ziel = jetzt - jetzt % 60 + PHASE_S
return ziel if ziel > jetzt + 1 else ziel + 60
async def messwerte_loop() -> None:
"""Im mc2-steward (Rolle box): Proben alle PROBE_S Sekunden, ein Punkt je Minute. Fehler kosten einen Punkt, nie
die Schleife."""
messer = await asyncio.to_thread(Messer)
naechste = naechste_messung(time.time())
log.info("messwerte: aktiv (ein Punkt je Minute nach %s, Proben alle %ss)", messreihen.ordner(), PROBE_S)
while True:
await asyncio.sleep(max(0.2, min(PROBE_S, naechste - time.time())))
try:
if time.time() >= naechste:
werte = await asyncio.to_thread(messer.punkt)
await asyncio.to_thread(messreihen.schreiben, QUELLE, werte)
naechste = naechste_messung(time.time())
else:
await asyncio.to_thread(messer.probe)
except Exception:
log.warning("messwerte: Messung fehlgeschlagen", exc_info=True)
naechste = naechste_messung(time.time())
# --- Lesen (MC2-Prozess) -------------------------------------------------------------------------------
ZEITRAEUME = messreihen.ZEITRAEUME
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
"""Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}, "prognose": {"platte": …}}.
aktuell ist der letzte Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null; prognose
die Vorhersage „voll in etwa N Tagen“ des Modell-Laufwerks (kern/prognose.py, seit 25.09.2026)."""
zeit = time.time() if jetzt is None else jetzt
daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, zeit)
letzter = messreihen.letzter_punkt(QUELLE, zeit, AKTUELL_MAX_S) or {}
return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN},
"prognose": {"platte": prognose.aus_messreihe(QUELLE, "platte", jetzt=jetzt)}}
+205
View File
@@ -0,0 +1,205 @@
"""
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026)
llama-swap schreibt jede Anfrage ins Journal — Absender-IP, Pfad, Status, Antwortgröße, Programm und
am Zeilenende die Dauer als Go-Dauer ("388.09ms", "3.41s", "1m2.3s"):
... [INFO] Request 192.168.178.28 "POST /v1/chat/completions HTTP/1.1" 200 806 "Go-http-client/1.1" 388.09ms
Daraus rechnen wir für die letzten TAGE Kalendertage (heute zählt bis jetzt):
- je Absender die Chat-Anfragen, die Fehler (Status nicht 2xx, je Code), die typische Antwortzeit
(Median), den Wert, unter dem 9 von 10 Antworten blieben (90-%-Wert), die Summe aller Antwortzeiten
("Rechenzeit"; gleichzeitige Anfragen zählen einzeln) und die letzte Anfrage;
- den Verlauf je Tag und je Stunde der letzten 24 h, beides je Absender;
- wann welches Modell geladen wurde ("Health check passed").
Welches Modell eine Anfrage bedient hat, steht nicht in diesen Zeilen — das zeigen wir darum nicht.
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
bevor jemand nachsieht (gemessen am 23.09.2026).
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). Die Reihenfolge der Namen
legt die Farbe im Cockpit fest ("reihe"): Ein Absender behält seine Farbe, auch wenn sich die
Rangfolge nach Anfragen ändert.
"""
import math
import os
import re
import statistics
import subprocess
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from datetime import time as uhrzeit
from kern.zeit import LOCAL_TZ
CACHE_S = 600
TAGE = 7
LADEZEITEN = 10 # so viele Ladezeitpunkte je Modell (neueste zuerst) reichen der Anzeige
# Die Reihenfolge ist die Farbe (Blau, Magenta, Gelb): Der größte Verbraucher bekommt das ruhige Blau.
_STANDARD_NAMEN = {
"192.168.178.28": "NerdQuiz auf Arcane",
"127.0.0.1": "Lucy und OpenChamber über MC2",
"::1": "Lucy und OpenChamber über MC2",
"192.168.178.22": "NerdQuiz-Tests vom PC",
}
# Status ist optional: Fehlt er (anderes Zeilenformat), zählt die Anfrage, aber nicht als Fehler.
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)[^"]*"(?: (\d{3}))?')
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
# Go schreibt Mikrosekunden mit dem Mikro-Zeichen (U+00B5); das griechische My und "us" gehen auch.
_DAUER_TEIL = re.compile(r"(\d+(?:\.\d+)?)(h|ms|m|s|µs|μs|us|ns)")
_DAUER_FAKTOR = {"h": 3600.0, "m": 60.0, "s": 1.0, "ms": 1e-3, "µs": 1e-6, "μs": 1e-6, "us": 1e-6, "ns": 1e-9}
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
def _namen() -> dict[str, str]:
namen = dict(_STANDARD_NAMEN)
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
if "=" in paar:
ip, name = paar.split("=", 1)
namen[ip.strip()] = name.strip()
return namen
def _journal(seit: str) -> list[str]:
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
am Tag. Ohne systemd (Windows-Dev) leer."""
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
" | grep -E 'POST /v1/|Health check passed'")
try:
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
except Exception:
return []
return out.stdout.splitlines()
def dauer_s(text: str) -> float | None:
"""Go-Dauer → Sekunden: "388.09ms" → 0.38809, "1m2.5s" → 62.5, "850µs" → 0.00085. Sonst None."""
teile = list(_DAUER_TEIL.finditer(text))
if not teile or "".join(t.group(0) for t in teile) != text:
return None
return sum(float(t.group(1)) * _DAUER_FAKTOR[t.group(2)] for t in teile)
def _kennwerte(dauern: list[float], summe: float) -> dict:
"""Typisch (Median) und 9 von 10 höchstens (90-%-Wert als nächster Rang, also eine echte
Antwortzeit), dazu die Summe — alles in Sekunden."""
werte = sorted(dauern)
return {
"dauer_typisch_s": round(statistics.median(werte), 3) if werte else None,
"dauer_p90_s": round(werte[math.ceil(0.9 * len(werte)) - 1], 3) if werte else None,
"dauer_summe_s": round(summe, 1),
}
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Tag, Stunde und Modell."""
jetzt = jetzt.astimezone(LOCAL_TZ)
tage = [jetzt.date() - timedelta(days=TAGE - 1 - i) for i in range(TAGE)]
beginn = datetime.combine(tage[0], uhrzeit(0), tzinfo=LOCAL_TZ)
# Stunden in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt oder verdoppelt.
stunde0 = jetzt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) - timedelta(hours=23)
grenze_24h = jetzt - timedelta(hours=24)
anfragen: Counter = Counter()
fehler: dict[str, Counter] = defaultdict(Counter) # Absender → Status → Anzahl
dauern: dict[str, list[float]] = defaultdict(list) # nur erfolgreiche Antworten
summe: Counter = Counter() # alle Antwortzeiten in Sekunden
zuletzt: dict[str, datetime] = {}
je_tag: dict[str, Counter] = {t.isoformat(): Counter() for t in tage}
je_stunde = [Counter() for _ in range(24)] # chronologisch, die letzte ist die laufende
stunden_alt: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender (für den Flugplan)
geladen: dict[str, list[datetime]] = defaultdict(list)
for z in zeilen:
try:
ts = datetime.fromisoformat(z.split(" ", 1)[0])
except ValueError:
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=LOCAL_TZ)
if ts < beginn:
continue
if (m := _GELADEN.search(z)):
geladen[m.group(1)].append(ts)
continue
m = _ANFRAGE.search(z)
if not m or not m.group(2).startswith("/v1/chat/completions"):
continue
wer = namen.get(m.group(1), m.group(1))
anfragen[wer] += 1
ok = m.group(3) is None or m.group(3).startswith("2")
if not ok:
fehler[wer][m.group(3)] += 1
if (d := dauer_s(z.rsplit(None, 1)[-1])) is not None:
summe[wer] += d
if ok:
dauern[wer].append(d)
if wer not in zuletzt or ts > zuletzt[wer]:
zuletzt[wer] = ts
if (tag := ts.astimezone(LOCAL_TZ).date().isoformat()) in je_tag:
je_tag[tag][wer] += 1
if 0 <= (i := int((ts - stunde0).total_seconds() // 3600)) < 24:
je_stunde[i][wer] += 1
if ts >= grenze_24h:
stunden_alt[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
bekannt = list(dict.fromkeys(namen.values()))
fremde = [n for n, _ in anfragen.most_common() if n not in bekannt]
def reihe(name: str) -> int:
return bekannt.index(name) if name in bekannt else len(bekannt) + fremde.index(name)
alle_fehler: Counter = sum(fehler.values(), Counter())
zuletzt_geladen = {modell: max(zeiten) for modell, zeiten in geladen.items()}
return {
"tage": TAGE,
"seit": beginn.isoformat(),
"stand": jetzt.isoformat(timespec="seconds"),
"absender": [
{"name": n, "anfragen": c, "reihe": reihe(n),
"fehler": sum(fehler[n].values()), "fehler_codes": dict(fehler[n]),
**_kennwerte(dauern[n], summe[n]),
"zuletzt": zuletzt[n].isoformat()}
for n, c in anfragen.most_common()
],
"gesamt": {
"anfragen": sum(anfragen.values()),
"fehler": sum(alle_fehler.values()), "fehler_codes": dict(alle_fehler),
**_kennwerte([d for liste in dauern.values() for d in liste], sum(summe.values())),
},
"je_tag": {tag: dict(c) for tag, c in je_tag.items()},
"stunden": [{"beginn": (stunde0 + timedelta(hours=i)).astimezone(LOCAL_TZ).isoformat(),
"je_absender": dict(c)} for i, c in enumerate(je_stunde)],
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden_alt.get(f"{h:02d}", {}))}
for h in range(24)],
"zuletzt_geladen": {modell: t.isoformat() for modell, t in zuletzt_geladen.items()},
"ladevorgaenge": [
{"modell": modell, "anzahl": len(geladen[modell]),
"zeiten": [t.isoformat() for t in sorted(geladen[modell], reverse=True)[:LADEZEITEN]]}
for modell in sorted(zuletzt_geladen, key=zuletzt_geladen.__getitem__, reverse=True)
],
}
def nutzung() -> dict:
"""Gecachte Auswertung der letzten TAGE Kalendertage (10 min)."""
with _lock:
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
return _cache["daten"]
jetzt = datetime.now(LOCAL_TZ)
seit = datetime.combine(jetzt.date() - timedelta(days=TAGE - 1), uhrzeit(0))
daten = werte_aus(_journal(seit.strftime("%Y-%m-%d %H:%M:%S")), jetzt, _namen())
with _lock:
_cache.update(ts=time.time(), daten=daten)
return daten
+415
View File
@@ -0,0 +1,415 @@
"""
Monatliche Probe-Wiederherstellung (seit 24.09.2026): Eine Sicherung ist erst eine, wenn sie sich zurückspielen lässt.
Packt die jüngste Sicherung (deploy/backup.sh → /srv/models/mc2-backups) probeweise in einen Tmp-Ordner aus, prüft die
Pflichtinhalte und räumt den Ordner wieder weg. Lebende Dateien fasst sie nie an; geschrieben werden nur der Tmp-Ordner
und die Zustandsdatei ZUSTAND_PATH. Die liest der Wächter (Rolle box): gelb, wenn die letzte Probe rot war oder älter
als WARN_TAGE ist. Bei Rot geht zusätzlich eine Meldung über deploy/notify.sh raus, in normaler Dringlichkeit — nachts
sammelt notify.sh sie für die Morgenmeldung um 07:00.
Pflichtinhalte (was restore.sh zurückspielt oder was ohne Sicherung verloren wäre):
• Lucys Gedächtnis (hermes/memories) — dazu ein Abgleich mit dem lebenden Gedächtnis: Was schon vor der Sicherung so
dastand, muss darin unverändert stehen. Ebenso SOUL.md.
• Skills (SKILL.md) und Cron-Skripte (hermes/scripts), die Hermes-Cron-Jobs (cron/jobs.json)
• Hermes-Config (config.yaml lesbar; .env vorhanden) und die llama-swap-Config (Modelle eingetragen)
• Box-Wart-Zustand (box-wart/mc2-*.json lesbar) und die systemd-Units (samt llama-swap-Drop-ins)
Geheimnisse (.env, Token-Dateien) und Verknüpfungen werden nicht ausgepackt, nur ihr Vorhandensein geprüft.
Aufruf: mc2-probe-wiederherstellung.service (Timer: erster Montag im Monat, 05:15) oder von Hand auf der Box:
cd ~/mission-control-v2/backend && .venv/bin/python -m services.probe_wiederherstellung
Exit 0 = grün, 1 = rot.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import zlib
from datetime import datetime
from pathlib import Path
from config import HERMES_HOME
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import backup as sicherung
log = logging.getLogger(__name__)
EINHEIT = "mc2-probe-wiederherstellung"
ZUSTAND_PATH = Path(os.environ.get("MC_PROBE_ZUSTAND",
str(einstellungen().daten_dir / "mc2-probe-wiederherstellung.json")))
SICHERUNGEN_DIR = sicherung.BACKUP_DIR
# Die Sicherung läuft täglich um 03:30. Ist die jüngste älter, steht sie — das ist ein roter Befund.
MAX_ALTER_H = float(os.environ.get("MC_PROBE_MAX_ALTER_H", "48"))
# Der Wächter zeigt gelb, wenn die letzte Probe älter ist (monatlicher Takt plus Spielraum).
WARN_TAGE = 40
# Lebender Stand, gegen den verglichen wird (Tests setzen eigene Pfade).
HERMES_LIVE = HERMES_HOME
DATEN_LIVE = einstellungen().daten_dir
LLAMA_SWAP_DROPINS = Path("/etc/systemd/system/llama-swap.service.d")
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
# Nicht auspacken: Geheimnisse. Geprüft wird nur, dass sie in der Sicherung stehen.
_GEHEIM = {".env", "desktop-gateway-token", "session-token.conf"}
_PRAEFIX = "mc2-probe-"
_NAME_ZEIT = re.compile(r"mc2-state-(\d{8}-\d{6})\.tar\.gz$")
# Eine Datei, die bis so kurz vor dem Start der Sicherung geändert wurde, zählt als „danach geändert“.
_SPIELRAUM_S = 5.0
# --- Hilfen ------------------------------------------------------------------------------------
def _tmp_basis() -> str | None:
"""Wohin ausgepackt wird: MC_PROBE_TMP, sonst /var/tmp (Platte; /tmp ist auf der Box ein RAM-Laufwerk)."""
if (basis := os.environ.get("MC_PROBE_TMP", "").strip()):
return basis
return "/var/tmp" if os.path.isdir("/var/tmp") else None
def _alte_tmp_ordner_entfernen() -> None:
"""Reste einer abgebrochenen Probe (nur eigene Ordner mit unserem Präfix, älter als ein Tag)."""
basis = Path(_tmp_basis() or tempfile.gettempdir())
try:
kandidaten = list(basis.glob(f"{_PRAEFIX}*"))
except OSError:
return
for ordner in kandidaten:
try:
if ordner.is_dir() and not ordner.is_symlink() and time.time() - ordner.stat().st_mtime > 86400:
_entfernen(ordner)
except OSError:
pass
def _entfernen(ordner: Path) -> bool:
"""Tmp-Ordner löschen, auch wenn die Sicherung schreibgeschützte Ordner mitbrachte. True = er ist weg."""
def schreibbar_machen(funktion, pfad, _fehler) -> None:
try:
os.chmod(os.path.dirname(pfad), 0o700)
os.chmod(pfad, 0o700)
funktion(pfad)
except OSError:
pass
shutil.rmtree(ordner, onexc=schreibbar_machen)
return not ordner.exists()
def _rel(name: str) -> str:
"""Name im Archiv ohne führendes „./“ („./hermes/SOUL.md“ → „hermes/SOUL.md“)."""
while name.startswith("./"):
name = name[2:]
return name.rstrip("/")
def _dateien(ordner: Path) -> list[Path]:
if not ordner.is_dir():
return []
return sorted(p for p in ordner.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
def _geaendert(pfad: Path) -> float:
"""Letzte Änderung einer lebenden Datei. ctime zählt mit: Wer eine Datei samt alter mtime auspackt (Skill-Paket,
Restore), erzeugt sie trotzdem neu — sie kann dann gar nicht in einer älteren Sicherung stehen."""
st = pfad.stat()
return max(st.st_mtime, st.st_ctime)
def sicherungs_zeit(tarball: Path) -> float:
"""Wann die Sicherung begann: Zeitstempel im Namen (Ortszeit der Box), sonst die Dateizeit."""
if (m := _NAME_ZEIT.search(tarball.name)):
try:
return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
pass
return tarball.stat().st_mtime
def juengste_sicherung(ordner: Path | None = None) -> Path | None:
ordner = SICHERUNGEN_DIR if ordner is None else ordner
try:
liste = sorted(ordner.glob("mc2-state-*.tar.gz"), key=lambda p: p.name, reverse=True)
except OSError:
return None
return liste[0] if liste else None
def _yaml(pfad: Path):
from ruamel.yaml import YAML
return YAML(typ="safe").load(pfad.read_text(encoding="utf-8"))
# --- Die Prüfung ---------------------------------------------------------------------------------
def _sammelzeile(bereich: str, namen: list[str], einzeln: str, mehrere: str) -> str:
"""Eine Fehlerzeile je Art und Bereich, auch wenn hunderte Dateien betroffen sind."""
if len(namen) == 1:
return f"{bereich}: {namen[0]} {einzeln}."
beispiele = ", ".join(namen[:3]) + (" …" if len(namen) > 3 else "")
return f"{bereich}: {len(namen)} Dateien {mehrere} ({beispiele})."
def _pruefe_abgleich(bereich: str, kopie: Path, live: Path | None, beginn: float, fehler: list[str],
inhalt_gleich: bool = False) -> None:
"""Jede lebende Datei, die schon vor Beginn der Sicherung so dastand, muss in der Sicherung stehen
(bei inhalt_gleich auch mit demselben Inhalt). Leere Dateien und Sperrdateien (*.lock) zählen nicht."""
if live is None or not live.exists():
return
fehlend: list[str] = []
anders: list[str] = []
unlesbar: list[str] = []
for datei in [live] if live.is_file() else _dateien(live):
name = datei.name if live.is_file() else datei.relative_to(live).as_posix()
try:
# Verknüpfungen packt die Probe nicht aus (backup.sh sichert sie als Verknüpfung) — nicht vergleichen.
if (datei.is_symlink() or datei.name.endswith(".lock") or datei.stat().st_size == 0
or _geaendert(datei) >= beginn - _SPIELRAUM_S):
continue
gegenstueck = kopie if live.is_file() else kopie / datei.relative_to(live)
if not gegenstueck.is_file():
fehlend.append(name)
elif inhalt_gleich and gegenstueck.read_bytes() != datei.read_bytes():
anders.append(name)
except OSError:
unlesbar.append(name)
if fehlend:
fehler.append(_sammelzeile(bereich, fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
if anders:
fehler.append(_sammelzeile(bereich, anders, "steht in der Sicherung anders als auf der Box",
"stehen in der Sicherung anders als auf der Box"))
if unlesbar:
fehler.append(_sammelzeile(bereich, unlesbar, "ließ sich nicht vergleichen", "ließen sich nicht vergleichen"))
def _pruefe_inhalt(wurzel: Path, mitglieder: dict[str, tarfile.TarInfo], beginn: float,
geprueft: list[str], fehler: list[str]) -> None:
"""Die Pflichtinhalte im ausgepackten Ordner (Geheimnisse nur über die Liste der Mitglieder)."""
hermes = wurzel / "hermes"
# Lucys Gedächtnis: das Wertvollste, deshalb auch Inhalt gegen den lebenden Stand.
gedaechtnis = [p for p in _dateien(hermes / "memories") if p.stat().st_size > 0 and not p.name.endswith(".lock")]
if gedaechtnis:
geprueft.append(f"Gedächtnis: {len(gedaechtnis)} Dateien ({', '.join(p.name for p in gedaechtnis[:4])})")
else:
fehler.append("Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("Gedächtnis", hermes / "memories", HERMES_LIVE / "memories", beginn, fehler, inhalt_gleich=True)
seele = hermes / "SOUL.md"
if seele.is_file() and seele.stat().st_size > 0:
geprueft.append("SOUL.md")
else:
fehler.append("SOUL.md fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("SOUL.md", seele, HERMES_LIVE / "SOUL.md", beginn, fehler, inhalt_gleich=True)
skills = [p for p in _dateien(hermes / "skills") if p.name == "SKILL.md"]
if skills:
geprueft.append(f"Skills: {len(skills)}")
else:
fehler.append("Keine Skills (hermes/skills/…/SKILL.md) in der Sicherung.")
_pruefe_abgleich("Skills", hermes / "skills", HERMES_LIVE / "skills", beginn, fehler)
skripte = _dateien(hermes / "scripts")
if skripte:
geprueft.append(f"Cron-Skripte: {len(skripte)}")
else:
fehler.append("Keine Cron-Skripte (hermes/scripts) in der Sicherung.")
_pruefe_abgleich("Cron-Skripte", hermes / "scripts", HERMES_LIVE / "scripts", beginn, fehler)
try:
jobs = json.loads((hermes / "cron" / "jobs.json").read_text(encoding="utf-8"))
liste = jobs.get("jobs", jobs) if isinstance(jobs, dict) else jobs
geprueft.append(f"Hermes-Cron-Jobs: {len(liste)}")
except (OSError, ValueError, TypeError) as exc:
fehler.append(f"Die Hermes-Cron-Jobs (cron/jobs.json) fehlen oder sind unlesbar ({exc.__class__.__name__}).")
try:
cfg = _yaml(hermes / "config.yaml")
if not isinstance(cfg, dict) or not cfg:
raise ValueError("leer")
geprueft.append("Hermes-Config lesbar")
except Exception as exc:
fehler.append(f"Die Hermes-Config (config.yaml) fehlt oder ist unlesbar ({exc.__class__.__name__}).")
env = mitglieder.get("hermes/.env")
if env is not None and env.isfile() and env.size > 0:
geprueft.append("Zugangsdaten (.env) enthalten (nicht ausgepackt)")
else:
fehler.append("Die Zugangsdaten (hermes/.env) fehlen in der Sicherung.")
try:
modelle = (_yaml(wurzel / "llama-swap" / "config.yaml") or {}).get("models")
if not isinstance(modelle, dict) or not modelle:
raise ValueError("ohne Modelle")
geprueft.append(f"llama-swap-Config: {len(modelle)} Modelle")
except Exception as exc:
fehler.append(f"Die llama-swap-Config fehlt oder ist unbrauchbar ({exc.__class__.__name__}: {exc})"[:200])
zustand = sorted((wurzel / "box-wart").glob("mc2-*.json")) if (wurzel / "box-wart").is_dir() else []
kaputt = []
for datei in zustand:
try:
json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
kaputt.append(datei.name)
if not zustand:
fehler.append("Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt in der Sicherung.")
elif kaputt:
fehler.append(f"Box-Wart-Zustand unlesbar: {', '.join(kaputt)}")
else:
geprueft.append(f"Box-Wart-Zustand: {len(zustand)} Dateien")
fehlend = []
for datei in sorted(DATEN_LIVE.glob("mc2-*.json")) if DATEN_LIVE.is_dir() else []:
try:
vorher = _geaendert(datei) < beginn - _SPIELRAUM_S and datei.stat().st_size > 0
except OSError:
continue
if vorher and not (wurzel / "box-wart" / datei.name).is_file():
fehlend.append(datei.name)
if fehlend:
fehler.append(_sammelzeile("Box-Wart-Zustand", fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
units = sorted(p.name for p in (wurzel / "systemd" / "user").glob("*.service")) \
if (wurzel / "systemd" / "user").is_dir() else []
if "mission-control-2.service" in units:
geprueft.append(f"systemd-Units: {len(units)} Dienste")
else:
fehler.append("Die systemd-Units (systemd/user, mindestens mission-control-2.service) fehlen in der Sicherung.")
if LLAMA_SWAP_DROPINS.is_dir() and not (wurzel / "systemd" / LLAMA_SWAP_DROPINS.name).is_dir():
fehler.append(f"Die llama-swap-Drop-ins ({LLAMA_SWAP_DROPINS.name}) fehlen in der Sicherung.")
def pruefe_sicherung(tarball: Path) -> tuple[list[str], list[str]]:
"""Eine Sicherung probeweise auspacken und prüfen. Rückgabe (Geprüftes, Fehler); der Tmp-Ordner ist danach weg."""
geprueft: list[str] = []
fehler: list[str] = []
beginn = sicherungs_zeit(tarball)
try:
with tarfile.open(tarball, "r:gz") as tar:
alle = tar.getmembers() # liest das ganze Archiv: ein abgeschnittenes fällt hier auf
mitglieder = {_rel(m.name): m for m in alle}
auspacken = [m for m in alle if _rel(m.name) and (m.isfile() or m.isdir())
and Path(_rel(m.name)).name not in _GEHEIM]
tmp = Path(tempfile.mkdtemp(prefix=_PRAEFIX, dir=_tmp_basis()))
try:
tar.extractall(tmp, members=auspacken, filter="data")
geprueft.append(f"ausgepackt: {sum(1 for m in auspacken if m.isfile())} Dateien")
_pruefe_inhalt(tmp, mitglieder, beginn, geprueft, fehler)
finally:
if not _entfernen(tmp):
fehler.append(f"Der Tmp-Ordner {tmp} ließ sich nicht entfernen.")
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
fehler.append(f"Die Sicherung lässt sich nicht auspacken: {exc.__class__.__name__}: {exc}"[:240])
return geprueft, fehler
def probe() -> dict:
"""Die ganze Probe: jüngste Sicherung finden, Alter prüfen, auspacken und prüfen, Ergebnis ablegen, bei Rot melden."""
start = time.time()
geprueft: list[str] = []
fehler: list[str] = []
tarball = None
try:
_alte_tmp_ordner_entfernen()
tarball = juengste_sicherung()
if tarball is None:
fehler.append(f"Keine Sicherung gefunden ({SICHERUNGEN_DIR}).")
else:
alter_h = (time.time() - tarball.stat().st_mtime) / 3600
if alter_h > MAX_ALTER_H:
fehler.append(f"Die jüngste Sicherung ist {alter_h / 24:.0f} Tage alt: Die tägliche Sicherung "
"(mc2-backup.timer) läuft nicht.")
else:
geprueft.append(f"jüngste Sicherung {alter_h:.0f} Stunden alt")
g, f = pruefe_sicherung(tarball)
geprueft += g
fehler += f
except Exception as exc: # die Probe selbst darf nie still scheitern
log.exception("Probe-Wiederherstellung abgestürzt")
fehler.append(f"Die Probe ist abgestürzt: {exc.__class__.__name__}: {exc}"[:240])
stand = {
"version": 1,
"zeit": start,
"datum": datetime.fromtimestamp(start, LOCAL_TZ).isoformat(timespec="seconds"),
"ergebnis": "rot" if fehler else "gruen",
"sicherung": tarball.name if tarball else None,
"dauer_s": round(time.time() - start, 1),
"geprueft": geprueft,
"fehler": fehler,
}
speichere(stand)
if fehler:
melden(stand)
return stand
# --- Ablage und Meldung ------------------------------------------------------------------------------
def speichere(stand: dict) -> None:
try:
ZUSTAND_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = ZUSTAND_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(stand, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ZUSTAND_PATH)
except OSError:
log.warning("Probe-Wiederherstellung: %s nicht schreibbar", ZUSTAND_PATH, exc_info=True)
def lese_stand() -> dict | None:
"""Letztes Ergebnis für den Wächter. None = noch nie gelaufen (oder Datei unlesbar)."""
try:
daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not isinstance(daten.get("zeit"), (int, float)):
return None
return daten
def meldetext(stand: dict) -> str:
fehler = stand.get("fehler") or []
text = (f"Die Probe-Wiederherstellung ist rot ({stand.get('sicherung') or 'keine Sicherung'}): "
+ " ".join(fehler[:3]))
if len(fehler) > 3:
text += f" (und {len(fehler) - 3} weitere)"
return text + " Der Wächter zeigt es auch im Cockpit."
def _bash() -> str | None:
"""bash für notify.sh; auf Windows (Entwicklung) gibt es keinen Meldeweg."""
return shutil.which("bash") if os.name == "posix" else None
def melden(stand: dict) -> None:
"""Rot → Meldung über notify.sh in normaler Dringlichkeit: kein -d, kein „Alarm“ im Betreff — nachts sammelt
notify.sh sie für die Morgenmeldung um 07:00."""
if not (bash := _bash()):
log.warning("Probe-Wiederherstellung rot, aber hier gibt es keinen Meldeweg: %s", meldetext(stand))
return
try:
subprocess.run([bash, str(NOTIFY_SH), "-s", "[Sicherung]", meldetext(stand)], timeout=120, check=False,
stdin=subprocess.DEVNULL, capture_output=True)
except (OSError, subprocess.SubprocessError):
log.warning("Probe-Wiederherstellung: Meldung ging nicht raus", exc_info=True)
def main() -> int:
logging.basicConfig(level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
stand = probe()
print(f"Probe-Wiederherstellung von {stand['sicherung'] or '–'}: "
f"{'grün' if stand['ergebnis'] == 'gruen' else 'ROT'} ({stand['dauer_s']} s)")
for zeile in stand["geprueft"]:
print(f" ok {zeile}")
for zeile in stand["fehler"]:
print(f" ! {zeile}")
return 0 if stand["ergebnis"] == "gruen" else 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -21,15 +21,15 @@ import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR
from kern.zeit import LOCAL_TZ
from services import announce
log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly")
-143
View File
@@ -1,143 +0,0 @@
"""
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
"""
import psutil
from services import budget, catalog, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _capability_suit(role: str, caps: dict, name: str) -> float:
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
if role == "vision":
if not vision:
return 0.0 # harte Anforderung
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
if role == "coder":
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
if role == "hermes":
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
if "hermes" in low:
return 1.0
return 0.6 if tools else 0.1
if role == "fast":
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
return 1.0 if tools else 0.6
if role == "heavy":
return 1.0
if role == "scout":
return 0.9 if vision else 0.7
return 0.5
def _pref(role: str, params: float, tps: float) -> float:
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
role = (role or "").lower()
if role == "fast":
speed = min(tps / 25.0, 1.0)
size_ok = 1.0 if params <= 50 else 50.0 / params
return speed * size_ok
if role == "heavy":
return min(params / 120.0, 1.0)
if role == "hermes":
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn
if role == "vision":
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
if role == "coder":
return 0.5 + 0.5 * min(params / 80.0, 1.0)
if role == "scout":
return 1.0 if params <= 40 else 0.5
return 0.5
def _catalog_role_match(role: str, name: str) -> bool:
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
meta = catalog.meta_for_name(name)
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
incomplete: bool, cat_match: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "coder" and not caps.get("coder"):
return "kein Coder-Modell"
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if cat_match:
bits.append("Katalog-Pick ✓")
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("Hermes" if "hermes" in (name or "").lower()
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
if caps.get("moe"):
bits.append("MoE")
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
return " · ".join(bits)
def recommend_for_role(role: str) -> dict:
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
role = (role or "").strip().lower()
ram = _ram_gb()
out = []
for m in llamaswap.list_models():
caps = m.get("capabilities") or {}
params = budget.params_of_model(m)
quant = m.get("quant") or "Q4_K_M"
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
incomplete = bool(m.get("incomplete"))
fits = (fit["level"] != "too_tight") and not incomplete
tps = fit["tps"] or 0
suit = _capability_suit(role, caps, m["name"])
cat_match = _catalog_role_match(role, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
if not fits:
score -= 5.0
out.append({
"name": m["name"], "current_role": m.get("role"),
"params_b": round(params, 1), "quant": quant,
"fit": fit, "suitable": suitable, "incomplete": incomplete,
"score": round(score, 3),
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
})
out.sort(key=lambda x: -x["score"])
rec = next((o["name"] for o in out if o["suitable"]), None)
for o in out:
o["recommended"] = (o["name"] == rec)
return {"role": role, "recommended": rec, "models": out}
+67 -19
View File
@@ -3,7 +3,7 @@ Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
- **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`;
triviale Kurzfrage ohne Code → `fast` (Tempo).
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
@@ -19,8 +19,6 @@ from services.routing_policy import load_policy
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
LANES = ["coding", "chat"]
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
_HEAVY_KW = re.compile(
@@ -36,23 +34,76 @@ _CODING_HEAVY_KW = re.compile(
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
re.IGNORECASE,
)
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
_CODE_HINT = re.compile(
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
r"npm|pip|git|docker|terminal|shell|command)\b",
re.IGNORECASE | re.MULTILINE,
)
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
a = (alias or "").lower()
return "coder" in a or a == "heavy"
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
frisch: list[tuple[dict, int]] = []
alt: list[tuple[dict, int]] = []
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), list):
continue
for j, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
(frisch if i > letzte_antwort else alt).append((m, j))
return frisch, alt
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
gibt), alles andere an den Hirn-Zwilling (vision)."""
if coder_vision_alias and ist_coder_alias(alias):
return coder_vision_alias
return vision_alias
def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
content = m.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
return True
return False
def _text_of(body: dict) -> str:
msgs = body.get("messages") or []
return "\n".join(str(m.get("content") or "") for m in msgs)
# Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing
# zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen).
out = []
for m in msgs:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str):
out.append(c)
elif isinstance(c, list):
for part in c:
if isinstance(part, dict) and part.get("type") == "text":
out.append(str(part.get("text") or ""))
return "\n".join(out)
def _route_chat(text: str, n: int) -> tuple[str, str]:
@@ -85,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
return _route_chat(text, n) # chat + alles Unbekannte
def choose_model(body: dict) -> tuple[str, str]:
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
return choose_for_lane("chat", body)
+9 -19
View File
@@ -30,6 +30,12 @@ DEFAULTS: dict = {
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
# "" = auch Coder-Bilder gehen an vision.
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
@@ -41,6 +47,8 @@ FIELDS: list[dict] = [
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
{"key": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
@@ -77,7 +85,7 @@ def _coerce(patch: dict) -> dict:
out[k] = bool(v)
else: # str
sv = str(v).strip()
if k != "coder_lite" and not sv:
if k not in ("coder_lite", "vision", "coder_vision") and not sv:
raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv
return out
@@ -107,21 +115,3 @@ def load_policy() -> dict:
return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
-141
View File
@@ -1,141 +0,0 @@
"""
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
"""
import asyncio
import logging
import os
import time
import httpx
import psutil
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool:
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500
except Exception:
return False
def _check_engine() -> bool:
return llamaswap.engine_reachable()
def _check_brain() -> bool:
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
st = llamaswap.brain_status()
if st.get("ready"):
return True
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
def _check_disk() -> bool:
try:
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
except Exception:
return True # kein Messwert ≠ Alarm
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
CHECKS: dict[str, tuple] = {
"engine": (_check_engine,
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
"Die Modell-Engine ist wieder da."),
"brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
"hermes": (lambda: _reach(HERMES_API_URL),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."),
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
"Mein Gedächtnis ist wieder online."),
"voice": (lambda: _reach(VOICE_SERVICE_URL),
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
"Der Hör-Dienst läuft wieder."),
"disk": (_check_disk,
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
"Die Platte hat wieder genug Luft."),
}
class _Watch:
__slots__ = ("fails", "alerted", "alert_ts")
def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge
self.alerted = False # Alarm ist raus, Entwarnung steht aus
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
def _notify_telegram(subject: str, text: str) -> None:
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
legt der Wächter selbst ab."""
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
def _tick() -> None:
now = time.time()
for name, (check, fail_msg, ok_msg) in CHECKS.items():
w = _watches[name]
try:
ok = bool(check())
except Exception:
ok = False
if ok:
w.fails = 0
if w.alerted:
w.alerted = False
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
_notify_telegram("[Box wieder ok]", ok_msg)
continue
w.fails += 1
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
if due:
prefix = "" if not w.alerted else "Immer noch: "
w.alerted = True
w.alert_ts = now
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
_notify_telegram("[Box-Problem]", prefix + fail_msg)
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
async def sentry_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
await asyncio.sleep(START_DELAY)
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+77
View File
@@ -0,0 +1,77 @@
"""
Software-Stand einer Instanz (seit 24.09.2026): welcher Commit seit wann live ist.
Nach einem erfolgreichen Umschalten schreibt deploy/deploy.sh (KI-Box) bzw. deploy/homelab/ausrollen.sh
(Container des Homelab-Teils) die Stand-Datei mc2-stand.json in den Datenordner (deploy/stand-schreiben.py):
Commit kurz, Betreff, Commit-Datum und den Zeitpunkt des Deploys. Die Oberfläche zeigt sie unter
Einstellungen → Software-Stand, für diese Instanz über /api/instanz und für die andere über /api/partner/instanz.
Ohne Stand-Datei (Entwicklung am PC, Box vor dem ersten Deploy mit dieser Datei) kommt der Stand aus git, sonst
heißt er „unbekannt“. Nur lesend.
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from kern.einstellungen import einstellungen
REPO = Path(__file__).resolve().parents[2]
GIT_CACHE_S = 60.0
_lock = threading.Lock()
_git_cache: dict = {"ts": 0.0, "stand": None}
def stand_datei() -> Path:
return Path(os.environ.get("MC_STAND_DATEI", str(einstellungen().daten_dir / "mc2-stand.json")))
def _text(wert: object) -> str | None:
return wert.strip() if isinstance(wert, str) and wert.strip() else None
def _aus_datei() -> dict | None:
try:
daten = json.loads(stand_datei().read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not _text(daten.get("commit")):
return None
return {"quelle": "deploy", **{k: _text(daten.get(k)) for k in ("commit", "betreff", "commit_datum",
"deploy_zeit")}}
def _git_log() -> str | None:
"""Kopf des Checkouts, in dem dieser Code liegt. Die Schicht, die Tests ersetzen."""
try:
r = subprocess.run(["git", "-C", str(REPO), "log", "-1", "--format=%h%n%cI%n%s"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
except (OSError, subprocess.SubprocessError):
return None
return r.stdout if r.returncode == 0 else None
def _aus_git() -> dict | None:
with _lock:
if time.time() - _git_cache["ts"] < GIT_CACHE_S:
return _git_cache["stand"]
ausgabe = _git_log()
stand = None
if ausgabe:
commit, datum, betreff = ([z.strip() for z in ausgabe.splitlines()] + ["", "", ""])[:3]
if commit:
stand = {"quelle": "git", "commit": commit, "betreff": betreff or None,
"commit_datum": datum or None, "deploy_zeit": None}
with _lock:
_git_cache.update(ts=time.time(), stand=stand)
return stand
def stand() -> dict:
"""{"quelle": "deploy"|"git"|"unbekannt", "commit", "betreff", "commit_datum", "deploy_zeit"}."""
return _aus_datei() or _aus_git() or {"quelle": "unbekannt", "commit": None, "betreff": None,
"commit_datum": None, "deploy_zeit": None}

Some files were not shown because too many files have changed in this diff Show More