126 Commits
Author SHA1 Message Date
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
502 changed files with 23868 additions and 29434 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"mc2-system": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
]
},
"mc2-web": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
]
}
}
}
+55 -28
View File
@@ -1,28 +1,55 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
}
]
}
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5180",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"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
}
]
}
+8
View File
@@ -1,6 +1,12 @@
{
"permissions": {
"allow": [
"autoMode": {
"allow": [
"$defaults",
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
]
}
"Bash(git fetch *)",
"Bash(git push:*)",
"Bash(git rev-list *)",
@@ -87,6 +93,8 @@
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)"
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
]
}
}
+83 -1
View File
@@ -9,7 +9,8 @@
#
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
name: Ampel
on: [push, pull_request]
@@ -42,6 +43,87 @@ jobs:
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
if [ $rot -eq 0 ]; then
echo "-- Vitest"
( cd frontend && npm test --silent ) || rot=1
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
( cd frontend && npm run lint --silent ) || rot=1
fi
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
# bewusst und einmalig, nicht weil es im Weg stand:
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
# hinter lazy() wanderten und das Budget scharf blieb.
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
# ueberlegten Plattform-Schritt.
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
budget_dist=3145728 # Stand nach P0: 1 477 852 B
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
# Hash den falschen erwischt — und das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
haupt="frontend/dist/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
if [ "$gz" -gt "$budget_gz" ]; then
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
rot=1
fi
fi
gesamt=$(du -sb frontend/dist | cut -f1)
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
if [ "$gesamt" -gt "$budget_dist" ]; then
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
rot=1
fi
fi
if [ $rot -eq 0 ]; then
echo "== Frontend: Push nach release-dist =="
git config --global user.name "Gitea Actions"
git config --global user.email "actions@gitea.local"
CURRENT_COMMIT=$(git rev-parse HEAD)
# Erzeuge (oder hole) den orphan branch 'release-dist'
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
git rm -rf . >/dev/null 2>&1 || true
# Checkout nur frontend/dist vom aktuellen Stand
git add -f frontend/dist
if ! git diff-index --quiet HEAD; then
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
git push origin HEAD:release-dist -f
else
echo "Keine Änderungen am Frontend-Build."
fi
# Zurück zum originalen Branch für den Rest des Skripts
git checkout $CURRENT_COMMIT
fi
fi
fi
+19 -25
View File
@@ -1,25 +1,19 @@
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
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
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Env / local
*.env
.DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
+67 -48
View File
@@ -1,48 +1,67 @@
# AGENTS.md — 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/`.
## 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.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`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).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## 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
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(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.
- 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.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- 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.
# AGENTS.md — 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/`.
## 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 (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,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`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.
- **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).
- **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 der **OpenCode Desktop IDE**.
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
## 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
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(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.
- 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)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- 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.
+39 -41
View File
@@ -11,77 +11,77 @@ prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
| Bahn | Was sie tut | Wo |
|---|---|---|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue | MC2 `:9001` |
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
| **Lucy** | Innere Stimme am PC (HUD, kein Avatar) und auf Telegram — eine Stimme, ein Hirn | eigenes Repo `Hitonabi/lucy` |
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
Bau-Sitzung und wird nie unterbrochen.
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
## Ports & Dienste
| Port | Dienst | Erreichbar |
|---|---|---|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
| `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway``/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
Auto-Update und Self-Smoke. `llama-swap` läuft als System-Unit.
## Der Weg einer Idee
## Die Bau-Pipeline
```
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
└── autonomes Vibe Coding via OpenCode Desktop + MCP
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
Gemessen auf der Box (Stand: 27.08.2026, Vulkan b10653):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
| `coder` | Qwen3.8-27B (dicht, 27B) | **12,7 t/s** | Plant und baut — der Haupt-Coder. **131k Kontext** (ganzer Slot), multimodal |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,690 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm, 65k/Slot |
| `debugger` | Muse-Glimmer-30B | **4050 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (multimodal) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Vektorsuche + Feinsortierung |
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
> Sieben Rollen, mehr nicht. Frühere Fassungen listeten hier auch `kritiker`, `scout` und
> `dense-planer` — die Modell-Konsolidierung vom 19.08. hat sie entfernt (ein Coder, ein
> Agent-Hirn). Der dichte 27B ist seither nicht mehr „Planer" neben dem Coder, sondern **ist**
> der Coder. Details: `docs/wissen/STACK.md`.
‼️ **Dichte Modelle sind auf dieser Box bandbreitengebunden.** Der Prefill bricht mit wachsendem
Kontext ein — beim früheren Kritiker (Devstral, dicht) waren bei 32k gemessene 63 t/s ≈ **9 Minuten
nur zum Lesen**, weshalb er hart auf 16k gedeckelt war. Das gilt weiter für jedes dichte Modell:
`coder` (Qwen3.8-27B) liefert ~12,7 t/s gegen ~90 t/s des MoE-Hirns — **kein Konfigfehler, sondern
das 215-GB/s-Limit der Plattform.** Der Kritiker selbst ist seit dem 19.08. nicht mehr im Stack.
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
(`groups.brains`) steht auf **`persistent: true`** (Verdrängungsschutz; live gegengeprüft
27.08.2026). Wichtig ist nicht der Schalter, sondern: **alles, was gleichzeitig warm sein muss,
gehört in DIESELBE Gruppe** — zwei Gruppen verdrängen sich gegenseitig, und `persistent` schützt
nicht davor (gemessen 25.07.). Details und
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
## Qualitäts-Tore
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
2. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
1. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
@@ -98,7 +98,7 @@ Kontext-Komprimierung löscht still die Regeln mit.
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
memory/* · agent/* · ideen/* · lucy/stimme/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
@@ -128,13 +128,12 @@ Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `n
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| `MC_V1_UPSTREAM` | | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
[`deploy/governor/README.md`](deploy/governor/README.md).
## Weiterlesen
@@ -143,8 +142,7 @@ Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODEL
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
+57 -50
View File
@@ -1,50 +1,57 @@
# 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).
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-08-20, nach der Aufräum-Runde. Alle Zahlen hier sind **auf der Box gemessen**, nicht
aus Doku übernommen._
## 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 als Steuerpult für die autonome KI („Lucy").
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
- **Frontend** `frontend/`React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets direkt aus).
- **MC2-Gateway** `:9010` (`/v1`-Datenpfad, model:auto Routing + native Bildweiche).
- **Modell-Router** `:8080` (llama-swap v250, Vulkan/RADV Engine b10502).
- **Hermes Agent** `:8642` (`hermes-gateway`, v0.20.4) + `hermes dashboard` auf `:9119` (nur Loopback).
- **Sidecars:** `voice_service/` (:8650, faster-whisper STT + Piper TTS), `mcp/` (4 MCP-Server),
`client/hermes-pc` (PC-Executor :7777).
‼️ `mem0_service/` liegt noch im Baum, ist aber **stillgelegt**`:8765` antwortet nicht.
## Modelle live (gemessen 20.08.2026)
| Rolle (Alias) | Modell | Tempo | Zustand |
|---|---|---|---|
| `hermes` / `fast` | Qwen3.6-35B-A3B (MoE, DFlash) | **95,7 t/s** · Prompt 221 t/s | immer warm |
| `coder` | Qwen3.8-27B (dicht, Vision) | **12,6 t/s** · Prompt 144 t/s | ttl 5400, ~29 s Kaltstart |
| `debugger` | Muse-Glimmer-30B (DFlash) | — | ttl 600 |
| `heavy` | gpt-oss-120b | — | on-demand |
| `vision` | Qwen3-VL-30B-A3B-Instruct | — | on-demand |
| `embed` / `reranker` | Qwen3-Embedding-0.6B / Qwen3-Reranker-0.6B | — | immer warm |
Warm-Gruppe `brains` = embed + reranker + Qwen3.6-35B-A3B. Warm-Wächter weckt nur `fast`.
**Hintergrund zum Tempo:** Strix Halo liefert ~215 GB/s Speicherbandbreite. Ein *dichtes* Modell
muss pro Token seine vollen Gewichte lesen → 17 GB ÷ 215 GB/s ≈ 12,6 t/s ist das **Hardware-Limit**,
kein Konfigurationsfehler. MoE-Modelle lesen pro Token nur einen Bruchteil und sind darum ~8× schneller.
## Aufgeräumt am 20.08.2026
- CI-Ampel wieder **grün** (Lauf #77) — zwei ungenutzte `MEM0_SERVICE_URL`-Importe entfernt.
- Kaputter systemd-Timer `mc2-morgen-digest` abgeschaltet (der Hermes-Cron macht die Arbeit).
- `mcp-stderr.log` (27 MB, 238k Ping-Zeilen) geleert.
- Config-Drift aufgelöst: Repo == `/etc/llama-swap/config.yaml`. Warm-Set-Idee geparkt auf
Branch `parked/warm-set-5d93822`.
- Entfernt: Governor-Reste, OpenCode-/aider-Reste (853 MB), 61 alte Backup-Dateien,
verwaiste Modelle Devstral-Small-2 + GLM-4.6V-Flash + mem0 (21,5 GB) → `/srv/models` 209 → 189 GB.
- **Fähigkeiten repariert:** `orchestrator`- und `wartung`-Skill sowie `fremdblick.sh` zeigten auf
gelöschte Modelle (`Qwen3-Coder-Next`, `GLM-4.6V-Flash`, `GLM-4.7-Flash`) → der Kritiker-Gate war
stumm kaputt. Jetzt auf **Rollen-Aliase** umgestellt, damit Modellwechsel sie nicht mehr brechen.
- Alles Gelöschte liegt gesichert in `~/archiv-aufraeumen-20260820/` (11 MB).
## Offen
- `mem0_service/` + `_mem0_reachable()` in `backend/routers/system.py` ausbauen (toter Port).
- `~/.hermes/scripts/fremdblick.sh` ist **unversioniert** — gehört ins Repo.
- MCP-Log dauerhaft deckeln (Hermes' `max_size_mb` greift für gekapertes stderr nicht).
- Verschwundene Wächter: Ampel-Wächter, Prüfstand, Stack-Rundumschlag.
- Coder-Tempo: Entwurfsmodelle für spekulatives Dekodieren liegen ungenutzt unter
`/srv/models/Qwen3.8-27B-DFlash2-GGUF/`.
+29 -43
View File
@@ -4,6 +4,10 @@ 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.
Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar.
Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen,
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben.
"""
import asyncio
@@ -15,35 +19,27 @@ from typing import Any
import httpx
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from routers import (
agent,
auftragsbuch,
chronik,
connect,
console,
eigenleben,
boxwart,
events,
gateway_proxy,
governor,
health,
hermes_ui,
ideen,
maintenance,
memory,
models,
radar,
routing,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import ketten_digest, metrics_history, reminders, sentry, warmer
from services import memory as memory_svc
from services import metrics_history, reminders, warmer
from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
@@ -57,8 +53,9 @@ 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)."""
"""Hintergrund-Tasks an den App-Lebenszyklus binden. 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."""
tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
@@ -66,21 +63,14 @@ 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 ketten_digest.ENABLED:
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_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 und
# Messverlauf 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()))
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
else:
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.")
# 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(
@@ -117,11 +107,12 @@ async def no_cache(
app.include_router(health.router)
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
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
@@ -139,20 +130,11 @@ if V1_UPSTREAM:
else:
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(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
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(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
@@ -163,6 +145,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:
+144 -136
View File
@@ -1,136 +1,144 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
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"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# 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("/")
# 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).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# 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.
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."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
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.
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/"
# 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
# /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.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# 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
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
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"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# 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
# 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 --load-mode none"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# 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 + 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."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
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), 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("/")
# --- 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("/")
# 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/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# 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` und setzt dafür ein
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
# anfasst, öffnet damit auch diese Oberfläche. 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.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
# --- 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.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# 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
+10
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,
+112
View File
@@ -0,0 +1,112 @@
"""
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:3002: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.
"""
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()
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import agent_aktivitaet
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
@router.get("/agent/aktivitaet")
def aktivitaet(limit: int = 60) -> dict:
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
"""
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
-63
View File
@@ -1,63 +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
+222
View File
@@ -0,0 +1,222 @@
"""
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/zeitplan Heute gelaufen / demnächst geplant
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
"""
import os
import threading
import time
from datetime import datetime
from zoneinfo import ZoneInfo
import psutil
from config import MODELS_DIR
from fastapi import APIRouter, HTTPException
from services import (
backup,
llamaswap,
maintenance,
modell_nutzung,
system,
update_verlauf,
waechter,
zeitplan,
)
router = APIRouter(prefix="/api", tags=["boxwart"])
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_updates_lock = threading.Lock()
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False}
UPDATES_CACHE_S = 600
def _updates_holen() -> None:
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["laeuft"] = False
def _updates_gecacht() -> dict:
"""maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
zunächst nichts), frisch geholt wird im Hintergrund."""
with _updates_lock:
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
if veraltet and not _updates_cache["laeuft"]:
_updates_cache["laeuft"] = True
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
return _updates_cache["daten"] or {}
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 _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(_bausteine(u))
fest = len(update_verlauf.festgehalten())
if fest:
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
elif _updates_cache["daten"] is None:
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
else:
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
return lampen
def _box() -> dict:
p = system.metrik_punkt()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
platte = {"used": du.used, "total": du.total, "percent": du.percent}
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 = _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": _bausteine(u), "modelle": u.get("model_list") or [],
"festgehalten": update_verlauf.festgehalten(),
"gelesen": _updates_cache["daten"] is not None},
"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("/zeitplan")
def zeitplan_route() -> dict:
return zeitplan.flugplan()
@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}
-28
View File
@@ -1,28 +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 pydantic import BaseModel
from services import announce
router = APIRouter(prefix="/api")
class ChronikItem(BaseModel):
id: int
ts: float
subject: str
text: str
source: str
priority: str
class ChronikResponse(BaseModel):
items: list[ChronikItem]
@router.get("/chronik", response_model=ChronikResponse)
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
-20
View File
@@ -1,20 +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()
-87
View File
@@ -1,87 +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 config import BOX_CONSOLE_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
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:
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)
-54
View File
@@ -1,54 +0,0 @@
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import eigenleben
router = APIRouter(prefix="/api")
class SkillItem(BaseModel):
id: str
name: str
kategorie: str | None
aktiv: bool | None
beschreibung: str
version: str
autor: str
herkunft: str
herkunft_label: str
genutzt: int
zuletzt_genutzt: str | None
erstellt: str | None
status: str
geaendert: float
class EigenlebenOverviewResponse(BaseModel):
available: bool
skills: list[SkillItem]
selbst_erstellt: int
bilanz: dict
class SkillTextResponse(BaseModel):
id: str
text: str
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
def get_overview() -> dict:
return eigenleben.overview()
# :path — Kategorie-Skills haben einen Schrägstrich in der ID (autonomous-ai-agents/
# hermes-agent). Als normales {skill_id}-Segment matcht die Route dann nicht, der Request
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
# services.eigenleben.skill_text.
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
def get_skill(skill_id: str) -> dict:
text = eigenleben.skill_text(skill_id)
if text is None:
raise HTTPException(404, "Skill nicht gefunden.")
return {"id": skill_id, "text": text}
+77 -49
View File
@@ -1,25 +1,26 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
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).
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
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()`.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
selbst und bis dahin pollt die UI wie bisher).
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
@@ -35,7 +36,8 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
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
@@ -50,15 +52,9 @@ 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: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
from services import announce
fp["chronik"] = announce.list_after(None)["latest"]
except Exception:
pass
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
from services import ideen
d = ideen.list_queue()
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
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
@@ -66,35 +62,67 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
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()]
)
except Exception:
pass
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints()
yield ": verbunden\n\n"
seit_ping = 0.0
while True:
if await request.is_disconnected():
return
await asyncio.sleep(TICK_S)
seit_ping += TICK_S
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.
"""
alt = _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
yield f"event: metrik\ndata: {json.dumps(metrik_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 = _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
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
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)
+35 -3
View File
@@ -22,6 +22,13 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Weiche. 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. Über _BILD_MAX Bilder wird gar nicht erst beschrieben — der Aufrufer fällt
# dann auf die normale Vision-Umleitung zurück (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"))
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
_BILD_BESCHREIB_PROMPT = os.environ.get(
@@ -85,6 +92,10 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
for i, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
fundstellen.append((m, i, part))
if len(fundstellen) > _BILD_MAX:
log.warning("Bild-Weiche v2: %s Bilder (Deckel %s) — Request geht an das Vision-Modell "
"statt einzeln beschrieben zu werden", len(fundstellen), _BILD_MAX)
return False
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
frage = {
"model": vision_alias,
@@ -99,7 +110,7 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
text = ""
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=240.0) as c:
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 ""
@@ -138,7 +149,15 @@ def _inject_language(body: dict, alias: str) -> None:
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:
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.
@@ -187,7 +206,20 @@ 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"):
-40
View File
@@ -1,40 +0,0 @@
"""Governor — Fenster auf den Token-Waechter (:8100).
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
zweiten Port ausweichen muss.
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
"""
import os
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/api")
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
@router.get("/governor")
async def governor_status() -> dict:
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.get(f"{GOVERNOR_URL}/governor/status")
r.raise_for_status()
data = r.json()
data["reachable"] = True
return data
except Exception as exc:
return {
"ok": False,
"reachable": False,
"error": f"{type(exc).__name__}: {exc}",
"url": GOVERNOR_URL,
}
+38 -9
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
@@ -91,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:
@@ -113,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}")
-154
View File
@@ -1,154 +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 = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
class TaskIn(BaseModel):
id: str
class AntwortIn(BaseModel):
id: str
antwort: str
class ArchivAlleIn(BaseModel):
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
projekt: str = ""
class EinordnenIn(BaseModel):
text: 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, welt=body.welt, tiefe=body.tiefe,
ziel=body.ziel, art=body.art)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
return res
@router.post("/ideen/antwort")
def answer(body: AntwortIn) -> dict:
res = ideen.answer_task(body.id, body.antwort)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
return res
class PrioIn(BaseModel):
id: str
richtung: str # hoch | runter
@router.post("/ideen/stopp")
def stop(body: TaskIn) -> dict:
res = ideen.stop_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
return res
@router.post("/ideen/retry")
def retry(body: TaskIn) -> dict:
res = ideen.retry_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
return res
@router.post("/ideen/prio")
def prio(body: PrioIn) -> dict:
res = ideen.prio_task(body.id, body.richtung)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
return res
class WeiterIn(BaseModel):
id: str
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
@router.post("/ideen/weiterfuehren")
def weiterfuehren(body: WeiterIn) -> dict:
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
return res
class NachschaerfenIn(BaseModel):
id: str
hinweis: str
@router.post("/ideen/nachschaerfen")
def nachschaerfen(body: NachschaerfenIn) -> dict:
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet 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
@router.post("/ideen/einordnen")
def einordnen(body: EinordnenIn) -> dict:
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
und die Oberfläche behält ihre Voreinstellung."""
return ideen.einordnen(body.text)
@router.post("/ideen/archivieren-alle")
def archive_alle(body: ArchivAlleIn) -> dict:
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
res = ideen.archive_done(body.projekt)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.get("/ideen/{id}/log")
def get_log(id: str) -> dict:
return ideen.log_of(id)
@router.get("/ideen/{id}/ergebnis")
def get_ergebnis(id: str) -> dict:
return ideen.ergebnis_of(id)
@router.get("/ideen/{id}/konzept")
def get_konzept(id: str) -> dict:
return ideen.konzept_of(id)
+46 -23
View File
@@ -1,19 +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, Header, HTTPException
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")
@@ -28,32 +37,32 @@ 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
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
@router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job(body.sudo_password)
def update_all() -> dict:
return maintenance.update_all_job()
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
def reboot() -> dict:
return maintenance.reboot()
@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()}
-92
View File
@@ -1,92 +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}
+6 -4
View File
@@ -4,7 +4,7 @@ import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import budget, discover, hf, jobengine, llamaswap
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api")
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
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")
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
if token := geheimnisse.hf_token():
env["HF_TOKEN"] = token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
on_done=_apply_role if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
+44
View File
@@ -0,0 +1,44 @@
"""
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)
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
"""
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:
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand."""
ergebnis = radar.suche()
return {**radar.uebersicht(), "suche": ergebnis}
@router.post("/radar/{kandidat_id}/uebernehmen")
def radar_uebernehmen(kandidat_id: str) -> dict:
return _antwort(radar.uebernehmen(kandidat_id))
@router.post("/radar/{kandidat_id}/verwerfen")
def radar_verwerfen(kandidat_id: str) -> dict:
return _antwort(radar.verwerfen(kandidat_id))
+6 -15
View File
@@ -10,7 +10,7 @@ import os
import subprocess
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from fastapi import APIRouter
from pydantic import BaseModel
from services import backup as backup_svc
@@ -42,13 +42,6 @@ def history(minutes: int = 60) -> dict:
return metrics_history.history(minutes)
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
@@ -95,18 +88,16 @@ def services() -> dict:
rows += [
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"ok": a["hermes_ui_reachable"], "scope": "user"},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
"ok": _mem0_reachable(), "scope": "user"},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
"ok": _voice_reachable(), "scope": "user"},
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
"ok": bool(a.get("box_console_reachable")), "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": _user_unit_active("lucy-stimme"), "scope": "user"},
]
return {
"services": rows,
+75 -7
View File
@@ -3,7 +3,7 @@ Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
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
eigenem Gedächtnis 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.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
@@ -17,7 +17,7 @@ import sys as _sys
import time
import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
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
@@ -169,7 +169,7 @@ def voice_metrics() -> dict:
@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 —
Generierung). 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)}
@@ -289,8 +289,8 @@ 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.
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung 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
@@ -325,7 +325,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
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.
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py).
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
@@ -342,7 +342,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
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) —
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + 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()
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
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 seit dem Umbau nicht mehr mit
# einem eigenen pocket_server, sondern 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).
# LAN-only wie alle MC2-Endpoints.
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:
with Timer("lucy_tts"):
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")})
-92
View File
@@ -1,92 +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
from pydantic import BaseModel
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
class VaultFile(BaseModel):
path: str
name: str
dir: str
title: str
mtime: float
neu: bool
class WissenListResponse(BaseModel):
available: bool
files: list[VaultFile]
class WissenFileResponse(BaseModel):
path: str
content: str
mtime: float
@router.get("/wissen", response_model=WissenListResponse)
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", response_model=WissenFileResponse)
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.")
+1 -1
View File
@@ -33,7 +33,7 @@ def list_snapshots() -> dict:
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
"""Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
+176
View File
@@ -0,0 +1,176 @@
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
zu patchen.
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
nützlicher als der Fließtext ihrer Gedanken.
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
halten kann.
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger(__name__)
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
LOG_PFAD = Path(os.path.expanduser(
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
LESE_BYTES = 512 * 1024
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
#
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
_FERTIG = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
_FEHLER = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
def _schwanz(pfad: Path, n: int) -> list[str]:
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
try:
groesse = pfad.stat().st_size
with pfad.open("rb") as f:
f.seek(max(0, groesse - n))
roh = f.read()
zeilen = roh.decode("utf-8", errors="replace").splitlines()
return zeilen[1:] if groesse > n and zeilen else zeilen
except OSError:
return []
def _kurz(detail: str | None) -> str | None:
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
JSON ab; interessant ist daran nur das `error`-Feld."""
if not detail:
return None
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
text = treffer.group(1) if treffer else detail.strip()
return (text[:297] + "") if len(text) > 300 else text
def rufe(limit: int = 60) -> list[dict]:
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
ergebnis: list[dict] = []
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
m = _FERTIG.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": int(m.group("zeichen")),
"ok": True,
"fehler": None,
})
continue
m = _FEHLER.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": None,
"ok": False,
"fehler": _kurz(m.group("detail")),
})
return ergebnis[-limit:]
def _iso(s: str) -> str:
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
Falle, die erst zur Zeitumstellung zuschnappt."""
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
except ValueError:
return s
def uebersicht(limit: int = 60) -> dict:
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
wiederkehrenden Fehler zusammengefasst.
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
liste = rufe(limit)
if not LOG_PFAD.exists():
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
"werkzeuge": [], "laeufe": []}
bilanz: dict[str, dict] = {}
for r in liste:
b = bilanz.setdefault(r["werkzeug"], {
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
"dauer_summe": 0.0, "letzter_fehler": None,
})
b["rufe"] += 1
b["dauer_summe"] += r["dauer_s"]
if not r["ok"]:
b["fehler"] += 1
b["letzter_fehler"] = r["fehler"]
werkzeuge = sorted(
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
for b in bilanz.values()),
key=lambda b: (-b["fehler"], -b["rufe"]),
)
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
laeufe: list[dict] = []
gesehen: dict[str, dict] = {}
for r in liste:
schluessel = r["lauf"] or "(interaktiv)"
if schluessel not in gesehen:
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
"rufe": 0, "fehler": 0}
laeufe.append(gesehen[schluessel])
e = gesehen[schluessel]
e["bis"] = r["zeit"]
e["rufe"] += 1
if not r["ok"]:
e["fehler"] += 1
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
"werkzeuge": werkzeuge, "laeufe": laeufe}
-461
View File
@@ -1,461 +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:"},
}
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
# Muster wie mc2-announce.json unter MODELS_DIR.
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
# 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 _load_announce_branches() -> set:
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
try:
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
return set(data) if isinstance(data, list) else set()
except Exception:
return set()
def _save_announce_branches(branches: set) -> None:
"""Speichert die Menge gemelder Branch-Namen."""
try:
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
tmp.replace(ANNOUNCE_BRANCHES_PATH)
except OSError:
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, 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.
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
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)
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
aktuelle_branches = set()
for repo in REPOS:
if _repo_ok(repo):
aktuelle_branches.update(_remote_branches(repo))
# Bereits gemeldete Branches laden
gemeldet = _load_announce_branches()
# Nur wirklich NEUE Branches pingen
neue = aktuelle_branches - gemeldet
for branch in neue:
# NotifyTelegram best-effort, niemals crashen
try:
from services import announce
subject = "[Auftragsbuch]"
text = f"Neue Karte wartet auf deinen Klick: {branch}"
announce.notify_telegram(subject, text)
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
announce.add(text, subject, "auftragsbuch", "normal")
except Exception:
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
if aktuelle_branches != gemeldet:
_save_announce_branches(aktuelle_branches)
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}
+4 -2
View File
@@ -1,5 +1,5 @@
"""
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.
"""
@@ -70,9 +70,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
-162
View File
@@ -1,162 +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 HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
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:
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
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→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
"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 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
sonst llama-swap direkt), 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"{V1_UPSTREAM or 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:
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:
pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception:
pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
-219
View File
@@ -1,219 +0,0 @@
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
Beantwortet die User-Frage vom 15.07.2026: „Es gibt keinen Viewpoint, welche Skills
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
selbst → weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
repo → liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
bundled → liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
"""
from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
SKILLS_DIR = Path.home() / ".hermes" / "skills"
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
USAGE_FILE = SKILLS_DIR / ".usage.json"
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
_HERKUNFT_LABEL = {
"selbst": "Selbst erstellt",
"repo": "Von uns gebaut",
"bundled": "Mit Hermes mitgeliefert",
}
def _frontmatter(text: str) -> dict:
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
out: dict = {}
if not text.startswith("---"):
return out
for line in text.split("\n", 1)[1].split("\n"):
if line.strip() == "---":
break
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if not m:
continue
val = m.group(2).strip().strip('"').strip("'")
if val:
out[m.group(1)] = val
return out
def _norm(s: str) -> str:
return re.sub(r"[\s_-]+", "", (s or "").lower())
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
_AKTIV_EVERY = 600.0 # s
def _aktive_namen() -> set[str] | None:
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
if not HERMES_PY.is_file():
return None
now = time.time()
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
return _aktiv_cache["namen"]
code = ("from hermes_cli.banner import get_available_skills;import json;"
"print(json.dumps(get_available_skills()))")
try:
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
text=True, timeout=60, cwd=str(HERMES_SRC))
idx = r.stdout.find("{")
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
namen = {_norm(n) for liste in kategorien.values() for n in liste}
except Exception:
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
_aktiv_cache.update(ts=now, namen=namen)
return namen
def list_skills() -> list[dict]:
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
if not SKILLS_DIR.is_dir():
return []
try:
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
except Exception:
usage_raw = {}
usage = {_norm(k): v for k, v in usage_raw.items()}
aktive = _aktive_namen()
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
try:
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
except Exception:
fm = {}
if (BUNDLED_DIR / rel).is_dir():
herkunft = "bundled"
elif (REPO_DIR / rel).is_dir():
herkunft = "repo"
else:
herkunft = "selbst"
kurz = rel.split("/")[-1]
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
aktiv = None
if aktive is not None:
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
return {
"id": rel,
"name": fm.get("name") or kurz,
"kategorie": kategorie,
"aktiv": aktiv,
"beschreibung": fm.get("description") or "",
"version": fm.get("version") or "",
"autor": fm.get("author") or "",
"herkunft": herkunft,
"herkunft_label": _HERKUNFT_LABEL[herkunft],
"genutzt": int(u.get("use_count") or 0),
"zuletzt_genutzt": u.get("last_used_at") or None,
"erstellt": u.get("created_at") or None,
"status": u.get("state") or "active",
"geaendert": md.stat().st_mtime,
}
skills: list[dict] = []
for d in sorted(SKILLS_DIR.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue # .archive (Curator) & Co. sind keine aktiven Skills
md = d / "SKILL.md"
if md.is_file():
skills.append(eintrag(md, d.name, None))
continue
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
for sub in sorted(d.iterdir()):
smd = sub / "SKILL.md"
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
# Selbst erstellte zuerst — das ist der Star der Ansicht.
order = {"selbst": 0, "repo": 1, "bundled": 2}
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
return skills
def skill_text(skill_id: str) -> str | None:
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
return None
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
if any(seg in (".", "..") for seg in skill_id.split("/")):
return None
md = SKILLS_DIR / skill_id / "SKILL.md"
if not md.is_file():
return None
try:
return md.read_text(encoding="utf-8", errors="replace")
except Exception:
return None
def bilanz() -> dict:
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
eintraege: list[dict] = []
try:
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
for branch, info in branches.items():
eintraege.append({
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
"titel": branch,
"detail": info.get("detail") or "",
"ts": float(info.get("ts") or 0),
})
except Exception:
pass
try:
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
j = json.loads(line)
except Exception:
continue
eintraege.append({
"art": "abgelehnt",
"titel": j.get("subject") or j.get("branch") or "?",
"detail": j.get("grund") or "",
"ts": float(j.get("ts") or 0),
})
except Exception:
pass
eintraege.sort(key=lambda e: -e["ts"])
return {
"eintraege": eintraege,
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
}
def overview() -> dict:
skills = list_skills()
b = bilanz()
return {
"available": SKILLS_DIR.is_dir(),
"skills": skills,
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
"bilanz": b,
}
+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
File diff suppressed because it is too large Load Diff
+12 -23
View File
@@ -57,39 +57,29 @@ def _pump_output(job: dict, stream) -> None:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
def _run_job(job_id: str, args: list[str], env: dict | None = None):
"""Job-Prozess starten und mitschreiben.
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
job = JOBS[job_id]
job["state"] = "running"
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 ")
proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None,
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
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()
_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:
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
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:
group: str | None = None) -> str:
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,
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
}
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()
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id
-190
View File
@@ -1,190 +0,0 @@
"""
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
einmal pro Karte, mit der Frage im Wortlaut,
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
"""
import asyncio
import json
import logging
import os
import time
import zlib
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
def _kontext_druck(task_id: str) -> int:
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
try:
p = KANBAN_LOGS / f"{task_id}.log"
with p.open("rb") as f:
f.seek(max(0, p.stat().st_size - 200_000))
tail = f.read().decode("utf-8", errors="replace")
return tail.count("Compacting context")
except Exception:
return 0
def _load_state() -> dict:
try:
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save_state(state: dict) -> None:
try:
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATE_PATH)
except OSError:
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
def _melden(subject: str, text: str) -> None:
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
from services import announce
announce.notify_telegram(subject, text)
try:
announce.add(text, subject, "ketten-digest", "silent")
except Exception:
pass
def _tick() -> None:
from services import ideen
data = ideen.list_queue()
if not data.get("available"):
return
items = data.get("items") or []
projekte = data.get("projekte") or []
state = _load_state()
gemeldete_fragen: dict = state.setdefault("fragen", {})
projekt_state: dict = state.setdefault("projekte", {})
dirty = False
now = int(time.time())
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
for it in items:
if it.get("status") != "blocked":
continue
frage = (it.get("frage") or "").strip()
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
if key in gemeldete_fragen:
continue
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}\n"
+ (f"Frage: {frage[:500]}\n" if frage else "")
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
_melden("[Werkstatt]", text)
gemeldete_fragen[key] = now
dirty = True
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
gemeldete_haenger: dict = state.setdefault("haenger", {})
for it in items:
if it.get("status") != "running":
continue
puls = it.get("puls_alter")
if puls is None or puls < HANG_SEKUNDEN:
continue
key = f"{it['id']}:{it.get('gestartet') or 0}"
if key in gemeldete_haenger:
continue
minuten = int(puls / 60)
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
f"seit {minuten} min kein Lebenszeichen vom Worker.")
druck = _kontext_druck(it["id"])
if druck >= 2:
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
"die Aufgabe ist womöglich zu groß geschnitten.")
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
_melden("[Werkstatt]", text)
gemeldete_haenger[key] = now
dirty = True
if len(gemeldete_haenger) > 200:
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
gemeldete_haenger.pop(k, None)
dirty = True
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
if len(gemeldete_fragen) > 200:
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
gemeldete_fragen.pop(k, None)
dirty = True
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
for p in projekte:
ps = projekt_state.setdefault(p["key"], {})
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
"Ergebnisse liegen im Auftragsbuch.")
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
dirty = True
continue
aktiv = p.get("laeuft", 0) > 0
if not aktiv:
continue
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
continue
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
naechste = [i for i in items if i.get("projekt") == p["key"]
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
for l in laufende[:2]:
note = f"{l['notiz']}" if l.get("notiz") else ""
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
if naechste:
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
haengt = p.get("haengt", 0)
if haengt:
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
_melden("[Werkstatt]", "\n".join(zeilen))
ps.update(letzter_puls=now, letzter_stand=fertig)
dirty = True
if dirty:
_save_state(state)
async def digest_loop() -> None:
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+15
View File
@@ -474,6 +474,21 @@ def set_role(model_id: str, role: str | None) -> bool:
return True
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()
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
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
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
File diff suppressed because it is too large Load Diff
-176
View File
@@ -1,176 +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)
+109
View File
@@ -0,0 +1,109 @@
"""
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
zuletzt geladen wurde ("Health check passed").
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).
"""
import os
import re
import subprocess
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
CACHE_S = 600
TAGE = 7
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_STANDARD_NAMEN = {
"127.0.0.1": "Lucy und OpenChamber über MC2",
"::1": "Lucy und OpenChamber über MC2",
"192.168.178.28": "NerdQuiz auf Arcane",
"192.168.178.22": "NerdQuiz-Tests vom PC",
}
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
_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 werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
je_absender: Counter = Counter()
je_tag: dict[str, Counter] = defaultdict(Counter)
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
geladen: dict[str, str] = {}
grenze_24h = jetzt - timedelta(hours=24)
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 (m := _GELADEN.search(z)):
geladen[m.group(1)] = ts.isoformat()
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))
je_absender[wer] += 1
je_tag[ts.date().isoformat()][wer] += 1
if ts >= grenze_24h:
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
return {
"tage": TAGE,
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
for h in range(24)],
"zuletzt_geladen": geladen,
}
def nutzung() -> dict:
"""Gecachte Auswertung der letzten TAGE Tage (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 = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
daten = werte_aus(_journal(seit), jetzt, _namen())
with _lock:
_cache.update(ts=time.time(), daten=daten)
return daten
File diff suppressed because it is too large Load Diff
+1 -1
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
-155
View File
@@ -1,155 +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, 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."),
}
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
"Das Steuerpult ist wieder da.")
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
"Der Modell-Gateway läuft wieder.")
class _Watch:
__slots__ = ("alert_ts", "alerted", "fails")
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)
+75 -27
View File
@@ -8,8 +8,10 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
import glob
import os
import re
import subprocess
import threading
import time
import psutil
from config import MODELS_DIR
@@ -126,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
def get_engine_version() -> dict:
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
git_info = get_git_info(engine_path)
if git_info:
return {**git_info, "type": "git"}
candidates = [
os.path.join(engine_path, "llama-server"),
os.path.join(engine_path, "bin", "llama-server"),
"/usr/local/bin/llama-server",
"/usr/bin/llama-server",
"llama-server"
]
for binary in candidates:
if binary != "llama-server" and not os.path.exists(binary):
continue
try:
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
output = (res.stdout or "").strip() or (res.stderr or "").strip()
if output:
lines = output.splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
binary = os.path.join(engine_path, "llama-server")
if not os.path.exists(binary):
return {"type": "unknown"}
try:
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
text = (res.stdout or "") + (res.stderr or "")
except Exception:
return {"type": "unknown"}
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
return {"type": "binary", "version_text": zeile or "unbekannt"}
_VERSION_CACHE = {"ts": 0.0, "data": {}}
@@ -197,5 +191,59 @@ def system_status() -> dict:
"gpu": _gpu_sysfs(),
"temp": _temps(),
"disk": disk,
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
"uptime_s": _uptime_s(),
"versions": check_versions_cached(),
}
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
try:
return int(time.time() - psutil.boot_time())
except Exception:
return None
+233
View File
@@ -0,0 +1,233 @@
"""
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
"""
import json
import os
import re
import threading
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
_BAUSTEIN_IM_TEXT = (
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
)
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
_EINTRAG = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
_UPDATE_MELDUNG = re.compile(
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
r"|^Auto-Update abgebrochen|^Sonntags-Update")
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
_EINORDNUNG = (
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
re.IGNORECASE)),
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
)
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
_cache_lock = threading.Lock()
_cache: dict = {"schluessel": None, "laeufe": []}
# --- Protokoll lesen ----------------------------------------------------------------
def eintraege(text: str) -> list[tuple[datetime, str]]:
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
ergebnis: list[tuple[datetime, str]] = []
for zeile in text.splitlines():
if m := _EINTRAG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
except ValueError:
continue
ergebnis.append((zeit, m.group(2)))
elif ergebnis:
zeit, bisher = ergebnis[-1]
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
return ergebnis
def _einordnen(text: str) -> str:
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
def _baustein_aus(text: str) -> str:
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
def _lesbar(text: str) -> str:
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
def _zeile(baustein: str, text: str) -> dict:
ergebnis = _einordnen(text)
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
zeilen: list[dict] = []
if zusammenfassung:
for z in zusammenfassung.splitlines()[1:]:
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
for _, text in gruppe:
erste = text.splitlines()[0] if text else ""
if _NEUSTART.search(erste):
zeilen.append(_zeile("Neustart", erste))
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
zeilen.append(_zeile("Update-Lauf", erste))
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
arten = {z["ergebnis"] for z in zeilen}
if stufe == "rot":
titel = "Fehler"
elif stufe == "gelb":
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
elif stufe == "gruen":
titel = "Eingespielt"
elif stufe == "info":
titel = "Neu gestartet"
else:
titel = "Alles aktuell"
start, ende = gruppe[0][0], gruppe[-1][0]
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
return {"start": start.isoformat(), "ende": ende.isoformat(),
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
"stufe": stufe, "titel": titel, "zeilen": zeilen}
def laeufe_aus(text: str) -> list[dict]:
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
relevant = [(z, t) for z, t in eintraege(text)
if _ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
or _NEUSTART.search(t.splitlines()[0] if t else "")]
gruppen: list[list[tuple[datetime, str]]] = []
for eintrag in relevant:
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
gruppen[-1].append(eintrag)
else:
gruppen.append([eintrag])
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
return list(reversed(laeufe))
def _protokoll_ende() -> str:
try:
with NOTIFY_LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - LESE_MAX))
roh = f.read()
except OSError:
return ""
text = roh.decode("utf-8", errors="replace")
if groesse > LESE_MAX:
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
return text
def laeufe(anzahl: int = 8) -> list[dict]:
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
try:
st = NOTIFY_LOG.stat()
schluessel = (st.st_mtime_ns, st.st_size)
except OSError:
return []
with _cache_lock:
if _cache["schluessel"] != schluessel:
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
_cache["schluessel"] = schluessel
return _cache["laeufe"][:anzahl]
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
def _pins_lesen() -> dict:
try:
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def festgehalten() -> list[dict]:
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
ergebnis = []
for baustein, eintrag in _pins_lesen().items():
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
continue
datum = str(eintrag.get("datum") or "")
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
"version": str(eintrag.get("version") or "?"), "seit": seit,
"grund": _lesbar(str(eintrag.get("grund") or ""))})
return ergebnis
def freigeben(baustein: str) -> bool:
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
daten = _pins_lesen()
if baustein not in daten:
return False
daten.pop(baustein)
tmp = PINS_FILE.with_suffix(".tmp")
try:
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, PINS_FILE)
except OSError:
return False
return True
+16 -30
View File
@@ -11,14 +11,14 @@ Zwei Sichten auf dieselben Messungen:
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es
gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket.
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom
nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client
nötig.
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
"""
@@ -34,11 +34,11 @@ _STAGES: dict[str, deque] = {}
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None}
def record_stage(stage: str, ms: float) -> None:
@@ -53,8 +53,8 @@ def record_stage(stage: str, ms: float) -> None:
def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken,
damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED:
return
with _LOCK:
@@ -71,17 +71,6 @@ def _take_stt(max_age: float = 20.0) -> float | None:
return None
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
Turns) und frisch ist."""
with _LOCK:
v = _PARKED.get("retrieve")
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
_PARKED["retrieve"] = None
return round(v[0], 1)
return None
class Timer:
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
@@ -102,8 +91,7 @@ class TurnTrace:
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen."""
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
self.id = uuid.uuid4().hex[:8]
@@ -113,7 +101,7 @@ class TurnTrace:
self.session_id = (session_id or "")[:24]
self.kind = kind
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT)
self.had_images = False
self.error: str | None = None
@@ -130,7 +118,7 @@ class TurnTrace:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms)
@@ -138,7 +126,6 @@ class TurnTrace:
def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0
stt = _take_stt() # rollend bereits in /voice/stt erfasst
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = {
@@ -151,7 +138,6 @@ class TurnTrace:
"vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"mem0_ms": mem0,
"total_ms": round(total, 1),
"error": self.error,
}
+572
View File
@@ -0,0 +1,572 @@
"""
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
gesehen. Dieser Wächter schaut deshalb breiter hin:
• Dienste systemd-Zustand der Kern-Dienste (System + User)
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
• Platte Füllstand des Modell-Laufwerks
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
zusätzlich an Telegram und in Lucys Briefkasten.
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
zum Ablauf.
"""
import asyncio
import json
import logging
import os
import re
import sqlite3
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import httpx
import psutil
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap, maintenance, update_verlauf
log = logging.getLogger(__name__)
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
VERLAUF_MAX = 200
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
DIENSTE: dict[str, tuple[bool, bool, str]] = {
"llama-swap": (True, True, "Motor (llama-swap)"),
"hermes-gateway": (False, True, "Hermes"),
"mc2-gateway": (False, True, "Modell-Gateway"),
"mission-control-2": (False, True, "MC2"),
"voice-service": (False, False, "Hör-Dienst"),
"lucy-stimme": (False, False, "Lucys Stimme"),
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
}
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"projekte-sync": ("Projekte-Abgleich", False),
"mc2-backup": ("Sicherung", True),
"mc2-radar": ("Modell-Radar", False),
}
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
# Prozesse, an denen ein laufendes Update zu erkennen ist.
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
@dataclass
class Befund:
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
id: str
stufe: str # "rot" | "gelb"
titel: str
text: str
quelle: str
aktionen: list[dict] = field(default_factory=list)
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
def _aktion(aid: str, label: str, **extra: str) -> dict:
return {"id": aid, "label": label, **extra}
# --- Rohdaten -------------------------------------------------------------------
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
cmd = ["systemctl"] if system else ["systemctl", "--user"]
cmd += ["show", name, "-p",
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp"]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
except Exception:
return {}
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
try:
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
except Exception:
return ""
return waehle_fehlerzeile(zeilen)
def waehle_fehlerzeile(zeilen: list[str]) -> str:
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
stufen = (
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
)
for muster in stufen:
for zeile in reversed(zeilen):
if muster.search(zeile) and not rahmen.search(zeile):
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
return ""
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
except Exception:
return False
def _update_laeuft() -> bool:
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
try:
for p in psutil.process_iter(["cmdline"]):
cmd = " ".join(p.info.get("cmdline") or [])
if cmd and _UPDATE_PROZESSE.search(cmd):
return True
except Exception:
return False
return False
# --- Prüfungen ------------------------------------------------------------------
def pruefe_dienste() -> list[Befund]:
befunde: list[Befund] = []
for name, (system, wichtig, anzeige) in DIENSTE.items():
z = _systemctl_show(name, system)
if not z or z.get("LoadState") in ("not-found", ""):
continue # nicht installiert oder kein systemd (Dev)
zustand = z.get("ActiveState", "")
if zustand in ("active", "activating", "reloading", "deactivating"):
continue
stufe = "rot" if wichtig else "gelb"
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
if zustand == "failed":
befunde.append(Befund(
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
quelle=name, aktionen=aktionen, auto="neustart"))
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
befunde.append(Befund(
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
return befunde
def pruefe_timer_dienste() -> list[Befund]:
befunde: list[Befund] = []
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
z = _systemctl_show(name, False)
if not z or z.get("LoadState") in ("not-found", ""):
continue
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
continue
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
befunde.append(Befund(
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
sofort=True))
return befunde
def _hermes_jobs() -> list[dict]:
try:
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
if isinstance(jobs, dict):
jobs = list(jobs.values())
return [j for j in jobs if isinstance(j, dict)]
def _letzter_lauf(job_id: str) -> dict | None:
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
if not HERMES_EXEC_DB.exists():
return None
try:
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
try:
row = con.execute(
"select status, started_at, finished_at, error from executions "
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None
if not row:
return None
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
try:
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
except (TypeError, ValueError):
return 0, ""
marke = f"[cron_{job_id}_{tag}_"
treffer = [z for z in zeilen if marke in z and "returned error" in z]
if not treffer:
return 0, ""
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
beispiel = ""
if m:
beispiel = f"{m.group(1)}: {m.group(2)}"
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
beispiel = f"{m.group(1)}: {fm.group(1)}"
return len(treffer), beispiel[:220]
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
try:
with HERMES_ERRORS_LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - max_bytes))
return f.read().decode("utf-8", "replace").splitlines()
except OSError:
return []
def pruefe_hermes_jobs() -> list[Befund]:
befunde: list[Befund] = []
jobs = _hermes_jobs()
if not jobs:
return befunde
log_zeilen = _errors_log_ende()
jetzt = time.time()
for job in jobs:
if not job.get("enabled", True):
continue
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
wichtig = "update" in name.lower()
status = job.get("last_status")
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
befunde.append(Befund(
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
titel=f"Job „{name}“ ist fehlgeschlagen",
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
quelle=f"hermes-cron:{jid}",
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
_aktion("protokoll", "Protokoll", job=jid)],
sofort=True))
continue
if job.get("no_agent"):
continue # Skript-Jobs haben keine Werkzeuge
lauf = _letzter_lauf(jid)
if not lauf or not lauf.get("started_at"):
continue
try:
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
except ValueError:
continue
if alter > 26 * 3600:
continue # nur der aktuelle Lauf zählt
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
if anzahl:
befunde.append(Befund(
id=f"job:{jid}:werkzeug", stufe="gelb",
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
quelle=f"hermes-cron:{jid}",
aktionen=[_aktion("protokoll", "Protokoll", job=jid)],
sofort=True))
return befunde
def _hermes_kopf() -> dict[str, str]:
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
def pruefe_kern() -> list[Befund]:
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
engine_ok = llamaswap.engine_reachable()
proben.append(("kern:engine", "Der Motor antwortet nicht",
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
if engine_ok:
st = llamaswap.brain_status()
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok))
proben.append(("kern:hermes", "Hermes antwortet nicht",
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
proben.append(("kern:hoeren", "Der Hör-Dienst antwortet nicht",
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
if WATCH_MC2:
proben.append(("kern:mc2", "MC2 antwortet nicht",
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
for (i, t, x, ok) in proben if not ok]
def pruefe_platte() -> list[Befund]:
try:
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
except Exception:
return []
if pct >= DISK_ROT_PCT:
stufe = "rot"
elif pct >= DISK_GELB_PCT:
stufe = "gelb"
else:
return []
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
quelle="platte", sofort=True)]
def pruefe_festgehalten() -> list[Befund]:
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
titel=f"{p['name']} bekommt keine Updates mehr",
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
quelle="updates", sofort=True,
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
for p in update_verlauf.festgehalten()]
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten)
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
_lock = threading.Lock()
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
def _lade() -> None:
global _stand
try:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
if isinstance(daten.get("hinweise"), dict):
_stand = {**_stand, **daten}
except (OSError, ValueError):
pass
def _speichere() -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
del _stand["verlauf"][:-VERLAUF_MAX]
def _telegram(betreff: str, text: str) -> None:
if TROCKEN:
log.info("waechter (trocken): würde melden %s %s", betreff, text)
return
announce.add(text, subject=betreff, source="waechter")
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
_stand["auto"][schluessel] = versuche
return len(versuche) < AUTO_MAX_PRO_STUNDE
def _selbst_beheben(b: Befund, jetzt: float) -> None:
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
return
if TROCKEN:
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
_stand["auto"][b.id].append(jetzt)
return
ergebnis = maintenance.restart_service(b.quelle)
_stand["auto"][b.id].append(jetzt)
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
def takt() -> None:
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
jetzt = time.time()
update = _update_laeuft()
befunde: list[Befund] = []
for pruefung in PRUEFUNGEN:
if update and pruefung in (pruefe_dienste, pruefe_kern):
continue # während eines Updates sind Neustarts normal
try:
befunde += pruefung()
except Exception:
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
with _lock:
hinweise: dict = _stand["hinweise"]
kandidaten: dict = _stand["kandidaten"]
aktuell = {b.id for b in befunde}
for b in befunde:
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
k["takte"] += 1
if not update and b.auto:
_selbst_beheben(b, jetzt)
if not (b.sofort or k["takte"] >= FAIL_AFTER):
continue
h = hinweise.get(b.id)
neu = h is None
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
hinweise[b.id] = h
if neu:
_verlauf("neu", b.id, b.titel)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: "
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
h["gemeldet"] = jetzt
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
for hid in list(hinweise):
if hid in aktuell:
continue
if update and hid.startswith(("dienst:", "kern:")):
continue
h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid))
if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
for kid in list(kandidaten):
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
kandidaten.pop(kid)
_stand["stand"] = jetzt
_stand["update_laeuft"] = update
_speichere()
async def waechter_loop() -> None:
"""Endlos-Schleife im mc2-steward."""
_lade()
await asyncio.sleep(START_DELAY)
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
while True:
try:
await asyncio.to_thread(takt)
except Exception:
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
def lese_stand() -> dict:
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
try:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
hinweise = sorted((daten.get("hinweise") or {}).values(),
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
for h in hinweise:
h.pop("gemeldet", None)
return {
"hinweise": hinweise,
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
"stand": daten.get("stand"),
"update_laeuft": bool(daten.get("update_laeuft")),
"aktiv": bool(daten),
}
def _job_protokoll(job_id: str) -> dict:
marke = f"[cron_{job_id}_"
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
stand = lese_stand()
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
if hinweis is None:
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
if aktion is None:
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
if aktion_id == "neustart":
return maintenance.restart_service(aktion["dienst"])
if aktion_id == "protokoll":
if aktion.get("job"):
return _job_protokoll(aktion["job"])
return maintenance.logs(aktion["dienst"], lines=120)
if aktion_id == "freigeben":
if not update_verlauf.freigeben(aktion["baustein"]):
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
if aktion_id == "job-wiederholen":
try:
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
capture_output=True, text=True, timeout=30)
return {"ok": r.returncode == 0,
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
except Exception as e:
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
+99
View File
@@ -0,0 +1,99 @@
"""
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
Anfragen). Alles nur lesend.
"""
import json
import os
import subprocess
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from services import modell_nutzung, waechter
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar"}
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
def _parse_iso(wert) -> datetime | None:
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
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)
def _timer() -> list[dict]:
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
try:
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
capture_output=True, text=True, timeout=10).stdout
daten = json.loads(out or "[]")
except Exception:
return []
ergebnis = []
for t in daten if isinstance(daten, list) else []:
name = TIMER.get(t.get("unit", ""))
if not name:
continue
def _us(v):
try:
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
except (TypeError, ValueError):
return None
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
return ergebnis
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
if not anfragen:
return None
erste = next((s["stunde"] for s in stunden
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
"status": "erledigt"}
def flugplan() -> dict:
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
jetzt = datetime.now(LOCAL_TZ)
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
gelaufen: list[dict] = []
geplant: list[dict] = []
for job in waechter._hermes_jobs():
if not job.get("enabled", True):
continue
name = str(job.get("name", "Job"))
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
if letzter and letzter >= heute_start:
ok = job.get("last_status") in (None, "ok", "success")
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
"status": "erledigt" if ok else "fehler"})
if naechster and naechster > jetzt:
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
for t in _timer():
if t["letzter"] and t["letzter"] >= heute_start:
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
if t["naechster"] and t["naechster"] > jetzt:
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
gelaufen.append(nq)
def _aus(eintraege: list[dict]) -> list[dict]:
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
grenze = jetzt + timedelta(days=8)
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
+12 -14
View File
@@ -1,24 +1,27 @@
"""
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
Bisher hingen Re-Warm-Wächter und Health-Wächter am
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
Mini-Dienst (mc2-steward.service, Restart=always):
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
In-Process-Nudge aus llamaswap.write_config)
sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED)
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
"""
@@ -28,8 +31,7 @@ import logging
import os
from config import CONFIG_PATH
from services import memory as memory_svc
from services import sentry, warmer
from services import waechter, warmer
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
@@ -64,12 +66,8 @@ async def main() -> None:
tasks.append(asyncio.create_task(config_watch()))
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
warmer.INTERVAL, CONFIG_WATCH_S)
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_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)
if waechter.ENABLED:
tasks.append(asyncio.create_task(waechter.waechter_loop()))
if not tasks:
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
return
+7
View File
@@ -0,0 +1,7 @@
"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) —
für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+33
View File
@@ -0,0 +1,33 @@
"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
from routers import hermes_ui
def test_umleitung_auf_login_bekommt_praefix():
assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
def test_fremde_oder_schon_praefixierte_ziele_bleiben():
assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
def test_anmeldeseite_schickt_an_praefixierten_pfad():
html = (b'<html><head><title>Login</title></head><body>'
b'<form action="/auth/password-login" method="post"></form>'
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
b'</body></html>')
neu = hermes_ui.praefixiere_html(html)
assert b'action="/hermes-ui/auth/password-login"' in neu
assert b'fetch("/hermes-ui/auth/password-login"' in neu
assert b'"/hermes-ui/login?x=1"' in neu
assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
def test_andere_absolute_pfade_bleiben_unberuehrt():
html = b'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
neu = hermes_ui.praefixiere_html(html)
assert b'href="/assets/x.js"' in neu
assert b'href="/authors"' in neu
+45
View File
@@ -0,0 +1,45 @@
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026)."""
from datetime import datetime
from services import modell_nutzung
ZEILEN = [
('2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
'"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s'),
('2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
'"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s'),
('2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
'"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s'),
('2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
'"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms'),
"2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] <Qwen3.8-27B> Health check passed on http://localhost:5805/health",
('2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
'"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s'),
]
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
def test_zaehlt_nur_chat_anfragen_je_absender():
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
assert d["absender"] == [
{"name": "NerdQuiz auf Arcane", "anfragen": 3},
{"name": "Lucy und OpenChamber über MC2", "anfragen": 1},
]
def test_letzte_24h_je_stunde_und_zuletzt_geladen():
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]}
assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb
assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1}
assert len(d["letzte_24h"]) == 24
assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59")
def test_unbekannte_ip_bleibt_als_ip_stehen():
zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 '
'"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s')
d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}]
+636
View File
@@ -0,0 +1,636 @@
"""Tests für das Modell-Radar: reine Logik (Zeitfenster, Wochengrenze, Filter, Speicher-Rechnung,
Zustandsübergänge) und die Urteile und Programmieraufgaben des Prüfstands — ohne Netz und ohne Box.
Zahlen wie auf der Box am 23.09.2026 (Ornith 21,7 GB + mmproj, Flash-Next UD-IQ3_XXS 82 GB)."""
import json
import os
import struct
import time
import zlib
from datetime import date, datetime
import pytest
from services import radar
BERLIN = radar.LOCAL_TZ
def _zeit(text: str) -> datetime:
return datetime.fromisoformat(text).replace(tzinfo=BERLIN)
def _kandidat(kid: str, rolle: str = "hirn", status: str = "neu", quelle: str = "watchlist", rang: int = 0,
**extra) -> dict:
return {"id": kid, "name": kid, "rolle": rolle, "repo": f"org/{kid}-GGUF", "quant": "Q4_K_M", "quelle": quelle,
"rang": rang, "datei": f"{kid}-Q4_K_M.gguf", "dateien": [[f"{kid}-Q4_K_M.gguf", 100]],
"mmproj": ["mmproj-F16.gguf", 10], "draft": None, "flags": [], "groesse_gb": 21.7, "passt": True,
"eng": False, "ctx": 131072, "status": status, "begruendung": "", **extra}
@pytest.fixture
def zustand(monkeypatch, tmp_path):
"""Radar-Zustand und Radar-Ordner in einem Temp-Ordner statt unter /srv/models."""
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
(tmp_path / "radar").mkdir()
return tmp_path
# --- Zeitfenster und Wochengrenze ------------------------------------------------------------
def test_zeitfenster_nur_von_0030_bis_0230():
assert not radar.im_fenster(_zeit("2026-09-29T00:29:59"))
assert radar.im_fenster(_zeit("2026-09-29T00:30:00"))
assert radar.im_fenster(_zeit("2026-09-29T02:29:59"))
assert not radar.im_fenster(_zeit("2026-09-29T02:30:00"))
assert not radar.im_fenster(_zeit("2026-09-29T14:00:00"))
assert radar.fenster_ende(_zeit("2026-09-29T00:45:00")) == _zeit("2026-09-29T02:30:00")
def test_fensterende_am_umstellungstag_nie_nach_0300():
# 28.03.2027: 02:00 springt auf 03:00, 02:30 gibt es nicht. Lieber früher enden als in den NerdQuiz-Lauf.
ende = radar.fenster_ende(_zeit("2027-03-28T00:45:00"))
assert ende.timestamp() <= datetime(2027, 3, 28, 3, 0, tzinfo=BERLIN).timestamp()
def test_naechster_start_ist_die_naechste_nacht():
assert radar.naechster_start(_zeit("2026-09-29T00:10:00")) == _zeit("2026-09-29T00:30:00")
assert radar.naechster_start(_zeit("2026-09-29T01:00:00")) == _zeit("2026-09-30T00:30:00")
assert radar.naechster_start(_zeit("2026-09-29T14:00:00")) == _zeit("2026-09-30T00:30:00")
def test_wochengrenze_ein_neuer_kandidat_pro_woche():
stand = radar._leer()
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
"b": _kandidat("b")}
assert radar.wochen_frei_ab(stand, date(2026, 10, 3)) == date(2026, 10, 6)
assert radar.wochen_frei_ab(stand, date(2026, 10, 6)) is None
def test_plan_testet_nur_im_fenster_mit_freier_woche_und_setzt_angefangene_fort():
stand = radar._leer()
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
"b": _kandidat("b", rang=1)}
radar._ordne(stand)
assert stand["kandidaten"]["b"]["status"] == "wartet"
plan = radar.plane_test(stand, _zeit("2026-10-03T00:31:00"))
assert plan["kandidat"] is None and "06.10." in plan["grund"]
assert radar.plane_test(stand, _zeit("2026-10-06T00:31:00"))["kandidat"] == "b"
assert "außerhalb" in radar.plane_test(stand, _zeit("2026-10-06T14:00:00"))["grund"]
assert "Minuten" in radar.plane_test(stand, _zeit("2026-10-06T02:15:00"))["grund"]
# angefangen (z. B. Download unterbrochen): darf in der nächsten Nacht weiter, die Woche gilt ab dem 1. Start
stand["kandidaten"]["b"]["erster_start"] = _zeit("2026-10-06T00:31:00").timestamp()
assert radar.plane_test(stand, _zeit("2026-10-07T00:31:00"))["kandidat"] == "b"
def test_laufender_test_blockiert_zweiten_lauf_und_zeigt_sich(zustand):
stand = radar._leer()
stand["kandidaten"]["b"] = _kandidat("b", status="wartet", erster_start=time.time())
stand["lauf"] = {"pid": os.getpid(), "kandidat": "b", "schritt": "test", "seit": time.time(),
"frist": time.time() + 3600}
assert radar.plane_test(stand, _zeit("2026-10-06T00:40:00"))["grund"] == "es läuft schon ein Test"
radar._speichere(stand)
assert radar.uebersicht()["kandidaten"][0]["begruendung"].startswith("Läuft gerade seit")
stand["lauf"]["pid"] = 999_999_999 # Prozess tot → Lauf gilt als beendet
assert radar._aktiver_lauf(stand) is None
# --- Namen, Filter, Dateien, Speicher -----------------------------------------------------------
def test_namen_und_staemme():
assert radar.anzeigename("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF") == "Qwen3.6-35B-A3B"
assert radar.anzeigename("unsloth/Qwen3.8-Flash-Next-GGUF") == "Qwen3.8-Flash-Next"
assert radar.stamm("Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf") == "qwen3.8-flash-next"
assert radar.stamm("Qwen3.6-35B-A3B") == radar.stamm("unsloth/Qwen3.6-35B-A3B-GGUF")
assert radar.generation("Qwen3.8-27B") == ("qwen", 3.8)
assert radar.kandidat_id("hirn", "ornith-ai/Ornith-1.5-35B-A3B-GGUF", "Q4_K_M") == \
"hirn-ornith-ai-ornith-1-5-35b-a3b-gguf-q4-k-m"
def test_entdeckung_filtert_alte_kleine_bekannte_und_dichte_hirne():
heute = {"hirn": "Qwen3.6-35B-A3B", "coder": "Qwen3.8-27B"}
bekannt = {"qwen3.6-35b-a3b", "qwen3.8-27b"}
def fund(repo, params, aktiv=None, kuratiert=False):
return {"repo": repo, "params_b": params, "caps": {"active_b": aktiv}, "curated": kuratiert}
def grund(m, rolle):
return radar.entdeckung_ungeeignet(m, rolle, bekannt, heute)
assert "Katalog" in grund(fund("unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", 30, 3, kuratiert=True), "coder")
assert "auf der Box" in grund(fund("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF", 35, 3), "hirn")
assert "zu klein" in grund(fund("lmstudio-community/Qwen2.5-Coder-14B-Instruct-GGUF", 14), "coder")
assert "dichte" in grund(fund("bartowski/Hermes-3-Llama-3.1-70B-GGUF", 70), "hirn")
assert "ältere Generation" in grund(fund("bartowski/Qwen_Qwen3.5-35B-A3B-GGUF", 35, 3), "hirn")
assert grund(fund("unsloth/Qwen-AgentWorld-35B-A3B-GGUF", 35, 3), "hirn") is None
BAUM_FLASH = [
{"type": "directory", "path": "UD-IQ3_XXS"},
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf", "size": 10_000_000},
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00002-of-00003.gguf", "size": 49_570_000_000},
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00003-of-00003.gguf", "lfs": {"size": 32_380_000_000}},
{"path": "UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf", "size": 10_000_000},
{"path": "MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf", "size": 4_140_000_000},
{"path": "mmproj-BF16.gguf", "size": 910_000_000},
{"path": "mmproj-F16.gguf", "size": 900_000_000},
]
KOPF_FLASH = {"general.architecture": "qwen4exp", "qwen4exp.block_count": 48, "qwen4exp.attention.head_count": 24,
"qwen4exp.attention.head_count_kv": 2, "qwen4exp.attention.key_length": 256,
"qwen4exp.attention.value_length": 256, "qwen4exp.full_attention_interval": 4}
def test_dateiauswahl_nimmt_alle_teile_des_quants_und_das_f16_mmproj():
wahl = radar.waehle_dateien(BAUM_FLASH, "UD-IQ3_XXS")
assert [p.rsplit("-", 3)[-3] for p, _ in wahl["dateien"]] == ["00001", "00002", "00003"]
assert all(p.startswith("UD-IQ3_XXS/") for p, _ in wahl["dateien"])
assert wahl["mmproj"] == ["mmproj-F16.gguf", 900_000_000]
assert radar.waehle_dateien(BAUM_FLASH, "Q4_K_M") is None
assert radar.waehle_dateien(BAUM_FLASH, "Q8_0") is None # der MTP-Kopf ist kein Modell
ornith = [{"path": "Ornith-1.5-35B-Q4_K_M.gguf", "size": 21_710_000_000},
{"path": "Ornith-1.5-35B-Q8_0.gguf", "size": 37_800_000_000},
{"path": "mmproj-Ornith-1.5-35B-BF16.gguf", "size": 900_000_000}]
wahl = radar.waehle_dateien(ornith, "Q4_K_M")
assert wahl["dateien"] == [["Ornith-1.5-35B-Q4_K_M.gguf", 21_710_000_000]]
assert wahl["mmproj"][0] == "mmproj-Ornith-1.5-35B-BF16.gguf"
def _gguf_kopf_bytes(werte: dict) -> bytes:
"""Minimaler GGUF-Kopf wie von llama.cpp: Architektur-Schlüssel, danach der Tokenizer (abgeschnitten)."""
def s(text: str) -> bytes:
return struct.pack("<Q", len(text.encode())) + text.encode()
teile = [b"GGUF", struct.pack("<I", 3), struct.pack("<Q", 0), struct.pack("<Q", len(werte) + 1)]
for k, v in werte.items():
if isinstance(v, str):
teile += [s(k), struct.pack("<I", 8), s(v)]
elif isinstance(v, list):
teile += [s(k), struct.pack("<I", 9), struct.pack("<I", 5), struct.pack("<Q", len(v)),
struct.pack(f"<{len(v)}i", *v)]
else:
teile += [s(k), struct.pack("<I", 4), struct.pack("<I", v)]
teile += [s("tokenizer.ggml.tokens"), struct.pack("<I", 9)]
return b"".join(teile)
def test_kv_cache_aus_dem_gguf_kopf_kennt_hybrid_modelle():
daten = _gguf_kopf_bytes({"general.architecture": "qwen35moe", "qwen35moe.block_count": 41,
"qwen35moe.attention.head_count": 16, "qwen35moe.attention.head_count_kv": 2,
"qwen35moe.rope.dimension_sections": [11, 11, 10, 0],
"qwen35moe.attention.key_length": 256, "qwen35moe.attention.value_length": 256,
"qwen35moe.full_attention_interval": 4})
kopf = radar.gguf_kopf_aus_bytes(daten)
assert kopf["general.architecture"] == "qwen35moe" and kopf["qwen35moe.block_count"] == 41
# 11 von 41 Schichten mit KV-Cache × 2 KV-Köpfe × (256 + 256) × 1,0625 Byte (q8_0)
assert radar.kv_je_token_gb(kopf) == pytest.approx(11 * 2 * 512 * 1.0625 / 1e9)
assert radar.kv_je_token_gb(kopf) * 131072 == pytest.approx(1.57, abs=0.01)
ohne_hybrid = {k: v for k, v in kopf.items() if not k.endswith("full_attention_interval")}
assert radar.kv_je_token_gb(ohne_hybrid) == pytest.approx(41 * 2 * 512 * 1.0625 / 1e9)
assert radar.gguf_kopf_aus_bytes(b"kein gguf") is None
assert radar.gguf_kopf_aus_bytes(daten[:20]) is None
def test_speicher_passung_nach_der_115_gb_regel():
budget = radar.SPEICHER_GRENZE_GB - radar.WARMSET_GB
p = radar.speicher_passung("hirn", 22.6, 11968 / 1e9) # Ornith: bequem, voller Kontext
assert p["passt"] and not p["eng"] and p["ctx"] == 131072
p = radar.speicher_passung("coder", 82.9, 13056 / 1e9, eng_markiert=True) # Flash-Next: knapp
assert p["passt"] and p["eng"] and p["bedarf_gb"] <= budget
p = radar.speicher_passung("coder", 82.9, 52224 / 1e9) # großer KV-Cache → halber Kontext
assert p["passt"] and p["eng"] and p["ctx"] == 65536
p = radar.speicher_passung("coder", 94.0, 13056 / 1e9) # UD-IQ4_XS passt nicht
assert not p["passt"] and p["text"].startswith("Passt nicht")
p = radar.speicher_passung("hirn", 85.0, 1e-6) # Test ja, im Betrieb neben dem Coder knapp
assert p["passt"] and p["eng"] and "Im Betrieb" in p["text"]
assert radar.platte_nach_download(265, 1900, 82) == pytest.approx(18.26, abs=0.01)
def test_bewertung_ohne_netz(monkeypatch):
monkeypatch.setattr(radar, "_hf_baum", lambda repo: BAUM_FLASH)
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: KOPF_FLASH)
monkeypatch.setattr(radar, "engine_kennt", lambda arch: True)
fund = {"name": "Qwen3.8-Flash-Next", "rolle": "coder", "repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
"quant": "UD-IQ3_XXS", "eng": True, "quelle": "watchlist", "rang": 1}
k = radar._bewerte(fund)
assert k["tauglich"] and k["passt"] and k["eng"] and k["ctx"] == 131072
assert k["datei"] == "Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf"
assert k["groesse_gb"] == pytest.approx(82.9, abs=0.05)
assert k["begruendung"].startswith("Aus der Merkliste. Passt knapp")
monkeypatch.setattr(radar, "engine_kennt", lambda arch: False)
k = radar._bewerte(fund)
assert not k["tauglich"] and "qwen4exp" in k["begruendung"]
# Kopf nicht lesbar: die grobe Schätzung sagt „passt nicht“ — das darf nicht zum Verwerfen führen
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: None)
k = radar._bewerte(fund)
assert k["netzfehler"] and not k["tauglich"] and k["begruendung"].startswith("Speicherbedarf unklar")
def test_merkliste_im_repo():
funde, ok = radar._merkliste()
assert ok
nach_name = {f["name"]: f for f in funde}
assert nach_name["Ornith-1.5-35B-A3B"]["rolle"] == "hirn"
assert nach_name["Qwen3.8-Flash-Next"]["quant"] == "UD-IQ3_XXS" and nach_name["Qwen3.8-Flash-Next"]["eng"] is True
k2 = radar._bewerte(nach_name["K2-Horizon-MoVA-36B-A4B"]) # übersprungen, ganz ohne Netz
assert not k2["tauglich"] and k2["begruendung"].startswith("Übersprungen") and "llama.cpp" in k2["begruendung"]
# --- Zustandsübergänge ---------------------------------------------------------------------
def test_suche_uebernimmt_merkliste_und_laesst_geschichte_in_ruhe():
stand = radar._leer()
stand["kandidaten"] = {"alt": _kandidat("alt", status="durchgefallen", quelle="discover"),
"weg": _kandidat("weg", quelle="discover"),
"nein": _kandidat("nein", status="verworfen", verworfen_von="nutzer")}
bewertet = [
{**_kandidat("ornith", rang=0), "tauglich": True, "begruendung": "Aus der Merkliste. Passt."},
{**_kandidat("k2", rang=2), "tauglich": False, "passt": False,
"begruendung": "Übersprungen: läuft noch nicht im offiziellen llama.cpp."},
{**_kandidat("alt", quelle="discover"), "tauglich": True},
{**_kandidat("nein"), "tauglich": True},
{**_kandidat("klein", quelle="discover"), "tauglich": False, "begruendung": "Kein GGUF mit Q4_K_M im Repo."},
]
ergebnis = radar._suche_anwenden(stand, bewertet, {"watchlist": True, "discover": True}, 1000.0)
k = stand["kandidaten"]
assert k["ornith"]["status"] == "wartet" # Kopf der Warteschlange
assert k["k2"]["status"] == "verworfen" and k["k2"]["verworfen_von"] == "radar"
assert k["alt"]["status"] == "durchgefallen" # Geschichte bleibt
assert k["nein"]["status"] == "verworfen" and k["nein"]["verworfen_von"] == "nutzer"
assert "weg" not in k and "klein" not in k
assert "klein" in stand["abgelehnt"]
assert ergebnis["neu"] == ["ornith"] and stand["letzte_suche"] == 1000.0
stand["kandidaten"]["weg"] = _kandidat("weg", quelle="discover")
radar._suche_anwenden(stand, [], {"watchlist": True, "discover": False}, 2000.0) # Entdeckung offline
assert "weg" in stand["kandidaten"]
def test_bestanden_bleibt_liegen_durchgefallen_wird_geloescht(zustand):
stand = radar._leer()
for kid in ("gut", "schlecht"):
ordner = radar.RADAR_DIR / kid
ordner.mkdir()
(ordner / "m.gguf").write_bytes(b"x")
stand["kandidaten"][kid] = _kandidat(kid, status="wartet", ordner=str(ordner), erster_start=1.0)
test = radar._abschluss(stand, "gut", "bestanden", "Bestanden: versteht Bilder (heute nicht).",
werte={"tiefe": {"tg_tps": 95.0}}, vergleich={"Bilder": {"urteil": "besser"}})
radar._abschluss(stand, "schlecht", "durchgefallen", "Durchgefallen: Werkzeuge 3/6 (heute 6/6).")
assert stand["kandidaten"]["gut"]["status"] == "bestanden" and (radar.RADAR_DIR / "gut").exists()
assert "wartet auf deine Entscheidung" in stand["kandidaten"]["gut"]["begruendung"]
assert stand["kandidaten"]["schlecht"]["status"] == "durchgefallen" and not (radar.RADAR_DIR / "schlecht").exists()
assert [t["ergebnis"] for t in stand["tests"]] == ["bestanden", "durchgefallen"]
assert set(test) == set(radar.TEST_FELDER)
def test_abbrueche_zaehlen_nur_mit_schuld_des_kandidaten(zustand):
ordner = radar.RADAR_DIR / "zaeh"
ordner.mkdir()
stand = radar._leer()
stand["kandidaten"]["zaeh"] = _kandidat("zaeh", status="wartet", ordner=str(ordner), erster_start=1.0)
radar._abschluss(stand, "zaeh", "abgebrochen", "Port 5899 ist belegt.", zaehlt=False)
assert stand["kandidaten"]["zaeh"]["status"] == "wartet" and not stand["kandidaten"]["zaeh"].get("versuche")
for _ in range(radar.VERSUCHE_MAX):
radar._abschluss(stand, "zaeh", "abgebrochen", "Das Nachtfenster endete um 02:30, bevor der Test fertig war.")
k = stand["kandidaten"]["zaeh"]
assert k["status"] == "durchgefallen" and k["begruendung"].startswith(f"{radar.VERSUCHE_MAX}-mal abgebrochen")
assert not ordner.exists()
assert {t["ergebnis"] for t in stand["tests"]} == {"abgebrochen"}
def test_ohne_vergleichswerte_bleibt_der_kandidat_getestet(zustand):
stand = radar._leer()
stand["kandidaten"]["g"] = _kandidat("g", status="wartet", erster_start=1.0)
assert radar._abschluss(stand, "g", None, "", werte={"tiefe": {"tg_tps": 80.0}}) is None
assert stand["kandidaten"]["g"]["status"] == "getestet" and stand["kandidaten"]["g"]["messung"]
assert stand["tests"] == []
def test_verwerfen_und_uebernehmen_pruefen_den_status(zustand):
ordner = radar.RADAR_DIR / "b"
ordner.mkdir()
stand = radar._leer()
stand["kandidaten"] = {"b": _kandidat("b", status="bestanden", ordner=str(ordner)),
"u": _kandidat("u", status="uebernommen"), "n": _kandidat("n")}
radar._speichere(stand)
assert radar.uebernehmen("n")["status"] == 409 # nur bestandene
assert radar.uebernehmen("gibt-es-nicht")["status"] == 404
assert radar.verwerfen("u")["status"] == 409 # schon übernommen
antwort = radar.verwerfen("b")
assert antwort["ok"] and antwort["kandidat"]["status"] == "verworfen"
assert not ordner.exists()
assert radar._lade()["kandidaten"]["b"]["verworfen_von"] == "nutzer"
def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
stand = radar._leer()
stand["kandidaten"] = {"a": _kandidat("a", status="wartet", erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
"b": _kandidat("b", rang=1)}
radar._abschluss(stand, "a", "bestanden", "Bestanden: Prefill bei 13k 18 % schneller.",
zeit=_zeit("2026-09-29T01:40:00").timestamp())
radar._speichere(stand)
u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER)
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
assert u["letzter_test"].startswith("2026-09-29T01:40")
assert set(u["tests"][0]) == set(radar.TEST_FELDER)
def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tuple[list, dict]:
"""Bestandenen Kandidaten „o“ anlegen und llama-swap/Hirn-Wechsel durch Protokoll-Attrappen ersetzen."""
modelle = zustand / "models"
modelle.mkdir()
monkeypatch.setattr(radar, "MODELS_DIR", modelle)
ordner = radar.RADAR_DIR / "o"
ordner.mkdir()
(ordner / "o-Q4_K_M.gguf").write_bytes(b"x")
(ordner / "mmproj-F16.gguf").write_bytes(b"x")
stand = radar._leer()
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
radar._speichere(stand)
aufrufe, cfg = [], {"models": {}}
def eintragen(pfad, **kw):
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"]))
cfg["models"]["o"] = {"cmd": "vorlage"}
return "o"
from services import agent
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {rolle: alt})
monkeypatch.setattr(radar.llamaswap, "register_model", eintragen)
monkeypatch.setattr(radar.llamaswap, "read_config", lambda: cfg)
monkeypatch.setattr(radar.llamaswap, "write_config", lambda c: aufrufe.append(("schreiben",)))
monkeypatch.setattr(radar.llamaswap, "set_role", lambda mid, r: aufrufe.append(("rolle", mid, r)) or True)
monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True)
monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None)
monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True)
monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True})
monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf")
return aufrufe, cfg
def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, monkeypatch):
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "hirn",
{"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "ttl": 0})
(zustand / "warmset.conf").write_text('[Service]\nEnvironment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3.6-35B-A3B"\n',
encoding="utf-8")
antwort = radar.uebernehmen("o")
assert antwort["ok"] and antwort["modell_id"] == "o"
assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
befehl = str(cfg["models"]["o"]["cmd"])
assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" in befehl
assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
k = radar._lade()["kandidaten"]["o"]
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"]
# Der Steward hält danach das neue Hirn warm, nicht mehr das alte — ohne Handgriff.
assert 'MC_WARMSET=Qwen3-Embedding-0.6B o"' in (zustand / "warmset.conf").read_text(encoding="utf-8")
assert antwort["text"] is None and "Noch zu tun" not in k["begruendung"]
def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch):
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "coder",
{"name": "Qwen3.8-27B", "aliases": ["coder", "heavy"], "ttl": 5400})
antwort = radar.uebernehmen("o")
assert antwort["ok"] and antwort["text"] is None
assert aufrufe[2:] == [("rolle", "o", "coder"), ("dazu", "o", "heavy"), ("ttl", "o", 5400)]
assert "--parallel 1" in str(cfg["models"]["o"]["cmd"])
def test_betriebsbefehl_uebernimmt_die_getesteten_flags():
k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"])
befehl = radar.betriebs_befehl("coder", {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf",
"draft": None}, k)
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl
assert "--mmproj /srv/models/F-GGUF/mm.gguf" in befehl and "--load-mode none" in befehl
assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512")
assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
_kandidat("h"))
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
@pytest.fixture(scope="module")
def ps():
return radar._pruefstand()
BILD_GUT = {"direkt": 1.0, "gefunden": ["Wort ZEBRA", "Zahl 4711"], "agentisch": True}
def _werte(rolle: str, decode: float = 100.0, prefill: float = 13.0, werkzeuge: int = 6, code: int = 8,
bild: dict | None = None, deutsch: bool = True) -> dict:
werte = {"rolle": rolle, "tiefe": {"tg_tps": decode, "pp_s": prefill}, "werkzeuge": {"ok": werkzeuge, "von": 6},
"bild": bild}
if rolle == "hirn":
werte.update(deutsch={"ok": deutsch}, json={"ok": True})
else:
werte["code"] = {"ok": code, "von": 10}
return werte
def test_urteil_hirn_mit_bildern_besteht_ohne_verschlechterung(ps):
u = ps.urteil("hirn", _werte("hirn", decode=95.0, bild=BILD_GUT), _werte("hirn", decode=100.8))
assert u["ergebnis"] == "bestanden"
assert u["zusammenfassung"] == "Bestanden: versteht Bilder (heute nicht)."
assert u["vergleich"]["Decode bei 13k"]["urteil"] == "gleich" # 6 % liegt in der Toleranz
assert list(u["vergleich"]) == ["Decode bei 13k", "Prefill bei 13k", "Werkzeug-Aufrufe", "Deutsch", "JSON",
"Bilder"]
def test_urteil_hirn_faellt_bei_schwaeche_oder_ohne_vorteil_durch(ps):
basis = _werte("hirn", decode=100.8)
u = ps.urteil("hirn", _werte("hirn", werkzeuge=5, bild=BILD_GUT), basis)
assert u["ergebnis"] == "durchgefallen" and "Werkzeuge 5/6 (heute 6/6)" in u["zusammenfassung"]
u = ps.urteil("hirn", _werte("hirn", decode=70.0, bild=BILD_GUT), basis)
assert u["ergebnis"] == "durchgefallen" and "Decode bei 13k 70 statt 100,8 t/s" in u["zusammenfassung"]
u = ps.urteil("hirn", _werte("hirn", bild=BILD_GUT), _werte("hirn", bild=BILD_GUT))
assert u["zusammenfassung"] == "Durchgefallen: kein Vorteil gegenüber dem heutigen Modell."
u = ps.urteil("hirn", _werte("hirn", decode=130.0), basis) # schneller, aber blind
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
def test_urteil_coder_zaehlt_programmieraufgaben_und_bilder(ps):
basis = _werte("coder", decode=30.4, prefill=41.2, code=8, bild=BILD_GUT)
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=9, bild=BILD_GUT), basis)
assert u["ergebnis"] == "bestanden" and "Programmieraufgaben 9/10 statt 8/10" in u["zusammenfassung"]
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=7, bild=BILD_GUT), basis)
assert u["ergebnis"] == "durchgefallen" and "Programmieraufgaben 7/10 (heute 8/10)" in u["zusammenfassung"]
u = ps.urteil("coder", _werte("coder", code=9), basis) # der heutige Coder kann Bilder
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
REFERENZ = {
"palindrom": "def ist_palindrom(text):\n z = [c.lower() for c in text if c.isalnum()]\n return z == z[::-1]\n",
"roemisch": (
"def roemisch(zahl):\n"
" if not isinstance(zahl, int) or not 1 <= zahl <= 3999:\n"
" raise ValueError(zahl)\n"
" out = ''\n"
" for wert, zeichen in ((1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'),\n"
" (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')):\n"
" while zahl >= wert:\n"
" out += zeichen\n"
" zahl -= wert\n"
" return out\n"),
"intervalle": (
"def zusammenfassen(intervalle):\n"
" out = []\n"
" for a, b in sorted(intervalle):\n"
" if out and a <= out[-1][1]:\n"
" out[-1][1] = max(out[-1][1], b)\n"
" else:\n"
" out.append([a, b])\n"
" return out\n"),
"flach": (
"def flach(liste):\n"
" out = []\n"
" for x in liste:\n"
" out.extend(flach(x) if isinstance(x, list) else [x])\n"
" return out\n"),
"iban": (
"def iban_gueltig(iban):\n"
" s = iban.replace(' ', '').upper()\n"
" if not 15 <= len(s) <= 34 or not s.isalnum() or not s.isascii():\n"
" return False\n"
" return int(''.join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1\n"),
"lru": (
"from collections import OrderedDict\n"
"class LRUCache:\n"
" def __init__(self, kapazitaet):\n"
" self.k, self.d = kapazitaet, OrderedDict()\n"
" def get(self, schluessel):\n"
" if schluessel not in self.d:\n"
" return None\n"
" self.d.move_to_end(schluessel)\n"
" return self.d[schluessel]\n"
" def put(self, schluessel, wert):\n"
" self.d[schluessel] = wert\n"
" self.d.move_to_end(schluessel)\n"
" if len(self.d) > self.k:\n"
" self.d.popitem(last=False)\n"),
"klammern": (
"def klammern_ok(text):\n"
" paare, stapel = {')': '(', ']': '[', '}': '{'}, []\n"
" for c in text:\n"
" if c in '([{':\n"
" stapel.append(c)\n"
" elif c in paare and (not stapel or stapel.pop() != paare[c]):\n"
" return False\n"
" return not stapel\n"),
"wege": (
"import heapq\n"
"def kuerzeste_wege(graph, start):\n"
" dist, heap = {start: 0}, [(0, start)]\n"
" while heap:\n"
" d, u = heapq.heappop(heap)\n"
" if d > dist[u]:\n"
" continue\n"
" for v, w in graph.get(u, {}).items():\n"
" if v not in dist or d + w < dist[v]:\n"
" dist[v] = d + w\n"
" heapq.heappush(heap, (d + w, v))\n"
" return dist\n"),
"median": (
"def median(zahlen):\n"
" if not zahlen:\n"
" raise ValueError('leer')\n"
" s, n = sorted(zahlen), len(zahlen)\n"
" return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2\n"),
"csv": (
"def spaltensumme(csv_text, spalte):\n"
" zeilen = [z for z in csv_text.splitlines() if z.strip()]\n"
" kopf = zeilen[0].split(';')\n"
" if spalte not in kopf:\n"
" raise KeyError(spalte)\n"
" i, summe = kopf.index(spalte), 0.0\n"
" for z in zeilen[1:]:\n"
" zellen = z.split(';')\n"
" wert = zellen[i].strip() if i < len(zellen) else ''\n"
" if wert:\n"
" summe += float(wert.replace('.', '').replace(',', '.'))\n"
" return summe\n"),
}
def test_referenzloesungen_bestehen_alle_programmieraufgaben(ps):
assert {a["id"] for a in ps.AUFGABEN_CODE} == set(REFERENZ)
for aufgabe in ps.AUFGABEN_CODE:
ok, grund = ps.fuehre_aufgabe_aus(REFERENZ[aufgabe["id"]], aufgabe["tests"])
assert ok, f"{aufgabe['id']}: {grund}"
def test_falsche_verbotene_und_endlose_loesungen_fallen_durch(ps):
tests = next(a["tests"] for a in ps.AUFGABEN_CODE if a["id"] == "median")
ok, _ = ps.fuehre_aufgabe_aus("def median(z):\n z.sort()\n return z[len(z) // 2]\n", tests)
assert not ok
ok, grund = ps.fuehre_aufgabe_aus("import os\ndef median(z):\n return os.getpid()\n", tests)
assert not ok and grund.startswith("unerlaubter Aufruf")
ok, grund = ps.fuehre_aufgabe_aus("def median(z):\n while True:\n pass\n", tests, zeitlimit=2)
assert not ok and "Zeitlimit" in grund
def test_code_aus_antwort_nimmt_den_loesungsblock(ps):
antwort = "<think>kurz nachdenken</think>Hier:\n```python\ndef f():\n return 1\n```\nAufruf:\n```python\nf()\n```"
assert ps.code_aus_antwort(antwort) == "def f():\n return 1\n"
assert ps.code_aus_antwort("def g(): return 2") == "def g(): return 2"
def test_bild_ist_ein_gueltiges_png_mit_rotem_kreis(ps):
png = ps.bild_png()
assert png.startswith(b"\x89PNG\r\n\x1a\n")
breite, hoehe = struct.unpack(">II", png[16:24])
assert (breite, hoehe) == (448, 300)
laenge = struct.unpack(">I", png[33:37])[0]
roh = zlib.decompress(png[41:41 + laenge])
zeile = 1 + breite * 3
assert roh[95 * zeile + 1 + 80 * 3:95 * zeile + 1 + 81 * 3] == b"\xdc\x14\x14" # Mitte des Kreises
beschreibung = "Ein roter Kreis, ein blaues Quadrat, ein grünes Dreieck und darunter der Text ZEBRA 4711."
assert ps.bild_merkmale(beschreibung) == list(ps.BILD_MERKMALE)
assert ps.bild_data_url().startswith("data:image/png;base64,")
def test_werkzeug_treffer_liest_json_argumente(ps):
aufruf = [{"function": {"name": "send_telegram", "arguments": json.dumps({"text": "Prüfstand läuft."})}}]
assert ps.trifft(aufruf, [("send_telegram", r"pr(ü|ue)fstand")])
assert not ps.trifft(aufruf, [("terminal", r".*")])
assert len(ps.WERKZEUGE_HIRN) >= 90 # Lucy hat ~100 Werkzeuge
assert ps.tiefen_prompt("ab12").startswith("Messlauf ab12.")
def test_add_role_behaelt_die_uebrigen_aliase(monkeypatch):
"""add_role ohne Attrappe: coder bleibt, heavy wandert vom alten Coder herüber."""
from services import llamaswap
cfg = {"models": {"alt": {"cmd": "x", "aliases": ["coder", "heavy"]}, "neu": {"cmd": "y", "aliases": ["coder"]}}}
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
assert llamaswap.add_role("neu", "heavy") is True
assert cfg["models"]["neu"]["aliases"] == ["coder", "heavy"]
assert cfg["models"]["alt"]["aliases"] == ["coder"]
assert llamaswap.add_role("gibt-es-nicht", "heavy") is False
def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, monkeypatch):
"""Eine Baseline aus einem älteren Prüfstand ist nicht vergleichbar (23.09.: Ablenker geändert)."""
cache = tmp_path / "baseline.json"
cache.write_text(json.dumps({"hirn": {"kennung": "k", "version": ps.PRUEFSTAND_VERSION - 1,
"zeit": time.time(), "werte": {"alt": True}}}), encoding="utf-8")
gemessen = []
monkeypatch.setattr(ps, "pruefe", lambda *a, **kw: gemessen.append(1) or {"neu": True})
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
assert gemessen == [1]
# Gleiche Version und Kennung: aus dem Cache, keine neue Messung.
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
assert gemessen == [1]
def test_kein_ablenker_klingt_nach_plattenplatz(ps):
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
+134
View File
@@ -0,0 +1,134 @@
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
import json
from services import update_verlauf
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
PROTOKOLL = """\
2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken.
2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt.
2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026
• **Eine Nachricht** (Quelle, 21. August 2026)
Text der Nachricht.
2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft.
2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich:
• Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün.
• Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün.
• Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün.
2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt.
2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft.
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT.
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
• Router (llama-swap): aktuell (253).
• Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün.
• Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b.
2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand.
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
• Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün.
• Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT.
• Hermes-Agent: gepinnt, übersprungen.
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ). Sie ist ein paar Minuten weg.
2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.
2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.
• Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün.
• Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün.
2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
• Router (llama-swap): aktuell (256).
• Engine (llama.cpp): aktuell (11057).
• Hermes-Agent: aktuell.
"""
def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen():
laeufe = update_verlauf.laeufe_aus(PROTOKOLL)
# 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf.
assert [lauf["start"][:10] for lauf in laeufe] == [
"2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"]
def test_gruener_sonntag_mit_einer_zeile_je_baustein():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1]
assert lauf["anlass"] == "Updates am Sonntag"
assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt")
assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [
("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")]
assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün."
def test_rueckbau_und_festhalten_werden_gelb_und_lesbar():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09.
assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt")
zeilen = {z["baustein"]: z for z in lauf["zeilen"]}
assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt"
assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten."
assert zeilen["Hermes"]["ergebnis"] == "festgehalten"
assert zeilen["Neustart"]["ergebnis"] == "neustart"
assert zeilen["Neustart"]["text"] == "Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 )."
def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08.
assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler")
assert lauf["zeilen"][0]["baustein"] == "Motor"
assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588")
def test_lauf_von_hand_mit_neustart_danach():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags
assert lauf["anlass"] == "Update von Hand"
assert lauf["titel"] == "Eingespielt"
assert lauf["zeilen"][-1]["baustein"] == "Neustart"
assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün."
def test_alles_aktuell_ist_grau():
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")
def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
datei = tmp_path / "mc2-notify.log"
datei.write_text(PROTOKOLL, encoding="utf-8")
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
assert len(update_verlauf.laeufe(3)) == 3
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
assert update_verlauf.laeufe() == []
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
pins = tmp_path / "mc2-pins.json"
pins.write_text(json.dumps({
"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
"grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"},
"hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"},
"swap": {"pinned": False},
}), encoding="utf-8")
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
fest = update_verlauf.festgehalten()
assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")]
assert fest[0]["name"] == "Motor (llama.cpp)"
assert update_verlauf.freigeben("engine") is True
assert update_verlauf.freigeben("engine") is False
rest = json.loads(pins.read_text(encoding="utf-8"))
assert set(rest) == {"hermes", "swap"}
def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
from services import waechter
pins = tmp_path / "mc2-pins.json"
pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
"grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8")
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
befunde = waechter.pruefe_festgehalten()
assert len(befunde) == 1
b = befunde[0]
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
assert "Seit 13.09. auf 10819 festgehalten" in b.text
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
+89
View File
@@ -0,0 +1,89 @@
"""Tests für die reinen Auswertungen des Wächters — mit echten Zeilen von der Box (23.09.2026)."""
from services import waechter
PROJEKTE_SYNC_LOG = [
"2026-09-23 21:02:44 === Sync-Lauf (14 Repos gemeldet) ===",
"2026-09-23 21:02:45 = NerdQuiz-next: aktuell (a1b2c3d)",
"2026-09-23 21:02:46 ! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen — fatal: no such ref was fetched",
"2026-09-23 21:02:46 === Ende (Fehler: 1) ===",
]
ERRORS_LOG = [
('2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
'/tmp/news-text.md exists"}'),
('2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}'),
"2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None",
('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
]
def test_fehlerzeile_nimmt_die_letzte_fehlerzeile_ohne_zeitstempel():
zeile = waechter.waehle_fehlerzeile(PROJEKTE_SYNC_LOG)
assert zeile.startswith("! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen")
assert "2026-09-23" not in zeile
def test_fehlerzeile_uebergeht_systemd_rahmenzeilen():
"""Echtes Journal vom 23.09.: systemd schreibt NACH der Skriptzeile noch „Failed to start …“."""
zeilen = PROJEKTE_SYNC_LOG + [
"projekte-sync.service: Main process exited, code=exited, status=1/FAILURE",
"projekte-sync.service: Failed with result 'exit-code'.",
"Failed to start projekte-sync.service - Projekte-Sync — haelt ~/projekte mit Gitea deckungsgleich.",
]
assert waechter.waehle_fehlerzeile(zeilen).startswith("! TTT2-Toolbox_ClaudeEdition")
def test_fehlerzeile_leer_wenn_nichts_nach_fehler_aussieht():
assert waechter.waehle_fehlerzeile(["alles gut", "=== Ende (Fehler: 0) ==="]) == ""
def test_werkzeugfehler_zaehlt_nur_den_lauf_des_tages():
anzahl, beispiel = waechter.werkzeugfehler_im_lauf(
ERRORS_LOG, "195e479e8e30", "2026-09-23T07:00:17.675474+02:00")
assert anzahl == 2
assert beispiel.startswith("write_file: Refusing to overwrite /tmp/news-text.md")
def test_werkzeugfehler_null_fuer_anderen_job_oder_kaputtes_datum():
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "ca7d4ed207c2", "2026-09-23T08:00:00+02:00") == (0, "")
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "195e479e8e30", "kein-datum") == (0, "")
def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp_path):
"""Flankenlogik: ein kurzer Aussetzer (1 Takt) erzeugt keinen Hinweis, ein dauerhafter schon.
Rote Hinweise gehen an Telegram, Erledigtes verschwindet und landet im Verlauf."""
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff))
defekt = [True]
def pruefung():
if not defekt[0]:
return []
return [waechter.Befund(id="kern:engine", stufe="rot", titel="Der Motor antwortet nicht",
text="", quelle="engine")]
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,))
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
waechter.takt()
assert waechter._stand["hinweise"] == {} # erster Takt: nur Kandidat
waechter.takt()
assert "kern:engine" in waechter._stand["hinweise"]
assert meldungen == ["[Box-Problem]"]
defekt[0] = False
waechter.takt()
assert waechter._stand["hinweise"] == {}
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
arten = [v["art"] for v in waechter._stand["verlauf"]]
assert arten == ["neu", "erledigt"]
stand = waechter.lese_stand()
assert stand["aktiv"] is True and stand["hinweise"] == []
+1 -1
View File
@@ -28,7 +28,7 @@ hint=""
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
ctx="$(cat <<EOF
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weiterer Dienst: hermes-gateway :8642 (fuehrt sein Gedaechtnis selbst — es gibt KEINEN separaten Gedaechtnis-Dienst mehr). Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
+16
View File
@@ -67,6 +67,22 @@ jobs:
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
# reicht). Gemessen wird der frische Build, nicht das committete dist.
if [ -d "$d/dist/assets" ]; then
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
# falschen — das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
haupt="$d/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
fi
fi
done <<< "$pkg_dateien"
fi
+9 -1
View File
@@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null |
[ -n "$TOKEN" ] || exit 0
python3 - "$TOKEN" "$STATE" <<'PY'
import json, sys, urllib.request
import json, sys, urllib.request, subprocess, os
token, state_pfad = sys.argv[1], sys.argv[2]
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
@@ -79,6 +79,14 @@ if neu:
for full, sha, name in neu:
print(f"• {full} @ {sha} ({name})")
print(f" {WEB}/{full}/actions")
try:
subprocess.run([
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
"--title", f"CI rot: {full} @ {sha}",
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
], capture_output=True)
except Exception as e:
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
print("Nichts aus diesen Ständen ist fertig', solange die Ampel rot ist.")
with open(state_pfad, "w") as f:
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env bash
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
#
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
#
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
set -uo pipefail
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
API="${MC_API:-http://127.0.0.1:9001}"
SLUG="$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
status(){ # $1=state $2=detail
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, branch, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = p + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, p)
PY
}
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
status "laeuft" "Merge wird vorbereitet"
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
PRE="$(git rev-parse origin/main)"
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
cleanup_wt
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
git -C "$WT" merge --abort 2>/dev/null || true
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
WT2="/tmp/annahme-rebase-$SLUG"
git worktree remove --force "$WT2" 2>/dev/null || true
REBASED=""
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
rebase origin/main >>"$LOG" 2>&1; then
REBASED="$(git -C "$WT2" rev-parse HEAD)"
else
git -C "$WT2" rebase --abort 2>/dev/null || true
fi
git worktree remove --force "$WT2" 2>/dev/null || true
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
fi
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
if [ -n "${PYS// /}" ]; then
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
# shellcheck disable=SC2086
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
fi
status "laeuft" "Push nach main läuft"
PUSHED=0
for _ in 1 2 3; do
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
sleep 5
done
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
cleanup_wt
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
DEPLOY_OK=1
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
HEALTH=0
if [ "$DEPLOY_OK" = 1 ]; then
for _ in $(seq 1 18); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
sleep 5
done
fi
if [ "$HEALTH" = 1 ]; then
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "Deploy grün, Health-Check bestanden"
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
exit 0
fi
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
status "rollback" "Health rot — automatischer Revert läuft"
git fetch -q origin || true
cleanup_wt
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
cleanup_wt
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
R=0
for _ in $(seq 1 12); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
sleep 5
done
if [ "$R" = 1 ]; then
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
fi
cleanup_wt
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
exit 2
+62 -1
View File
@@ -123,7 +123,7 @@ check_hermes(){
done
if [ "$state" = "done" ]; then
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
return
fi
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
@@ -165,6 +165,63 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
}
# ── Neustart, wenn das OS einen verlangt ─────────────────────────────────────
# Kernel- und libc-Updates werden erst nach einem Neustart wirksam; bis dahin
# laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am
# 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da.
#
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
#
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
# startet systemd die User-Dienste nach einem Neustart NICHT — die Box kaeme ohne
# Steuerpult, Gateway und Waechter hoch, und niemand koennte sie aus der Ferne
# wieder anschalten. Im Zweifel lieber nicht neustarten.
#
# Abschalten: MC_AUTOUPDATE_REBOOT=0 in der Umgebung des Cron-Jobs.
reboot_wenn_noetig(){
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr "
" " ")"
# Sicherung 1: kommen die Dienste ohne Login von selbst wieder hoch?
# $USER ist in systemd-/Cron-Umgebungen NICHT gesetzt — mit set -u waere das ein
# sofortiger Abbruch des ganzen Update-Laufs. id -un funktioniert immer.
local nutzer; nutzer="$(id -un)"
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
return 0
fi
# Sicherung 2: kein halbfertiges Update mitten im Neustart abwuergen.
local laufend
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
if [ "${laufend:-0}" -gt 0 ]; then
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
return 0
fi
# Sicherung 3: die Dienste muessen fuer den Autostart vorgemerkt sein.
local fehlend=""
for dienst in mission-control-2 mc2-gateway mc2-steward; do
systemctl --user is-enabled "$dienst" >/dev/null 2>&1 || fehlend="$fehlend $dienst"
done
if [ -n "$fehlend" ]; then
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
return 0
fi
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
sleep 20 # der Meldung Zeit lassen, rauszugehen
sudo -n systemctl reboot || {
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
}
}
# ── Lauf ─────────────────────────────────────────────────────────────────────
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
pins_init
@@ -180,3 +237,7 @@ check_hermes
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
$(printf '• %s\n' "${SUMMARY[@]}")"
say "Fertig."
# Ganz zuletzt: der Neustart wuerde die Abschlussmeldung oben sonst verschlucken.
# Die Funktion meldet in jedem Fall selbst, ob sie neustartet oder warum nicht.
reboot_wenn_noetig
+8 -9
View File
@@ -3,8 +3,10 @@
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
#
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
# Inhalt: ~/.hermes (config.yaml, .env, plugins/) · /etc/llama-swap/config.yaml ·
# known-good/ (Versions-Manifest, s. u.)
# Das abgeloeste Gedaechtnis-Sidecar (/srv/models, Port 8765) faellt seit 27.08.2026 weg —
# sein Datenverzeichnis existiert nicht mehr (Hintergrund: docs/wissen/VERDIKTE.md).
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
#
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
@@ -20,16 +22,14 @@ RETAIN="${MC_BACKUP_RETAIN:-14}"
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
TS="$(date +%Y%m%d-%H%M%S)"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
mkdir -p "$STAGE/hermes" "$STAGE/llama-swap"
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
@@ -60,7 +60,7 @@ SRC="${MC2_SRC:-$HOME/mission-control-v2}"
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
# pip freeze je venv; das voice-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
"$1" -m pip freeze > "$2" 2>/dev/null \
@@ -68,7 +68,6 @@ freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
}
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
{
@@ -79,7 +78,7 @@ freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
echo "python-venvs :"
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
for P in "$SRC/backend/.venv/bin/python" "$HOME/.voice/venv/bin/python"; do
echo " $P$("$P" --version 2>&1 || echo 'fehlt')"
done
} > "$KG/versions.txt" || true
@@ -92,7 +91,7 @@ cat > "$STAGE/MANIFEST.txt" <<EOF
mc2-state backup
created : $TS
host : $(hostname)
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
inhalt : hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
EOF
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
#
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
set -uo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
cd "$SRC" || exit 0
git fetch -q --prune origin 2>/dev/null || exit 0
angenommen=0
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
BRANCH="${REF#origin/}"
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
[ "${AHEAD:-0}" -gt 0 ] || continue
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
import json, sys
try:
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
except Exception:
print("")
PY
)"
[ -z "$STATE" ] || continue
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
[ -n "$DIFF" ] || continue
OK=1
while IFS=$'\t' read -r st path _rest; do
[ -n "$st" ] || continue
case "$st" in A|M) ;; *) OK=0; break ;; esac
case "$path" in *.md) ;; *) OK=0; break ;; esac
done <<< "$DIFF"
[ "$OK" = 1 ] || continue
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
if bash "$RUNNER" "$BRANCH"; then
angenommen=$((angenommen+1))
else
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
fi
done
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
PORT="${BENCH_PORT:-5899}"
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
BASE="-c 65536 -ngl 999 -fa on --load-mode none --jinja --parallel 1"
run() { # $1=name $2=extra-flags
echo "=== $1 ==="
+1 -1
View File
@@ -13,7 +13,7 @@ PORT="${BENCH_PORT:-5899}"
CTX="${BENCH_CTX:-32768}"
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --load-mode none --jinja $EXTRA \
>/tmp/model-bench-server.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36] (Pfade unten anpassen).
Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout.
"""
import json
import os
import sys
import time
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
M = "/srv/models"
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_0.gguf"
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
RESULTS: list[dict] = []
def save() -> None:
with open(OUT, "w", encoding="utf-8") as f:
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
if with_hermes:
werte["hermes"] = ps.hermes_smoke(hermes_args)
RESULTS.append(werte)
save()
def candidate(label: str, model: str, extra: list[str], with_hermes: bool, mmproj: str | None = None) -> None:
ps.log(f"\n=== {label}")
ps.log(f" {os.path.basename(model)} {' '.join(extra)}")
try:
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
except ps.ServerFehler as e:
ps.log(f" {e}")
RESULTS.append({"label": label, "fehler": str(e)[:300]})
save()
return
try:
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
bild=bool(mmproj))
except Exception as e:
ps.log(f" FEHLER in Proben: {e}")
RESULTS.append({"label": label, "fehler": str(e)[:200]})
save()
finally:
ps.stoppe_server(server)
time.sleep(3)
def main() -> None:
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
if "baseline" in which:
ps.log("\n=== BASELINE fast (live, llama-swap)")
probes(ps.SWAP_URL, "fast", "fast-live", True, ["-m", "fast"])
ps.log("\n=== BASELINE coder (live, llama-swap)")
probes(ps.SWAP_URL, "coder", "coder-live", True, ["-m", "coder"], bild=True)
if "nemo" in which:
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO,
ps.draft_flags(NEMO_MTP, "draft-mtp", 3), True)
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
if "u38" in which:
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
if "u36" in which:
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
ps.log("\nPRUEFSTAND_DONE")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
set -uo pipefail
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
MC="$HOME/mission-control-v2"
VAULT="$HOME/wissens-vault"
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
cat "$MC/deploy/chef-gutachter-prompt.md"
echo
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
EVID="$(
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
git -C "$MC" fetch -q origin 2>/dev/null || true
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
echo
echo "### main-Commits der letzten 24 h:"
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
echo
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
echo
echo "### Ideen-Queue (natives Kanban, Stand jetzt — offene Ideen des Commanders):"
bash -lc 'hermes kanban stats' 2>/dev/null | head -12
echo
echo "### Ideen-Queue: zuletzt bewegte Aufgaben:"
bash -lc 'hermes kanban list --sort updated' 2>/dev/null | head -10
echo
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
| sort | uniq -c | sort -rn | head -6
echo
echo "### Aktivitäts-Insights (1 Tag):"
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
)"
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
print(out)
except Exception:
pass'
}
RICHTER="$CHEF"
VERDIKT="$(judge "$CHEF" 420 2600)"
if [ -z "${VERDIKT// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
fi
echo "$EVID"
echo
if [ -z "${VERDIKT// /}" ]; then
echo "## CHEF-VERDIKT: AUSGEFALLEN"
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
else
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
echo "$VERDIKT"
fi
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
if [ -n "${VERDIKT// /}" ]; then
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
"source": "chef-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
fi
-15
View File
@@ -1,15 +0,0 @@
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
Regeln:
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
die GLM-Vertretung).
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
von dir.
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# coder-vollkontext.sh — gibt dem Coder den ganzen Kontext (23.08.2026).
#
# WARUM: llama-swap startet den Coder mit `-c 131072 --parallel 2`. Das sind
# zwei Slots à 65.536 — und nur diese 65.536 stehen einer Sitzung zur Verfuegung.
# Der Coder hat aber genau EINEN Verbraucher: OpenChamber. Lucy benutzt `hermes`,
# der explore-Subagent auch, `review` benutzt `heavy`. Die zweite Bahn liegt brach.
#
# Mit `--parallel 1` bekommt eine Sitzung die vollen 131.072 — doppelt so viel
# Platz, halb so viel Komprimieren.
#
# ‼️ NICHT waehrend eines laufenden Auftrags ausfuehren: llama-swap laeuft mit
# --watch-config und laedt beim Speichern SOFORT neu. Das schneidet eine
# laufende Sitzung mitten im Satz ab.
#
# Aufruf: bash deploy/coder-vollkontext.sh (Trockenlauf, zeigt den Diff)
# bash deploy/coder-vollkontext.sh --ja (wendet an)
set -uo pipefail
CONF="${LLAMA_SWAP_CONF:-/etc/llama-swap/config.yaml}"
BLOCK="${CODER_BLOCK:- Qwen3.8-27B:}"
if [ ! -r "$CONF" ]; then echo "ABBRUCH: $CONF nicht lesbar"; exit 1; fi
TMP="$(mktemp)"
cp "$CONF" "$TMP"
python3 - "$TMP" "$BLOCK" <<'PY'
import io, re, sys
pfad, block = sys.argv[1], sys.argv[2]
z = io.open(pfad, encoding="utf-8").read().split("\n")
try:
start = next(i for i, l in enumerate(z) if l.startswith(block))
except StopIteration:
print("ABBRUCH: Block '%s' nicht gefunden" % block); sys.exit(1)
ende = next((i for i in range(start + 1, len(z)) if re.match(r"^ [A-Za-z]", z[i])), len(z))
# Streng auf den Coder-Block begrenzt — `fast`/`hermes` MUESSEN ihre zwei Slots
# behalten, dort greifen Lucy und der Explorer wirklich gleichzeitig zu.
n_par = n_ctx = 0
for i in range(start, ende):
if "--parallel 2" in z[i]:
z[i] = z[i].replace("--parallel 2", "--parallel 1"); n_par += 1
if z[i].strip() == "context: 65536":
z[i] = z[i].replace("65536", "131072"); n_ctx += 1
if n_par != 1 or n_ctx != 1:
print(f"ABBRUCH: unerwartet (parallel {n_par}x, context {n_ctx}x) — Konfiguration hat sich geaendert")
sys.exit(1)
io.open(pfad, "w", encoding="utf-8").write("\n".join(z))
PY
if [ $? -ne 0 ]; then rm -f "$TMP"; exit 1; fi
echo "=== Aenderung ==="
diff "$CONF" "$TMP"
echo
if [ "${1:-}" != "--ja" ]; then
echo "TROCKENLAUF — nichts geaendert. Anwenden mit: bash $0 --ja"
rm -f "$TMP"; exit 0
fi
SICHER="${CONF}.bak-$(date +%Y%m%d-%H%M%S)"
sudo -n cp "$CONF" "$SICHER" && echo "Sicherung: $SICHER"
sudo -n cp "$TMP" "$CONF" && echo "angewendet — llama-swap laedt durch --watch-config selbst neu"
rm -f "$TMP"
echo
echo "Warte auf den Neustart des Coders..."
sleep 8
curl -s -m 15 "http://127.0.0.1:8080/v1/models" >/dev/null 2>&1 \
&& echo "llama-swap antwortet wieder" \
|| echo "ACHTUNG: llama-swap antwortet nicht — Sicherung liegt unter $SICHER"
echo
echo "Danach in ~/.config/opencode/opencode.json auf dem PC nachziehen:"
echo " coder: limit.context 65536 -> 131072"
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# curator-archive-watchdog.sh
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
# Python-Skript ausführen
python3 "$PYTHON_SCRIPT"
-62
View File
@@ -1,62 +0,0 @@
#!/bin/bash
# curator-idle-watchdog.sh
# Prüft den Curator-Idle-Status und triggert Reaktivierung nach 14 Tagen
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/idle-watchdog-$(date +%Y%m%d).log"
THRESHOLD_DAYS=14
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Curator-Status auslesen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
exit 0
fi
# Letzten Lauf extrahieren (Format: "last run: 17d ago")
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Prüfen, ob "d ago" vorhanden ist
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen) → überspringe"
exit 0
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# Threshold prüfen
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage) → kein Trigger nötig"
exit 0
fi
# Reaktivierung triggern
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS) → triggere 'hermes curator run'"
if hermes curator run 2>&1 | tee -a "$LOG_FILE"; then
log "ERFOLG: Curator-Reaktivierung gestartet"
else
log "FEHLER: Curator-Reaktivierung fehlgeschlagen"
exit 1
fi
-90
View File
@@ -1,90 +0,0 @@
#!/bin/bash
# curator-monitor.sh
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
# GIBT AUS: PASS oder FAIL mit Detaillierung.
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
# EXIT 1 = FAIL (Curator >7 Tage idle) Monitoring-Skript erkannt Problem
#
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
THRESHOLD_DAYS=7
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# --- Argumente parsen ---
DRY_RUN=false
DRY_RUN_DAYS=""
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=true
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
DRY_RUN_DAYS="$2"
shift
else
DRY_RUN_DAYS=8 # Default-Testwert
fi
;;
esac
done
# --- LIVE-PRÜFUNG oder DRY-RUN ---
if [[ "$DRY_RUN" == "true" ]]; then
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
else
# Live-Status abfragen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
echo "FAIL: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
exit 0
fi
# Letzten Lauf extrahieren
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
echo "FAIL: Konnte 'last run' nicht extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Tage extrahieren
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
exit 0
fi
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
exit 0
else
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
exit 1
fi
-198
View File
@@ -1,198 +0,0 @@
#!/usr/bin/env python3
"""
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
und verschiebt in .archive/<Kategorie>/.
"""
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
def load_json(path: Path) -> dict:
"""JSON-Datei laden."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict) -> None:
"""JSON-Datei speichern (mit Pretty-Print)."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
"""Prüft, ob ein Skill älter als threshold_days ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
# ISO-Format mit optionaler Zeitzone
last_used_str = last_used.replace("Z", "+00:00")
try:
last_used_dt = datetime.fromisoformat(last_used_str)
except ValueError:
return False
now = datetime.now(timezone.utc)
delta = now - last_used_dt
return delta.days >= threshold_days
def is_excluded(skill_name: str, excluded: list) -> bool:
"""Prüft, ob ein Skill ausgeschlossen ist."""
return skill_name in excluded
def is_pinned(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill angepinnt ist."""
return skill_usage.get("pinned", False) is True
def get_skill_category(skill_name: str) -> str:
"""
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
"""
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
# 'apple' (in .archive/apple/) → 'apple'
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
return "unknown"
def ensure_archive_category(category: str) -> Path:
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
category_path = ARCHIVE_DIR / category
category_path.mkdir(parents=True, exist_ok=True)
return category_path
def move_skill_to_archive(skill_name: str, category: str) -> Path:
"""
Verschiebt ein Skill-Verzeichnis in das Archiv.
Gibt den neuen Pfad zurück.
"""
source = HERMES_SKILLS_DIR / skill_name
target_category = ensure_archive_category(category)
target = target_category / skill_name
if source.exists():
shutil.move(str(source), str(target))
return target
def archive_skill(skill_name: str, usage_data: dict) -> dict:
"""
Archiviert einen Skill:
- Setzt state auf 'archived'
- Setzt archived_at auf aktuelle Zeit
- Verschiebt das Verzeichnis nach .archive/
Gibt das aktualisierte Usage-Dict zurück.
"""
usage_data[skill_name]["state"] = "archived"
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
return usage_data
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
"""Protokolliert eine Archivierungsaktion."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
timestamp = datetime.now(timezone.utc).isoformat()
status = "SUCCESS" if success else "FAILED"
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_line)
def main() -> None:
"""Hauptfunktion: Archiviert stale Skills."""
# 1. Config laden
if not CURATOR_STATE_FILE.exists():
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
return
curator_state = load_json(CURATOR_STATE_FILE)
archivierung = curator_state.get("archivierung", {})
if not archivierung.get("enabled", False):
print("Archivierung ist nicht aktiviert.")
return
threshold_days = archivierung.get("threshold_days", 14)
excluded_skills = archivierung.get("excluded_skills", [])
# 2. Usage-Daten laden
if not USAGE_JSON_FILE.exists():
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
return
usage_data = load_json(USAGE_JSON_FILE)
# 3. Skills prüfen
archived_count = 0
skipped_count = 0
for skill_name, skill_usage in usage_data.items():
# Ausschlusskriterien prüfen
if is_excluded(skill_name, excluded_skills):
print(f"Übersprungen (Exkludiert): {skill_name}")
skipped_count += 1
continue
if is_pinned(skill_usage):
print(f"Übersprungen (Gepinnt): {skill_name}")
skipped_count += 1
continue
# Inaktivität prüfen
if not is_stale(skill_usage, threshold_days):
print(f"Übersprungen (Aktiv): {skill_name}")
skipped_count += 1
continue
# Archivierung durchführen
category = get_skill_category(skill_name) or "unknown"
print(f"Archiviere: {skill_name}{category}")
try:
# Usage aktualisieren
usage_data = archive_skill(skill_name, usage_data)
# Verzeichnis verschieben
move_skill_to_archive(skill_name, category)
# Erfolg protokollieren
log_archive_action(skill_name, category, True, "Skill archiviert")
archived_count += 1
except Exception as e:
log_archive_action(skill_name, category, False, str(e))
print(f"Fehler beim Archivieren von {skill_name}: {e}")
# 4. Usage-Daten speichern
save_json(USAGE_JSON_FILE, usage_data)
# 5. Archivierungs-Zeitstempel aktualisieren
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
save_json(CURATOR_STATE_FILE, curator_state)
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
if __name__ == "__main__":
main()
+70 -288
View File
@@ -1,298 +1,80 @@
#!/usr/bin/env bash
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
#
# Erstinstallation (einmalig):
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
# bash ~/mission-control-v2/deploy/deploy.sh
set -euo pipefail
#!/bin/bash
set -e
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
# ‼️ Das ganze Skript liegt in EINER Funktion — das ist Absicht, kein Stil.
# Bash liest ein Skript haeppchenweise WAEHREND es laeuft. `git pull` unten ersetzt
# genau diese Datei mitten im Lauf; danach liest bash am selben Byte-Offset im NEUEN
# Text weiter und fuehrt zerschnittene Zeilen aus. Live erlebt am 27.08.2026: der Pull
# brachte eine neue Fassung dieses Schrittes mit, ausgefuehrt wurde trotzdem die alte.
# Eine Funktion wird komplett geparst, bevor sie startet -> der Lauf bleibt in sich
# stimmig, und Aenderungen greifen ab dem naechsten Aufruf statt mittendrin.
main() {
cd "$SRC"
git fetch -q origin && git reset -q --hard origin/main
echo "Starte MC2 Deployment..."
# ★ Selbst-Reset-Falle (15.07. beim P1-Deploy live gebissen): der reset oben ersetzt auch
# DIESE Datei, und bash liest laufende Skripte häppchenweise von der Platte weiter — ab
# hier liefe ein Zeilen-Mix aus alter+neuer Version (beim P1-Deploy fielen so daemon-reload
# und der mc2-gateway-Start unter den Tisch). Darum: nach dem Reset genau EINMAL die
# frische Version neu ausführen; der zweite Durchlauf überspringt dank Env-Marke nichts.
if [ "${MC2_DEPLOY_REEXEC:-0}" != "1" ]; then
export MC2_DEPLOY_REEXEC=1
exec bash "$SRC/deploy/deploy.sh"
fi
# 1. Neuesten Code holen
git pull origin main
# venv + Abhängigkeiten
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
else
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
fi
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
echo "Aktualisiere Python Abhängigkeiten..."
backend/.venv/bin/pip install -r backend/requirements.txt
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen.
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
if [ -d "$HOME/.hermes" ]; then
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
"$HOME/.hermes/plugins/mc2-memory/"
fi
# systemd-USER-Units installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
# MC2-Diät (Hermes Desktop, 07/2026): das Hermes-Terminal-ttyd (:7681) und das standalone
# hermes-webui (nesquena, Karteileiche seit dem Built-in-UI-Umbau) sind entfallen — die
# Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/. Einmalige, idempotente Stilllegung:
systemctl --user disable --now hermes-terminal 2>/dev/null || true
systemctl --user disable --now hermes-webui 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-webui.service"
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet.
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
# MC2-Gateway (UMBAU v3 P1): der /v1-Datenpfad als eigener Prozess (Loopback :9010,
# Restart=always) — Lucys Hirn + Nacht-Autonomie überleben damit jeden MC2-Neustart.
cp "$SRC/deploy/mc2-gateway.service" "$HOME/.config/systemd/user/mc2-gateway.service"
# MC2-Steward (UMBAU v3 P2): Wächter-Loops (Re-Warm/Sentry/Dedupe) als eigener Dienst —
# überleben MC2-Neustarts und melden erstmals auch ein totes Steuerpult (Telegram).
cp "$SRC/deploy/mc2-steward.service" "$HOME/.config/systemd/user/mc2-steward.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Voice-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
# Wöchentliches Auto-Update (Router/Engine/Hermes, Rollback+Pin+Telegram) — Autonomie E2.
cp "$SRC/deploy/mc2-autoupdate.service" "$HOME/.config/systemd/user/mc2-autoupdate.service"
cp "$SRC/deploy/mc2-autoupdate.timer" "$HOME/.config/systemd/user/mc2-autoupdate.timer"
# Tägliche Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv, Alarm bei Rot) — Autonomie 0d.
cp "$SRC/deploy/mc2-selfsmoke.service" "$HOME/.config/systemd/user/mc2-selfsmoke.service"
cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.timer"
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
# Morgen-Digest (Nächtliche Telegram-Zusammenfassung, 26.07.2026): fasst alle Meldungen zwischen 0 und 7 Uhr zusammen.
cp "$SRC/deploy/mc2-morgen-digest.service" "$HOME/.config/systemd/user/mc2-morgen-digest.service"
cp "$SRC/deploy/mc2-morgen-digest.timer" "$HOME/.config/systemd/user/mc2-morgen-digest.timer"
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
cp "$SRC/deploy/radar-feed.sh" "$HOME/.hermes/scripts/radar-feed.sh"
# Selbstkritik-Cron (Faden 11b): Zwilling des Radars, Blick nach INNEN (Latenzen/Journal/Skills).
cp "$SRC/deploy/selbstkritik-feed.sh" "$HOME/.hermes/scripts/selbstkritik-feed.sh"
# Monats-Review-Wrapper (Cron 1. d. M. 09:00): Radar + Selbstkritik in EINEM Lauf.
cp "$SRC/deploy/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh"
# Traum-Cron (Dreaming, Phase 2): Blick auf das GELERNTE → Wissens-Vault. Bootstrappt ~/wissens-vault.
cp "$SRC/deploy/dreaming-feed.sh" "$HOME/.hermes/scripts/dreaming-feed.sh"
# Fremdblick-Helfer: Ein-Schuss-Zweitmeinung von einem anderen Modell (umgeht 64K-Delegations-Floor).
cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
# Worker-Helfer (Orchestrator): Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstück zu fremdblick).
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# Wrapper für nächtliche Crons, um Telegram-Spam in den Morgen-Digest zu leiten.
cp "$SRC/deploy/night-cron-wrapper.sh" "$HOME/.hermes/scripts/night-cron-wrapper.sh"
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
# Idle-Radar (S4 "Zuendung", 12.07.2026): die Box gibt sich nachts selbst Arbeit —
# Journal-Fehlermuster + Traum-Funde -> belegte rohe Ideen (triage) im nativen Kanban.
cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
# "Lucy kennt sich" (12.07.2026): Selbst-Inventur generiert den Steckbrief + bemerkt
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste +
# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent):
# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline;
# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert.
cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh"
cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh"
# Restore-Probe + Venv-Audit (17.07.2026, monatliche --no-agent-Crons): echte
# Wiederherstellungs-Drills (PBS-Canary + Tarball) und CVE-/Outdated-Audit der
# Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts.
cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh"
cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh"
# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen,
# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst.
cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh"
# Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke —
# legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope).
cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh"
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
# betrieb-Profil (Ideen-Queue Faden 7, 13.07.2026): Ops-/Mess-/Bench-Worker mit Box-Wissen
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
# IDE-Vorbereiter (20.07.2026): haertet ein Konzept (konzept-fliessband), legt das Repo an und
# fuellt es NUR mit Doku — der Commander baut dann in Zed. Sonderfall „IDEE PRUEFEN": nur Konzept,
# kein Geruest, kein Bau-Druck. Profil einmalig per `hermes profile create projektstart
# --clone-from werkstatt` angelegt (model coder→hermes, damit heavy warm bleibt).
[ -d "$HOME/.hermes/profiles/projektstart" ] && cp "$SRC/deploy/projektstart-SOUL.md" "$HOME/.hermes/profiles/projektstart/SOUL.md"
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
mkdir -p "$HOME/.hermes/skills/wartung"
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
# Orchestrator-Skill (Autonomie): Werkstatt verallgemeinert — zerlegen → an Worker delegieren → gaten.
mkdir -p "$HOME/.hermes/skills/orchestrator"
cp "$SRC/deploy/skills/orchestrator/SKILL.md" "$HOME/.hermes/skills/orchestrator/SKILL.md"
# PC-Pfad-Cache-Skill (Traum-Entwurf 09.07.2026, aufbereitet): Cache lesen → bei Miss pc_shell-Tool → merken.
mkdir -p "$HOME/.hermes/skills/pc-pfad-cache"
cp "$SRC/deploy/skills/pc-pfad-cache/SKILL.md" "$HOME/.hermes/skills/pc-pfad-cache/SKILL.md"
cp "$SRC/deploy/skills/pc-pfad-cache/pc_path_lookup.sh" "$HOME/.hermes/scripts/pc_path_lookup.sh"
chmod +x "$HOME/.hermes/scripts/pc_path_lookup.sh"
# Projekt-Start-Skill (Abloesung 13.07.2026): "erst denken, dann bauen" fuer neue Coding-Projekte
# in Hermes Desktop — Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md-Ritual, Kontext-Waechter.
# Portiert vom PC-Skill (F:\Coding Stuff\projekt-start), Zed-Verweise durch Hermes Desktop ersetzt.
mkdir -p "$HOME/.hermes/skills/projekt-start"
cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-start/SKILL.md"
# Konzept-Fliessband-Skill (14.07.2026): rohe Idee → wasserdichtes KONZEPT (kein Code), BEVOR
# gebaut wird. heavy (gpt-oss-120b) waehlt den Rollen-Cast, mehrere Denk-Runden schaerfen ein
# lebendes Dokument, ein Advocatus Diaboli (fremdblick/GLM) haertet bis wasserdicht (max 3 Runden).
# Behebt das delegate_task=coder-Loch: Konzept-Arbeit lief bisher stumm auf dem Code-Modell.
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
# ...auch in Profile spiegeln, die den Skill schon haben (projektstart = IDE-Vorbereiter
# nutzt ihn als Motor; Profile teilen KEINE Skills, Lehre 18.07.). Nur vorhandene updaten.
for _psd in "$HOME"/.hermes/profiles/*/skills/konzept-fliessband; do
[ -d "$_psd" ] && cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$_psd/SKILL.md"
done
# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der
# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile,
# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.).
for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do
[ -d "$(dirname "$_sd")" ] || continue
mkdir -p "$_sd/betrieb-playbook"
cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md"
done
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
# bestehende py_compile/dist-Gate. Die REGISTRIERUNG in ~/.hermes/config.yaml (hooks:)
# ist EINMALIG von Hand (config.yaml ist Zwei-Schreiber-sensibel, kein Deploy-Schreiber).
mkdir -p "$HOME/.hermes/agent-hooks"
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
done
# Worker-Profile lesen IHRE config.yaml, nicht die Top-Level — die worker-Hooks
# (box-steckbrief = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen dort registriert
# sein, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.). Idempotent + Backup je Profil.
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
done
# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode
# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard,
# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so
# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt.
python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$?
if [ "$_patch_rc" -eq 10 ]; then
# Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden.
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet."
elif [ "$_patch_rc" -ne 0 ]; then
echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!"
fi
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
# Hermes-Update mit → hält die Einbettung frisch. Best-effort + Build-zu-Temp-dann-Swap, damit ein
# Fehlschlag die laufende web_dist NICHT leert (emptyOutDir).
cp "$SRC/deploy/hermes-builtin-ui.service" "$HOME/.config/systemd/user/hermes-builtin-ui.service"
_HUI_WEB="$HOME/.hermes/hermes-agent/web"
_HUI_DIST="$HOME/.hermes/hermes-agent/hermes_cli/web_dist"
if [ -d "$_HUI_WEB" ] && [ -x "$HOME/.hermes/node/bin/npx" ]; then
if ( cd "$_HUI_WEB" && PATH="$HOME/.hermes/node/bin:$PATH" npx --no-install vite build \
--base=/hermes-ui/ --outDir /tmp/hermes-ui-build --emptyOutDir ) >/tmp/hermes-ui-build.log 2>&1; then
# Dienst VOR dem Tausch stoppen — sonst crasht der laufende `hermes serve`, wenn er index.html
# im Lösch-Fenster (rm→cp) liest (FileNotFoundError → Start-Limit → tot). Der reguläre restart
# weiter unten bringt ihn sauber auf der neuen web_dist hoch.
systemctl --user stop hermes-builtin-ui 2>/dev/null || true
rm -rf "$_HUI_DIST" && cp -r /tmp/hermes-ui-build "$_HUI_DIST" && echo "Hermes-GUI (base=/hermes-ui/) gebaut."
else
echo "WARN: Hermes-GUI-Build fehlgeschlagen (siehe /tmp/hermes-ui-build.log) — bestehende web_dist bleibt."
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
#
# ‼️ ACHTUNG (27.08.2026): /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit — MC2
# schreibt sie selbst (backend/services/llamaswap.py::write_config), sobald jemand über
# die Oberfläche ein Modell hinzufügt oder eine Rolle ändert. deploy/llama-swap.config.yaml
# ist nur ein Abzug und hinkt regelmäßig hinterher (erlebt: der Coder-Vollkontext aus 34a9862
# stand tagelang nur live, nicht im Abzug — ein Deploy hätte ihn zurückgesetzt).
# Bis hierher wurde die Live-Datei kommentarlos mit dem Abzug überschrieben — alles per
# UI Hinzugefügte war nach jedem Deploy weg. Jetzt: erst sichern, dann kopieren, und die
# Abweichung sichtbar machen. MC_DEPLOY_SKIP_SWAP_CONFIG=1 überspringt den Schritt ganz.
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
else
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
echo "Llama-Swap Konfiguration hat sich geändert."
echo " Abweichung (live -> Repo-Abzug):"
diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
echo " Sichere die laufende Datei nach $SWAP_BAK"
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
sudo systemctl restart llama-swap
echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
fi
fi
fi
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable mc2-gateway >/dev/null 2>&1 || true
systemctl --user enable mc2-steward >/dev/null 2>&1 || true
systemctl --user enable box-console >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable voice-service >/dev/null 2>&1 || true
systemctl --user enable hermes-builtin-ui >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
# mc2-autoupdate.timer wird hier BEWUSST NICHT geschaltet — der Zustand wird nur von Hand
# geändert (Lehre 09.07.: die frühere enable-Zeile hat einen User-Entscheid bei jedem Deploy
# still rückgängig gemacht). Aktueller User-Entscheid (10.07.2026): Timer ist AN —
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-morgen-digest.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
# Eingebaute Hermes-Web-GUI (Loopback :9119) (re)starten — MC2 bettet sie unter /hermes-ui/ ein.
# reset-failed, damit ein zuvor am Start-Limit gestorbener Dienst wieder anläuft.
systemctl --user reset-failed hermes-builtin-ui 2>/dev/null || true
systemctl --user restart hermes-builtin-ui 2>/dev/null || true
# Gateway VOR dem Steuerpult (re)starten — MC2s /v1-Weiterleiter braucht ihn sofort.
# KEIN `|| true`: ohne Gateway ist der LLM-Datenpfad tot, das MUSS den Deploy stoppen.
systemctl --user restart mc2-gateway
# Steward (Wächter) — unabhängig vom Steuerpult; ohne ihn wacht niemand über die Box.
systemctl --user restart mc2-steward
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
sleep 2
echo "--- Health ---"
# Gateway-Kaltstart-Fenster: bis ~12 s tolerieren, DANN hart scheitern (ohne Gateway
# ist der LLM-Datenpfad tot — das muss den Karten-Runner rot machen).
for _i in $(seq 1 10); do
curl -sf -m 3 http://127.0.0.1:9010/gw/health >/dev/null 2>&1 && break
[ "$_i" -eq 10 ] && { echo "FEHLER: mc2-gateway (:9010) antwortet nicht"; exit 1; }
sleep 1
done
curl -sf http://127.0.0.1:9010/gw/health && echo
curl -sf http://127.0.0.1:9001/api/health && echo
# Steward hat keinen HTTP-Port — systemd-Zustand ist der Beweis (crasht er sofort,
# ist er hier bereits 'failed' statt 'active').
if systemctl --user is-active --quiet mc2-steward; then
echo "mc2-steward aktiv (Wächter laufen)"
else
echo "FEHLER: mc2-steward läuft nicht (journalctl --user -u mc2-steward -n 30)"; exit 1
fi
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
systemctl --user enable --now mc2-radar.timer
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
bash "$SRC/deploy/warmup.sh" || true
# 4.5 Hermes Skills synchronisieren
echo "Synchronisiere Hermes Skills..."
mkdir -p ~/.hermes/skills
# Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
for skill_dir in deploy/skills/*; do
if [ -d "$skill_dir" ]; then
skill_name=$(basename "$skill_dir" | tr '-' '_')
mkdir -p ~/.hermes/skills/"$skill_name"
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
fi
done
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
# 5. Dienste neu starten
# ‼️ mc2-steward gehoert hier dazu: er laeuft aus DEMSELBEN Checkout (backend/steward.py).
# Ohne ihn lief der Waechter-Prozess nach jedem Deploy mit dem alten Code weiter — der
# Neustart wurde 27.08.2026 nachgetragen. `--user restart` auf einer nicht installierten
# Unit ist folgenlos, darum ohne Vorabpruefung.
echo "Starte Backend neu..."
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
echo "✅ Deployment erfolgreich abgeschlossen!"
}
main "$@"
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Feed für den Traum-Cron (Dreaming / Continual-Learning-Kreislauf, Phase 2 — Capstone).
# Zwilling von radar-feed.sh (Blick nach draußen) und selbstkritik-feed.sh (Blick auf den
# Betrieb); der Traum blickt auf das GELERNTE: Sessions, Skills, Gedächtnis, Muster.
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
# Prompt eingespeist: versionierter Auftrag (deploy/dreaming-prompt.md) + Live-Schlaf-Daten.
set -uo pipefail
VAULT="$HOME/wissens-vault"
# --- Vault-Bootstrap (idempotent): eigenes git-Repo, menschenlesbar, NICHT im Stack-Code. ---
if [ ! -d "$VAULT/.git" ]; then
mkdir -p "$VAULT/traeume" "$VAULT/muster" "$VAULT/skill-kandidaten"
git -C "$VAULT" init -q 2>/dev/null || true
# Lokale Identität nur setzen, falls global keine da ist (Commits sollen nie scheitern).
git -C "$VAULT" config user.email >/dev/null 2>&1 || git -C "$VAULT" config user.email "lucy@box.local"
git -C "$VAULT" config user.name >/dev/null 2>&1 || git -C "$VAULT" config user.name "Lucy (Traum)"
if [ ! -f "$VAULT/INDEX.md" ]; then
cat > "$VAULT/INDEX.md" <<'EOF'
# Wissens-Vault — Navigationskarte
Menschenlesbare, git-versionierte Wissensschicht NEBEN dem Live-Gedächtnis (Mem0).
Angelegt von Lucys nächtlichem Traum. Verlinkung im Obsidian-Stil `[[dateiname]]`.
## Träume (chronologisch)
## Muster (akkumuliertes Projektwissen)
## Skill-Kandidaten (Vorschläge, Gate = Commander „mach")
EOF
git -C "$VAULT" add -A >/dev/null 2>&1 || true
git -C "$VAULT" commit -q -m "Vault angelegt" >/dev/null 2>&1 || true
fi
fi
# --- Versionierter Auftrag ---
cat "$HOME/mission-control-v2/deploy/dreaming-prompt.md"
echo
echo "## SCHLAF-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
# Hinweis: erst voll einsammeln, DANN kürzen — sonst schließt `head` die Pipe früh,
# der Produzent bekommt SIGPIPE, pipefail feuert und die Fallback-Zeile lügt "nicht verfügbar".
echo "### Insights der letzten 7 Tage (Token-/Tool-/Aktivitätsmuster):"
_ins="$(bash -lc "hermes insights --days 7" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_ins:-(insights nicht verfügbar)}" | head -62
echo
echo "### Session-Store (Umfang):"
_sst="$(bash -lc "hermes sessions stats" 2>/dev/null)"
echo "${_sst:-(sessions stats nicht verfügbar)}" | head -12
echo
echo "### Jüngste Sessions (woran zuletzt gearbeitet wurde):"
_sls="$(bash -lc "hermes sessions list" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_sls:-(sessions list nicht verfügbar)}" | head -25
echo
echo "### Gelernt bisher (journey — Skills + Gedächtnis-Knoten, kompakt):"
bash -lc "hermes journey --json" 2>/dev/null \
| jq -c '{stats: .stats, skills: ([.nodes[]? | select(.kind=="skill" or .type=="skill") | .label // .name] | .[0:25]), cluster_labels: ([.clusters[]?.label] | .[0:15])}' \
2>/dev/null || echo "(journey nicht verfügbar)"
echo
echo "### Curator / Skill-Hygiene (was brachliegt, was aktiv ist):"
bash -lc "hermes curator status" 2>/dev/null | head -26 || echo "(curator status nicht verfügbar)"
echo
echo "### Bisheriger Vault-INDEX (nur zur Ansicht — damit du nichts doppelt anlegst + verlinken"
echo "### kannst). Die echte Datei liegt unter ~/wissens-vault/INDEX.md; zwischen den Markern"
echo "### steht ihr AKTUELLER Inhalt. Kopiere die Marker NICHT in die Datei zurück."
echo "<<<INDEX-INHALT-ANFANG>>>"
cat "$VAULT/INDEX.md" 2>/dev/null | head -120 || echo "(noch kein INDEX — beim ersten Traum anlegen)"
echo "<<<INDEX-INHALT-ENDE>>>"
echo
echo "### Letzte 3 Traum-Notizen (nur Briefing — NICHT in den INDEX kopieren):"
ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -3 | while read -r f; do
echo " $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
done
-106
View File
@@ -1,106 +0,0 @@
# Der Traum (nächtlich) — Continual-Learning-Kreislauf
Es ist tiefe Nacht, niemand spricht mit dir, die Box ist im Leerlauf. Du bist Lucy und
tust jetzt das, wozu Menschen den Schlaf brauchen: Du gehst den vergangenen Tag/die
vergangene Woche noch einmal durch, ziehst Muster heraus, legst Gelerntes ordentlich ab
und überlegst, wie du beim nächsten Mal besser wirst — **ohne neu trainiert zu werden.**
Das ist der Höhepunkt der Autonomie-Vision: „Die Box wird besser, während der Commander
schläft." Dieser Lauf ist der Zwilling von Evolution-Radar (Blick nach draußen) und
Selbstkritik (Blick auf den Betrieb) — der **Traum** ist der Blick auf das **Gelernte**:
Sessions, Skills, Gedächtnis, wiederkehrende Muster.
## Dein Auftrag (streng in dieser Reihenfolge — der Commit und die Nachricht kommen ZULETZT)
1. **Reflektieren.** Lies die SCHLAF-DATEN unten gründlich: die Insights (Token-/Tool-/
Aktivitätsmuster der letzten 7 Tage), die jüngsten Sessions, den Skill-/Gedächtnis-Stand
(journey), den Curator-Stand. Frag dich:
- Was ging diese Woche wiederholt gut? Was ging wiederholt schief?
- Welche Tools laufen heiß, welche Skills liegen brach (activity=0)?
- Gibt es ein **wiederkehrendes Muster** über mehrere Sessions hinweg — dieselbe
Fehlerart, derselbe Handgriff, dieselbe Frage des Commanders? Ein Muster braucht
**mehrfache Belege** (nicht ein Einzelfall), sonst ist es Rauschen.
2. **Notizen ENTWERFEN** (schreiben, aber NOCH NICHT committen) im Wissens-Vault
(`~/wissens-vault/`, ein git-versioniertes, menschenlesbares Markdown-Verzeichnis — die
dauerhafte, navigierbare Wissensschicht NEBEN dem Live-Gedächtnis Mem0):
- Lege **eine** neue Traum-Notiz an: `~/wissens-vault/traeume/<YYYY-MM-DD>-traum.md`
(Datum von heute). Kurz und dicht: 25 belegte Beobachtungen, je mit Zahl/Session/
Log-Bezug. Keine Romane, keine Allgemeinplätze.
- Wenn ein Muster **mehrfach** belegt ist: lege `~/wissens-vault/muster/<kurz-slug>.md` an
(oder ergänze die vorhandene um einen neuen Beleg + heutiges Datum). Muster-Notizen sind
das akkumulierte Projektwissen.
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **ABER ZUERST prüfen:**
```
ls ~/wissens-vault/skill-kandidaten/*.md ~/wissens-vault/skill-kandidaten/beauftragt/*.md 2>/dev/null
ls ~/.hermes/skills/ 2>/dev/null
```
Wenn ein thematisch passender Kandidat schon existiert → **NICHTS anlegen**, stattdessen
im Bericht erwähnen: „Kandidat `[[slug]]` besteht bereits, seit <Datum>, kein neuer
Vorschlag nötig."
**WICHTIG:** Prüfe auch, ob es bereits einen fertig gebauten Skill gibt.
Wenn ja: kein Kandidat nötig, der Skill existiert bereits.
**Entwirf ihn gleich
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
**Wann nutzen** (Auslöser), **Schritte** (mit den ECHTEN Hermes-Tools/Befehlen aus den
belegten Sessions — keine erfundenen Kommandos) und **Belege** (Sessions/Zahlen, aus
denen der Handgriff stammt). **Du legst den Skill NICHT selbst an** (kein skill_manage) —
das ist ein Vorschlag für die Werkstatt, die der Commander im Auftragsbuch per
„Beauftragen" auslöst. Höchstens EIN Kandidat pro Traum.
- Verlinke zwischen Notizen mit `[[dateiname-ohne-endung]]` (Obsidian-Stil), großzügig.
3. **Fremdblick — PFLICHT-GATE** (Härtung „andere Brille": ein Agent, der seine eigenen Träume
benotet, stimmt sich selbst zu). Hol dir eine Zweitmeinung von einem ANDEREN Modell. **Nutze
dafür das bereitgestellte Skript** — NICHT `delegate_task` (der Kritiker heavy hat nur 32K
Kontext und ist als Subagent-Ziel disqualifiziert; das Skript umgeht das mit einer einzelnen
Completion). Konkret: Fasse deine 25 Beobachtungen je mit ihrem Zahlen-/Session-Beleg + den
(falls vorhandenen) Skill-Kandidaten KOMPAKT zusammen (wenige Zeilen je Punkt, KEINE
Rohdaten) und pipe sie in das Skript:
```
printf '%s' "<deine kompakten Beobachtungen + Belege>" | ~/.hermes/scripts/fremdblick.sh
```
Das Skript fragt gpt-oss (heavy, andere Modell-Familie als dein Qwen-Hirn) und gibt pro Punkt
ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER zurück. **Lies die Ausgabe und richte dich danach.**
**⛔ HARTE REGEL:** Du fährst mit Schritt 4 erst fort, wenn die Fremdblick-Ausgabe vorliegt.
Schreibe KEINE Nachricht und committe NICHTS vorher. Steht in der Ausgabe „FREMDBLICK
FEHLGESCHLAGEN", einmal wiederholen; klappt es dann immer noch nicht, kennzeichne deine Funde
in der Nachricht ehrlich als **„ungeprüft (Fremdblick fiel aus)"**.
4. **Bereinigen + INDEX pflegen.** Streiche aus deinen Notizen alles, was der Fremdblick nicht
getragen hat. Trage dann **jede** verbliebene neue/geänderte Notiz in `~/wissens-vault/INDEX.md`
ein (eine Zeile je Notiz unter der passenden Überschrift: `- [[dateiname]] — Ein-Satz-Haken`).
**Wichtig zum INDEX:** Bearbeite NUR die Markdown-Inhaltszeilen. Die Zeilen der SCHLAF-DATEN
unten (alles ab „## SCHLAF-DATEN", inklusive Abschnitte wie „### Bisheriger Vault-INDEX" und
„### Letzte 3 Traum-Notizen") sind BRIEFING für dich, NICHT Teil des INDEX — kopiere sie NIE
in die INDEX-Datei.
5. **Commit — die LETZTE Vault-Aktion, sie fasst alles zusammen:**
`git -C ~/wissens-vault add -A && git -C ~/wissens-vault commit -m "Traum <YYYY-MM-DD>"`.
(Harmloser Commit in einem EIGENEN Repo, nicht im Stack-Code — erlaubt und reversibel.)
6. **Dem Commander berichten — deine ALLERLETZTE Handlung** (nichts danach). EINE kompakte
Nachricht in DEINER Stimme (Lucy: Anrede „Commander", Fazit zuerst, Alltagssprache, Technik
knapp dahinter). Sag: was du heute Nacht bemerkt hast (13 Funde, je mit Beleg + wie der
Fremdblick sie bewertet hat), wohin du es im Vault gelegt hast (`[[link]]`), und — falls
vorhanden — welchen Skill-Kandidaten du zur Prüfung vorschlägst. Wenn ehrlich NICHTS
Nennenswertes war: genau das sagen, kurz und zufrieden — **keine Funde erfinden.**
## Leitplanken (nicht verhandelbar — selbstverbessernde Agenten driften sonst)
- **Du änderst am laufenden Betrieb NICHTS.** Der einzige Ort, an den du schreibst, ist der
`~/wissens-vault/` (Notizen + Commit). Alles andere ist nur VORSCHLAG an den Commander;
umgesetzt wird erst auf sein „mach" (dann übernimmt die Werkstatt mit Gates und Rollback).
- **Kein Security-Anfassen.** Approvals, Tokens, Firewall, Config nur BENENNEN, nie ändern.
- **Fremder Quellcode (Hermes, llama.cpp …) ist tabu** — Auto-Update-Kanal, nicht dein Revier.
- **Nichts, was das Warm-Set oder Lucys Sprech-Latenz gefährdet.** Der Fremdblick (Schritt 3)
nutzt ein LEICHTES Modell (GLM, lädt klein daneben) — **lade NIE gpt-oss/heavy** (60 GB,
kollidiert mit dem VL-30B-Warm-Set → Health-Check-Tod). Keine schweren Jobs, kein Neustart.
- **Sparsam mit Tools.** Die Schlaf-Daten unten reichen für die Reflexion; höchstens ein paar
gezielte Nachschau-Aufrufe (eine Session exportieren, eine Log-Zeile im Kontext lesen),
dann schreiben + berichten. Kein Tool-Feuerwerk.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -e
# Alte Crons löschen
hermes cron remove traum || true
hermes cron remove chef-gutachter || true
hermes cron remove release-radar || true
hermes cron remove idle-radar || true
hermes cron remove selbst-inventur || true
hermes cron remove karten-gutachter || true
hermes cron remove pruefstand || true
hermes cron remove trend-radar || true
# Neue Crons registrieren mit Wrapper und --deliver origin.
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
echo "Crons erfolgreich gefixt!"
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env bash
# Fremdblick — Ein-Schuss-Zweitmeinung von einem ANDEREN Modell (Default seit 07.07.:
# GLM-4.7-Flash, 30B-A3B Text — klar stärkerer Kritiker als die 9B-Vision-Schwester 4.6V,
# gleicher Fremd-Vendor wie zuvor → echte „andere Brille" bleibt). Eine EINZELNE
# Completion — kein agentischer Subagent — umgeht Hermes' 64K-Delegations-Floor
# (MINIMUM_CONTEXT_LENGTH) UND das 60-GB-Ladeproblem von gpt-oss (das mit dem Warm-Set kollidiert
# und „exited prematurely" wirft). GLM lädt klein neben dem Warm-Set und antwortet zuverlässig.
#
# Nutzung: echo "<Beobachtungen/Behauptungen je mit Beleg>" | fremdblick.sh
# printf '%s' "$text" | FREMDBLICK_MODEL=Qwen3-Coder-Next FREMDBLICK_SYS="$raster" fremdblick.sh
#
# Env-Overrides:
# FREMDBLICK_MODE 'prose' (Default, Dreaming-Prosa-Raster) oder 'code' (Werkstatt-Code-Review).
# 'code' setzt Raster + Default-Modell (Qwen3-Coder-Next) + größeres Budget.
# FREMDBLICK_MODEL Modell-ID (echte llama-swap-ID, kein Alias). Übersteuert den Mode-Default.
# FREMDBLICK_SYS eigenes System-Raster (übersteuert das Mode-Raster).
# FREMDBLICK_MAXTOK max_tokens (übersteuert den Mode-Default).
set -uo pipefail
# Achtung: llama-swap listet unter /v1/models die ECHTEN Modell-IDs, nicht die Gateway-Aliase
# (kein "heavy"/"scout" hier).
ENDPOINT="${FREMDBLICK_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODE="${FREMDBLICK_MODE:-prose}"
SUBJECT="$(cat)"
if [ -z "${SUBJECT// /}" ]; then
echo "=== FREMDBLICK: nichts zu prüfen (leere Eingabe) ==="
exit 0
fi
# Prosa-Raster (Dreaming): urteilt über Beobachtungen/Behauptungen. Default-Kritiker = GLM (Fremd-Vendor).
PROSE_SYS='Du bist ein KRITISCHER Zweitgutachter und ausdrücklich ein ANDERES Modell als der Autor. Prüfe jede vorgelegte Beobachtung/Behauptung streng: Trägt der genannte Beleg (Zahl / Session / Log-Zeile) die Behauptung wirklich, oder ist es ein Einzelfall bzw. ein Bauchgefühl mit Zahlen-Deko? Verwechselt der Autor Korrelation mit Ursache? Fehlt entscheidender Kontext? Falls ein Skill-Kandidat dabei ist: ist er wirklich WIEDERVERWENDBAR oder Einmal-Kram? Antworte kompakt auf Deutsch: pro Punkt ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER mit genau einem Satz Begruendung. Sei knapp und unbestechlich; nicke nichts aus Hoeflichkeit durch. Erfinde keine neuen Belege.'
# Code-Raster (Werkstatt): reproduziert den Fehlerfall aus der Bug-Beschreibung, statt dem Autor zu glauben.
CODE_SYS='Du bist ein kritischer Code-Reviewer und ein ANDERES Modell als der Autor. Vertraue seiner Begruendung NICHT. Dir liegen ein Wartungs-AUFTRAG (Bug-Beschreibung) und ein git-Diff vor. Trenne klar: (A) KERNFRAGE: Behebt der Diff den im Auftrag KONKRET beschriebenen Fehlerfall? Leite die Eingabe SELBST aus der Bug-Beschreibung ab und belege einen Uebergang: Eingabe X -> VOR Patch: Y (Bug sichtbar) -> NACH Patch: Z. (B) RANDFAELLE: nenne offene Risiken/Randfaelle als Hinweis. URTEIL-Regel: ABGELEHNT NUR, wenn (A) nicht belegt behoben ist (Kern-Bug bleibt, ODER der Patch trifft die Auftrags-Absicht nicht, ODER er bricht den Normalfall). Ist der Kern-Fall belegt behoben und es bleiben nur Randfaelle: FREIGABE-MIT-VORBEHALT (Randfaelle auflisten). Ist alles sauber: FREIGABE. Ein blosses sieht-plausibel-aus OHNE reproduzierten Kern-Uebergang = ABGELEHNT. Beginne die Antwort mit der Zeile "URTEIL: <ABGELEHNT|FREIGABE-MIT-VORBEHALT|FREIGABE>". Antworte knapp auf Deutsch.'
if [ "$MODE" = "code" ]; then
MODEL="${FREMDBLICK_MODEL:-Qwen3-Coder-Next}"
MAXTOK="${FREMDBLICK_MAXTOK:-1600}"
SYS="${FREMDBLICK_SYS:-$CODE_SYS}"
else
MODEL="${FREMDBLICK_MODEL:-GLM-4.7-Flash}"
# 4000 (war 2200): GLM-4.7-Flash ist ein Reasoning-Modell und verbrennt real ~2000 Tokens
# im reasoning_content, BEVOR das Urteil in content landet (verifiziert 14.07.). Bei 2200 lief
# es Gefahr, mitten im Denken abzuschneiden → leeres content → "ABGESCHNITTEN/FEHLGESCHLAGEN"
# (traf die Nacht-Kritiker mit Default-Budget). Wer explizit knapper braucht, setzt FREMDBLICK_MAXTOK.
MAXTOK="${FREMDBLICK_MAXTOK:-4000}"
SYS="${FREMDBLICK_SYS:-$PROSE_SYS}"
fi
# max_tokens großzügig: Reasoning-Modelle (GLM) verbrauchen Tokens im reasoning_content, bevor sie
# die eigentliche Antwort in content schreiben — zu knapp = leeres content (finish=length).
RESP="$(jq -n --arg m "$MODEL" --arg s "$SYS" --arg u "$SUBJECT" --argjson t "$MAXTOK" \
'{model:$m, temperature:0.2, max_tokens:$t, messages:[{role:"system",content:$s},{role:"user",content:$u}]}' \
| curl -s --max-time 240 "$ENDPOINT" -H 'Content-Type: application/json' -d @- 2>/dev/null)"
OUT="$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null)"
FIN="$(printf '%s' "$RESP" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)"
if [ -n "$OUT" ]; then
echo "=== FREMDBLICK (Zweitmeinung von Modell: $MODEL) ==="
echo "$OUT"
elif [ "$FIN" = "length" ]; then
echo "=== FREMDBLICK ABGESCHNITTEN ==="
echo "(Das Kritiker-Modell hat sein Token-Budget im Nachdenken verbraucht, bevor ein Urteil kam."
echo " Kürze deine Vorlage auf die Kern-Behauptungen und versuche es erneut; sonst UNGEPRUEFT.)"
else
echo "=== FREMDBLICK FEHLGESCHLAGEN ==="
echo "(Kein Urteil erhalten — Endpoint/Modell nicht erreichbar. Behandle die Funde als UNGEPRUEFT.)"
fi
-33
View File
@@ -1,33 +0,0 @@
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
## 1. Zu Beginn: erst SAVEPOINT.md lesen
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
ist, sag es, statt es zu erfinden.
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
- **Offene Fragen** — Entscheidungen, die noch anstehen.
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
## 3. Kleine, überprüfbare Schritte
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
## 4. Wenn der Governor das Sitzungs-Limit meldet
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
-134
View File
@@ -1,134 +0,0 @@
# Governor — Phase 0
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
## Bausteine (dieses Verzeichnis)
| Datei | Zweck |
|---|---|
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
## Verhalten des Governors
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
kalibriert auf CPT **3.5**`est ≈ echte prompt_tokens` auf ~13 % bei echten Sessions):
- **est < Soft** → unverändert durchreichen.
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
ehrlichen Abschluss-Savepoint.
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
mitgeloggt.
### Sprach-Signal an Lucy (Phase 2)
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
### Umgebungsvariablen
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
```bash
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
pipx install uv && uv tool install --python 3.12 aider-chat
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
# Aider-Sitzung durch den Governor:
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
```
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
`openai/Qwen3-Coder-Next` auf den Governor.
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
## Ergebnisse & Befunde (24.07.2026)
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
`HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
Schritt) und verweigerte neuen Code.
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
Tests laufen grün** gegen die echte API. Kein Erfinden.
### Kalibrierung
`CHARS_PER_TOKEN = 3.5``est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
### Weitere Befunde / Fallen
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
Aider selbst und der Governor greift nie. Siehe oben.
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
Python 3.12 installiert.
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
lokalen `:8080`-Endpoint irrelevant.
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
## Nächste Schritte (Phase 1+)
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
-23
View File
@@ -1,23 +0,0 @@
# SAVEPOINT
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
## Ziel
_(noch nichts — von der ersten Sitzung zu füllen)_
## Erledigt
- _(noch nichts)_
## Nächster Schritt
- Auftrag des Nutzers entgegennehmen und beginnen.
## Offene Fragen
- _(keine)_
## Stolpersteine
- _(keine bekannt)_
## Dateien
- `SAVEPOINT.md` — diese Übergabe-Datei.
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_fire.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
PY
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
sleep 1.5 # Announce-Thread durchlassen
echo "=== Governor-Log (letzte 4 Zeilen) ==="
tail -4 ~/governor-p0/governor.log
echo "=== Announce-Queue: Governor-Eintraege ==="
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde genau das, was
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
.../aider-chat/bin/python driver.py <messages-file>
"""
import os
import sys
from aider.coders import Coder
from aider.io import InputOutput
from aider.models import Model
def main() -> int:
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
os.environ.setdefault("OPENAI_API_KEY", "dummy")
if len(sys.argv) < 2:
print("usage: driver.py <messages-file>", file=sys.stderr)
return 2
with open(sys.argv[1], encoding="utf-8") as fh:
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
model = Model("openai/Qwen3-Coder-Next")
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
coder = Coder.create(
main_model=model,
io=io,
fnames=["SAVEPOINT.md"], # editierbar
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
auto_commits=True, # jede Etappe -> git-Commit
stream=False, # deterministische Logs fuer P0
map_tokens=512,
use_git=True,
)
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
try:
coder.summarizer.max_tokens = 10 ** 9
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
except Exception as exc:
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
for i, msg in enumerate(messages, 1):
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
print(f">> {msg[:140]}", flush=True)
try:
coder.run(with_message=msg)
except Exception as exc:
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
break
sent = getattr(coder, "total_tokens_sent", "?")
recv = getattr(coder, "total_tokens_received", "?")
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
print("\n===== SESSION ENDE =====", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
# Nutzung:
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
# gov-ctl.sh stop
# gov-ctl.sh status
set -u
DIR="$HOME/governor-p0"
PIDF="$DIR/governor.pid"
LOG="$DIR/governor.log"
OUT="$DIR/governor.stdout"
start() {
stop
local thr="${1:-25000}"
cd "$DIR"
: > "$LOG"
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
local hardenv=()
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
GOV_LOG="$LOG" GOV_PORT=8100 \
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
echo $! > "$PIDF"
sleep 1.2
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
}
stop() {
pkill -f "$DIR/governor.py" 2>/dev/null || true
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
sleep 0.6
rm -f "$PIDF"
}
status() {
if pgrep -f "$DIR/governor.py" >/dev/null; then
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
ss -tlnp 2>/dev/null | grep ":8100" || true
else
echo "not running"
fi
}
case "${1:-status}" in
start) shift; start "${1:-25000}" "${2:-}" ;;
stop) stop; echo stopped ;;
status) status ;;
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
esac
-568
View File
@@ -1,568 +0,0 @@
#!/usr/bin/env python3
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
MC2-Gateway (:9001). Reicht ALLES unveraendert durch mit einer Ausnahme bei
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper inklusive
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
einem einmal geratenen Wert festzuhaengen.
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
letzten Laeufe als JSON Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
und Lucys `loop_status`.
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
Konfiguration per Umgebungsvariablen (alle optional):
GOV_PORT Listen-Port (Default 8100)
GOV_HOST Listen-Adresse (Default 0.0.0.0)
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
GOV_DIRECTIVE Text des Soft-Einschubs
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
reranker,vision,scout Lucys Alltag wird nie unterbrochen)
"""
import http.client
import json
import os
import re
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
# ---- Konfiguration ---------------------------------------------------------
PORT = int(os.environ.get("GOV_PORT", "8100"))
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
_hard_env = os.environ.get("GOV_HARD_CEILING")
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
EXEMPT_MODELS = {m.strip().lower() for m in
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
DEFAULT_DIRECTIVE = (
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
)
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
DEFAULT_HARDSTOP = (
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
)
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
DEFAULT_ANNOUNCE = (
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
"Stand im Savepoint; am besten fangen wir gleich frisch an."
)
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
up = urlparse(UPSTREAM)
UP_HOST = up.hostname or "127.0.0.1"
UP_PORT = up.port or 80
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
}
_log_lock = threading.Lock()
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
_announce_lock = threading.Lock()
_last_announce = 0.0
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
# ---- Zustand (klein, im Speicher) ------------------------------------------
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
_state_lock = threading.Lock()
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
_cpt_n: dict = {} # modell -> Anzahl Messungen
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
def log(line: str) -> None:
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
msg = f"{stamp} {line}"
with _log_lock:
print(msg, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(msg + "\n")
except OSError:
pass
def cpt_for(model: str) -> float:
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
with _state_lock:
return _cpt.get(model, CHARS_PER_TOKEN)
def calibrate(model: str, chars: int, exact: int) -> None:
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
return
ratio = chars / exact
if not (CPT_MIN <= ratio <= CPT_MAX):
return
with _state_lock:
n = _cpt_n.get(model, 0)
old = _cpt.get(model, CHARS_PER_TOKEN)
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
w = max(0.08, 1.0 / (n + 2))
_cpt[model] = old * (1 - w) + ratio * w
_cpt_n[model] = n + 1
def body_chars(data: dict) -> int:
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
Wir serialisieren einfach alles, was ans Modell geht.
"""
payload = {k: v for k, v in data.items()
if k in ("messages", "tools", "tool_choice", "system", "functions")}
try:
return len(json.dumps(payload, ensure_ascii=False))
except (TypeError, ValueError):
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
chars = 0
for m in data.get("messages") or []:
c = m.get("content") if isinstance(m, dict) else None
if isinstance(c, str):
chars += len(c) + 4
elif isinstance(c, list):
for p in c:
if isinstance(p, dict) and isinstance(p.get("text"), str):
chars += len(p["text"])
return chars
def safe_to_append(messages) -> bool:
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen schiebt man da
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
"""
if not isinstance(messages, list) or not messages:
return False
last = messages[-1]
if not isinstance(last, dict):
return False
role = last.get("role")
if role == "tool":
return False
return not (role == "assistant" and last.get("tool_calls"))
def _post_announce(est) -> None:
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
try:
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
body = json.dumps({"text": text, "subject": "[Governor]",
"source": "governor", "priority": "normal"}).encode("utf-8")
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
_an.port or 80, timeout=4)
conn.request("POST", _an.path or "/api/voice/announce", body=body,
headers={"Content-Type": "application/json",
"Content-Length": str(len(body))})
resp = conn.getresponse()
resp.read()
conn.close()
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
except Exception as exc:
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
def maybe_announce(est) -> None:
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
if not _an:
return
global _last_announce
now = time.time()
with _announce_lock:
if now - _last_announce < ANNOUNCE_THROTTLE:
return
_last_announce = now
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
def status_payload() -> dict:
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
with _state_lock:
return {
"ok": True,
"upstream": UPSTREAM,
"soft": THRESHOLD,
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
"uptime_s": int(time.time() - _counters["started"]),
"counters": {k: v for k, v in _counters.items() if k != "started"},
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
for m, v in _cpt.items()},
"calibration_default": CHARS_PER_TOKEN,
"exempt_models": sorted(EXEMPT_MODELS),
"recent": list(_recent),
}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "Governor/2.0"
def log_message(self, *args):
pass
def do_GET(self):
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
self._send_json(200, status_payload())
return
self._proxy()
def do_POST(self):
self._proxy()
def do_PUT(self):
self._proxy()
def do_DELETE(self):
self._proxy()
def do_OPTIONS(self):
self._proxy()
# -- Kern ---------------------------------------------------------------
def _send_json(self, status: int, obj) -> None:
try:
data = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
except OSError:
pass
def _read_body(self) -> bytes:
length = self.headers.get("Content-Length")
if length is None:
return b""
try:
return self.rfile.read(int(length))
except (ValueError, OSError):
return b""
def _proxy(self) -> None:
body = self._read_body()
path = self.path
clean_path = path.split("?", 1)[0]
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
action = "passthrough"
est = None
streaming = False
model = ""
chars = 0
if is_chat and body:
action, body, est, streaming, model, chars = self._decide(body)
if action in ("soft", "hard"):
maybe_announce(est)
if action == "hard":
self._send_canned_stop(model, streaming, est)
with _state_lock:
_counters["chat"] += 1
_counters["hard"] += 1
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": None, "action": "hard"})
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
f"HARDSTOP stream={streaming} status=200")
return
out_headers = {}
for k, v in self.headers.items():
kl = k.lower()
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
continue
out_headers[k] = v
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
out_headers["Accept-Encoding"] = "identity"
if body:
out_headers["Content-Length"] = str(len(body))
out_headers["Connection"] = "close"
conn = None
try:
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
conn.request(self.command, path, body=body or None, headers=out_headers)
resp = conn.getresponse()
except (OSError, http.client.HTTPException) as exc:
log(f"ERROR upstream {self.command} {path}: {exc!r}")
if conn is not None:
conn.close()
self._safe_error(502, f"governor upstream: {exc}")
return
self.send_response(resp.status)
for k, v in resp.getheaders():
kl = k.lower()
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
continue
self.send_header(k, v)
self.send_header("Connection", "close")
self.end_headers()
tail = bytearray()
try:
while True:
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
chunk = resp.read1(65536)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
tail.extend(chunk)
if len(tail) > 16384:
del tail[:-16384]
except OSError:
pass
finally:
conn.close()
exact = self._scan_prompt_tokens(tail)
if is_chat:
if exact:
calibrate(model, chars, exact)
with _state_lock:
_counters["chat"] += 1
_counters["soft" if action == "soft" else "passthrough"] += 1
if exact:
_counters["tokens_prompt"] += exact
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": exact, "action": action})
exact_s = str(exact) if exact is not None else "-"
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
def _decide(self, body: bytes):
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
try:
data = json.loads(body)
except (ValueError, UnicodeDecodeError):
return "passthrough", body, None, False, "", 0
if not isinstance(data, dict):
return "passthrough", body, None, False, "", 0
messages = data.get("messages")
streaming = bool(data.get("stream"))
model = (data.get("model") or "").strip()
chars = body_chars(data)
est = int(chars / max(cpt_for(model), 0.1))
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
base = model.split("/")[-1].lower()
if base in EXEMPT_MODELS:
return "passthrough", body, est, streaming, model, chars
if not isinstance(messages, list):
return "passthrough", body, est, streaming, model, chars
has_warned = False
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
for m in reversed(messages):
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
has_warned = True
break
is_safe = safe_to_append(messages)
# 1. Absolutes Not-Aus bei komplettem Amoklauf
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
return "hard", body, est, streaming, model, chars
# 2. Sind wir am Limit?
if est >= THRESHOLD:
if not is_safe:
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
return "defer", body, est, streaming, model, chars
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
if HARD_CEILING > 0 and est >= HARD_CEILING:
if has_warned:
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
return "hard", body, est, streaming, model, chars
else:
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
pass
# Soft-Limit greift
if not has_warned:
directive = (DIRECTIVE.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
messages.append({"role": "user", "content": directive})
data["messages"] = messages
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
return "passthrough", body, est, streaming, model, chars
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
created = int(time.time())
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
"total_tokens": est or 0}
try:
if streaming:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
def sse(obj):
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
self.wfile.flush()
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
"created": created, "model": model}
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {},
"finish_reason": "stop"}], "usage": usage})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
else:
payload = {
"id": "governor-hardstop", "object": "chat.completion",
"created": created, "model": model,
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
"usage": usage,
}
data = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
self.wfile.flush()
except OSError:
pass
def _safe_error(self, status: int, msg: str) -> None:
self._send_json(status, {"error": msg})
@staticmethod
def _scan_prompt_tokens(tail: bytearray):
if not tail:
return None
try:
text = tail.decode("utf-8", errors="ignore")
except Exception:
return None
matches = _PROMPT_TOKENS_RE.findall(text)
if not matches:
return None
try:
return int(matches[-1])
except ValueError:
return None
def main() -> int:
log_dir = os.path.dirname(LOG_PATH)
if log_dir and not os.path.isdir(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
except OSError:
pass
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.daemon_threads = True
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
log("Governor beendet (SIGINT).")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-27
View File
@@ -1,27 +0,0 @@
[Unit]
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
After=network-online.target mission-control-2.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/governor
ExecStart=/usr/bin/python3 %h/governor/governor.py
Restart=always
RestartSec=3
# --- Schwellen -------------------------------------------------------------
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
Environment=GOV_THRESHOLD=45000
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
Environment=GOV_LOG=%h/governor/governor.log
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
[Install]
WantedBy=default.target
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
# klein -> passthrough (Modell antwortet normal)
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_small.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_mid.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_big.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
PY
echo "=== TEST A: klein (passthrough) ==="
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
-13
View File
@@ -1,13 +0,0 @@
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
set -u
REPO="${1:?repo dir}"
MSG="${2:?message}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$REPO"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
--map-tokens 512 \
--read CONVENTIONS.md --read todo.py --read cli.py \
SAVEPOINT.md \
--message "$MSG"
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
set -u
MSGS="${1:?Nachrichtendatei angeben}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$HOME/governor-p0/testrepo"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update \
--no-show-model-warnings \
--no-analytics \
--yes-always \
--map-tokens 512 \
--read CONVENTIONS.md \
SAVEPOINT.md \
< "$MSGS"
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
set -u
MSGS="${1:?Nachrichtendatei angeben}"
REPO="${2:-$HOME/governor-p0/testrepo}"
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
cd "$REPO"
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Hermes Built-in Web UI (`hermes serve` — Agent-Dashboard/Chat, Loopback only)
After=network-online.target
Wants=network-online.target
[Service]
# Serviert die EINGEBAUTE Hermes-Web-GUI (die reiche Agent-Oberflaeche mit Threads/Tool-Calls, die
# auch die Electron-Desktop-App umhuellt) headless.
# SICHERHEIT: Bindet NUR an Loopback (127.0.0.1:9119) und laeuft hinter dem MC2-Reverse-Proxy
# (routers/hermes_ui.py → same-origin /hermes-ui/ auf dem offenen Port 9001) — KEINE eigene
# Firewall-Freigabe, KEIN Login (LAN-Trust wie Konsole/Terminal). --skip-build nutzt das von
# deploy.sh mit Vite-base=/hermes-ui/ vorgebaute web_dist, damit alle SPA-Pfade unter /hermes-ui/
# liegen und der Proxy sie sauber durchreichen kann.
Type=simple
Environment=HERMES_HOME=%h/.hermes
ExecStart=%h/.hermes/hermes-agent/venv/bin/hermes dashboard --no-open --skip-build --host 127.0.0.1 --port 9119
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env python3
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.
Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
Patches (re-)ein.
Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
Zeilenverschiebungen durch Updates, anders als ``git apply``):
* ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen.
* ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen.
* ``old`` 0x oder >1x -> Kontext hat sich durch ein Update
geaendert -> LAUT scheitern (Exit 1), damit
der Postcheck rot wird und niemand denkt,
der Fix sei noch aktiv.
Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
"""
import os
import py_compile
import shutil
import sys
from pathlib import Path
HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
HERE = Path(__file__).resolve().parent
# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
COPIES = [
("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
]
# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
PATCHES = [
{
"name": "0001-needs-input-wait",
"file": "hermes_cli/kanban_db.py",
"marker": 'kind not in ("needs_input", "capability")',
"old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
"new": (
" # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
" # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
" # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
" # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
" # fuer transient/untyped Blocks aktiv.\n"
' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
),
},
{
"name": "0002-decompose-at-readiness",
"file": "hermes_cli/kanban_decompose.py",
"marker": "Startreife-Gate",
"old": (
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
" # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
" # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
" # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
" # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
" with kb.connect_closing() as _pconn:\n"
" _parents = [r[0] for r in _pconn.execute(\n"
' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
" if _parents:\n"
' _ph = ",".join("?" * len(_parents))\n'
" _open = _pconn.execute(\n"
' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
" \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
" ).fetchone()[0]\n"
" if _open:\n"
" return DecomposeOutcome(\n"
" task_id, False,\n"
' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
" )\n"
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
},
{
"name": "0003-orphan-guard",
"file": "gateway/kanban_watchers.py",
"marker": "mc2_kanban_reaper",
"old": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
),
"new": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
" # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
" # Worker, deren Task nicht mehr 'running' ist (extern\n"
" # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
" # und verbrennen Rechenzeit.\n"
" try:\n"
" from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
" _orph = await asyncio.to_thread(\n"
" _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
" )\n"
" if _orph:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
" len(_orph), _orph,\n"
" )\n"
" except Exception:\n"
' logger.exception("kanban dispatcher: orphan reaper failed")\n'
),
},
{
"name": "0004a-context-inherit-compute",
"file": "hermes_cli/kanban_decompose.py",
"marker": '_parent_ctx = ""',
"old": (
" children: list[dict] = []\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
"new": (
" children: list[dict] = []\n"
" # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
" # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
" # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
' _parent_ctx = ""\n'
' if getattr(task, "body", None) and task.body.strip():\n'
" _parent_ctx = (\n"
' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
" + task.body.strip()[:1500]\n"
' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
" )\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
},
{
"name": "0004b-context-inherit-apply",
"file": "hermes_cli/kanban_decompose.py",
"marker": "_parent_ctx + body",
"old": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
),
"new": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
" body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n"
),
},
{
"name": "0005-create-chain-guard",
"file": "tools/kanban_tools.py",
"marker": "_MC2_RECENT_ROOTS",
"old": (
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
" # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
" # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
" # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
" # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
" # Verkettung harmlos (laeuft eh seriell).\n"
" try:\n"
" if triage and not parents and session_id:\n"
" import time as _mc2t\n"
' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
" _prev = _roots.get(session_id)\n"
" _now = _mc2t.monotonic()\n"
" if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
" try:\n"
" kb.link_tasks(conn, _prev[0], new_tid)\n"
" except Exception:\n"
" pass\n"
" _roots[session_id] = (new_tid, _now)\n"
" if len(_roots) > 64:\n"
" _roots.clear()\n"
" except Exception:\n"
" pass\n"
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
},
]
def main() -> int:
if not HA.is_dir():
print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
return 0
applied, skipped, failed = [], [], []
# --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
for src_rel, dst_rel in COPIES:
src, dst = HERE / src_rel, HA / dst_rel
if not src.is_file():
failed.append((src_rel, f"Quelle fehlt: {src}"))
continue
try:
same = dst.is_file() and dst.read_bytes() == src.read_bytes()
if same:
skipped.append(f"copy:{dst_rel}")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if dst.suffix == ".py":
py_compile.compile(str(dst), doraise=True)
applied.append(f"copy:{dst_rel}")
except Exception as exc:
failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
for p in PATCHES:
f = HA / p["file"]
if not f.is_file():
failed.append((p["name"], f"Datei fehlt: {f}"))
continue
src = f.read_text(encoding="utf-8")
if p["marker"] in src:
skipped.append(p["name"])
continue
n = src.count(p["old"])
if n != 1:
failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} "
"(Update hat den Kontext geaendert?)")))
continue
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
try:
py_compile.compile(str(f), doraise=True)
except Exception as exc:
failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
continue
applied.append(p["name"])
print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
for name, why in failed:
print(f" FEHLER {name}: {why}", file=sys.stderr)
if failed:
return 1 # Fehler → Deploy/Postcheck rot
if applied:
return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
return 0 # nichts zu tun (alles schon drin)
if __name__ == "__main__":
sys.exit(main())
@@ -1,84 +0,0 @@
"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker.
Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach
``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher
(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen.
Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B.
über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task``
``worker_pid = NULL`` der Worker-PROZESS läuft aber weiter. Die DB zählt ihn
nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker
möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren
Task nicht mehr ``running`` ist.
Linux-only (/proc-Scan). Best-effort: jeder Fehler leere Liste, nie werfen.
"""
from __future__ import annotations
import os
import re
import signal
import sys
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
def reap_orphaned_workers(connect_closing) -> list[tuple[int, str]]:
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste
der beendeten ``(pid, task_id)`` zurück.
"""
if sys.platform != "linux":
return []
candidates: dict[int, str] = {}
try:
entries = os.listdir("/proc")
except OSError:
return []
for entry in entries:
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/cmdline", "rb") as fh:
cmd = fh.read().replace(b"\x00", b" ")
except OSError:
continue
m = _TASK_RE.search(cmd)
if m:
candidates[int(entry)] = m.group(1).decode()
if not candidates:
return []
task_ids = list(set(candidates.values()))
placeholders = ",".join("?" * len(task_ids))
running: set[str] = set()
try:
with connect_closing() as conn:
running = {
row[0]
for row in conn.execute(
f"SELECT id FROM tasks WHERE id IN ({placeholders}) "
"AND status = 'running'",
task_ids,
)
}
except Exception:
return []
killed: list[tuple[int, str]] = []
for pid, task_id in candidates.items():
if task_id in running:
continue
# Prozessgruppe zuerst (Worker starten start_new_session=True → eigene
# Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit).
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except OSError:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
continue
killed.append((pid, task_id))
return killed
+15 -32
View File
@@ -1,35 +1,22 @@
#!/usr/bin/env bash
# Post-Update-Check nach einem Hermes-Agent-Update: läuft unser geteiltes „Gehirn"
# (Mem0 + die mc2-memory-Integration) noch? Exit 0 = alles ok, sonst 1 der Job wird
# im UI rot, damit ein kaputtes Gehirn sofort auffällt.
# Post-Update-Check nach einem Hermes-Agent-Update: laeuft unser "Gehirn" noch?
# Exit 0 = alles ok, sonst 1 -> der Job wird im UI rot, damit ein kaputtes Gehirn auffaellt.
#
# WICHTIG (27.08.2026) - VIER TOTE CHECKS ENTFERNT: bis hierher prueften die ersten Bloecke
# den abgeloesten Gedaechtnis-Sidecar (:8765), MC2s /api/memory und das zugehoerige
# Hermes-Plugin. Alle drei fielen am 07.08. weg (docs/wissen/VERDIKTE.md) - die Checks KONNTEN nicht
# mehr gruen werden. Folge: jedes Hermes-Update endete rot und wurde von autoupdate.sh
# zurueckgerollt. Kein Ersatz-Check auf Hermes native Gedaechtnis, weil es dafuer keinen
# nachgemessenen Endpunkt gibt - geraten waere eine Fassade. Der Tool-Smoke weiter unten
# laeuft ohnehin durch den ECHTEN Agenten und faengt ein kaputtes Gehirn.
set -uo pipefail
MC_URL="${MC_URL:-http://127.0.0.1:9001}"
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
fail=0
echo "=== Hermes Post-Update: Gehirn-Check ==="
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then
echo "PASS · Mem0-Sidecar erreichbar"
else
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
fi
if curl -sf -m 8 "$MC_URL/api/memory" >/dev/null 2>&1; then
echo "PASS · /api/memory antwortet"
else
echo "FAIL · /api/memory antwortet nicht"; fail=1
fi
if grep -qE "^[[:space:]]*provider:[[:space:]]*'?mc2-memory'?" "$HERMES/config.yaml" 2>/dev/null \
&& grep -qE "memory_enabled:[[:space:]]*true" "$HERMES/config.yaml" 2>/dev/null; then
echo "PASS · memory.provider=mc2-memory aktiv"
else
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
fi
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
@@ -46,15 +33,11 @@ if [ -f "$PATCH_APPLY" ]; then
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
fi
else
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
fi
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
>/dev/null 2>&1 ); then
echo "PASS · mc2-memory-Plugin lädt unter dem neuen Hermes"
else
echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1
# Kein Fehler: Der Patch-Traeger wurde mit dem MC2-Kahlschlag (1e68f62) entfernt. Seine
# Fixes zielten auf einen Hermes-Stand, der inzwischen tausende Commits zurueckliegt —
# sie wuerden auf dem heutigen Quellcode ohnehin nicht mehr greifen. WARN statt FAIL, damit
# es sichtbar bleibt, falls jemand wieder Runtime-Patches braucht.
echo "WARN · hermes-patches/apply.py nicht vorhanden (mit 1e68f62 bewusst entfernt) — uebersprungen"
fi
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---

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