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
273 changed files with 13989 additions and 17614 deletions
+28 -26
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,31 +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,
chronik,
connect,
console,
boxwart,
events,
gateway_proxy,
health,
hermes_ui,
ideen,
maintenance,
models,
radar,
routing,
skills,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import metrics_history, reminders, sentry, warmer
from services import metrics_history, reminders, warmer
from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
@@ -53,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()))
@@ -62,11 +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()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
# 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(
@@ -103,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(agent.router)
app.include_router(
voice.router
@@ -125,18 +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(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(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(skills.router) # Skills & Jobs Dashboard
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.
@@ -147,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:
+3 -1
View File
@@ -26,9 +26,11 @@ DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
"-c {ctx} -ngl 999 -fa on --load-mode none"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
+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())
+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 — 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)}
-17
View File
@@ -1,17 +0,0 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets für den Gateway."""
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) -> dict:
return build_snippets(host=host)
@router.get("/connect/health")
def connect_health() -> dict:
"""Live-Status der drei Leitungen (Gateway, Gedächtnis, Desktop-Gateway) 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)
+6 -24
View File
@@ -1,14 +1,10 @@
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
Zwei Endpunkte, ein Sammler:
GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
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).
GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
nur `/api/events` kennt. Danach entfernen.
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
@@ -56,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
@@ -133,14 +123,6 @@ _KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
"""Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
return StreamingResponse(_strom(request, mit_metrik=False),
media_type="text/event-stream", headers=_KOPF)
+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 Agentic IDE
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)
+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))
-102
View File
@@ -1,102 +0,0 @@
import os
import subprocess
import psutil
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api")
def _hermes_name(ordner: str) -> str:
"""Repo-Ordnername -> Hermes-Skillname.
deploy/deploy.sh kopiert `deploy/skills/<name>` nach `~/.hermes/skills/<name mit _>`
(`tr '-' '_'`), weil Hermes keine Bindestriche in Skillnamen mag. Ohne dieselbe
Umschreibung hier bekam Hermes den Ordnernamen MIT Bindestrich und fand den Skill
nicht - betroffen waren 7 der 12 Skills (betrieb-playbook, konzept-fliessband,
llm-wiki, morning-report, pc-pfad-cache, projekt-start, trend-radar): der Startknopf
meldete Erfolg, ausgefuehrt wurde nichts. deploy.sh bleibt die Quelle der Wahrheit.
"""
return ordner.replace("-", "_")
class RunSkillRequest(BaseModel):
skill_name: str
@router.get("/skills")
def list_skills():
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
current_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(os.path.dirname(current_dir))
skills_dir = os.path.join(repo_root, "deploy", "skills")
skills = []
# Check currently running hermes skill processes
running_skills = set()
for p in psutil.process_iter(['name', 'cmdline']):
try:
cmdline = p.info.get('cmdline')
if cmdline and any("hermes" in arg for arg in cmdline):
for arg in cmdline:
if "Führe den " in arg and " Skill aus" in arg:
# Extract skill name from "Führe den 'skill_name' Skill aus"
import re
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
if match:
running_skills.add(match.group(1))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
for entry in os.scandir(skills_dir):
if entry.is_dir():
# Suche nach SKILL.md für Metadaten
skill_md_path = os.path.join(entry.path, "SKILL.md")
description = ""
if os.path.exists(skill_md_path):
try:
with open(skill_md_path, "r", encoding="utf-8") as f:
# Lese erste Zeilen für Description (YAML Frontmatter)
lines = f.readlines()[:10]
for line in lines:
if "description:" in line.lower():
description = line.split(":", 1)[1].strip().strip('"\'')
break
elif "author:" in line.lower() and not description:
description = line.strip()
except Exception:
pass
skills.append({
"name": entry.name,
# Der Name, unter dem Hermes den Skill kennt - auch fuer den Laufend-
# Vergleich, weil im Prozess-cmdline genau dieser Name steht.
"hermes_name": _hermes_name(entry.name),
"description": description or "Keine Beschreibung verfügbar.",
"running": _hermes_name(entry.name) in running_skills
})
return {"skills": skills}
@router.post("/skills/run")
def run_skill(req: RunSkillRequest):
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
if not os.path.exists(hermes_bin):
# Fallback falls lokal (auf Windows) entwickelt wird
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat - mit dem Namen, den Hermes
# nach dem Deploy-Sync wirklich kennt (Bindestriche -> Unterstriche, s. _hermes_name).
skill = _hermes_name(req.skill_name)
cmd = [hermes_bin, "-z", f"Führe den '{skill}' Skill aus", "chat"]
try:
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"\n--- Starting Skill: {skill} ---\n")
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
except Exception as e:
return {"ok": False, "err": str(e)}
+4 -4
View File
@@ -94,10 +94,10 @@ def services() -> dict:
"ok": a["hermes_ui_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,
-151
View File
@@ -1,151 +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 re
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 or "traeume" 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.")
@router.get("/wissen/graph")
def graph_vault() -> dict:
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
if not _available():
return {"nodes": [], "edges": []}
nodes = []
edges = []
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
name_to_id = {}
# 1. Alle Nodes sammeln
for p in VAULT.rglob("*.md"):
if ".git" in p.parts or "traeume" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
name_to_id[p.stem.lower()] = rel
# Kategorie aus dem Ordner ableiten
cat = "knowledge"
parent = p.parent.name if p.parent != VAULT else ""
if parent == "traeume":
cat = "events"
elif parent == "muster":
cat = "rules"
elif parent == "skill-kandidaten":
cat = "identity"
nodes.append({
"id": rel,
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
"category": cat,
"source": "wiki"
})
# 2. Edges extrahieren (Wiki-Links [[Name]])
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
for n in nodes:
try:
target = VAULT / n["id"]
content = target.read_text(encoding="utf-8", errors="replace")
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
for link in found_links:
target_id = name_to_id.get(link)
if target_id and target_id != n["id"]:
edges.append({
"source": n["id"],
"target": target_id,
"weight": 1.0
})
except OSError:
pass
return {"nodes": nodes, "edges": edges}
+3 -1
View File
@@ -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
-156
View File
@@ -1,156 +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).
Gedächtnis ist hier bewusst KEINE eigene Leitung mehr: bis August 2026 gab es dafür einen
Memory-MCP-Server gegen MC2s /api/memory; seit dessen Ablösung (docs/wissen/VERDIKTE.md,
07.08.2026) führt Hermes sein Gedächtnis
selbst — es hängt am Agenten, nicht am Gateway. check_health() prüft es weiterhin (Leitung 2),
es ist nur nichts mehr zu konfigurieren.
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, 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) -> 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"'
)
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."},
},
}
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 = Natives Hermes-Gedächtnis (hermes-gateway),
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:
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
r = c.get("http://127.0.0.1:8642/health")
if r.status_code in (200, 404, 401):
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
else:
memory = {"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}
File diff suppressed because it is too large Load Diff
+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()
+22 -2
View File
@@ -11,6 +11,7 @@ import re
import subprocess
import time
from datetime import datetime
from pathlib import Path
import httpx
import psutil
@@ -19,8 +20,11 @@ from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "voice-service"}
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
"projekte-sync", "mc2-backup", "mc2-radar"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -510,6 +514,16 @@ def _summarize_hermes_commits(commits: list[dict]) -> dict:
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def _hermes_version(path: str) -> str | None:
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
try:
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
except OSError:
return None
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
return m.group(1) if m else None
def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
@@ -533,6 +547,7 @@ def hermes_update_details() -> dict:
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
info["commits"] = commits
info["behind"] = len(commits)
info["installed_version"] = _hermes_version(path)
if commits:
info.update(_summarize_hermes_commits(commits))
except Exception as exc:
@@ -717,10 +732,15 @@ def _hermes_update_cmd() -> str:
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
return ("RC=0; "
f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
f"&& {py} -m hermes_cli.main doctor "
f"&& {build_cmd}; }} || RC=1; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
+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
-164
View File
@@ -1,164 +0,0 @@
"""
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway,
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_KEY, HERMES_API_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", headers: dict[str, str] | None = None) -> bool:
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
und er haette einen echten Auth-Fehler unter sich begraben."""
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 _hermes_kopf() -> dict[str, str]:
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
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, "/v1/models", _hermes_kopf()),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."),
"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 und Briefkasten 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)
+20 -27
View File
@@ -8,6 +8,7 @@ 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
@@ -127,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": {}}
+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
+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])}
+8 -7
View File
@@ -1,15 +1,16 @@
"""
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
Bisher hingen Re-Warm-Wächter und Health-Wächter (sentry) 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
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
@@ -30,7 +31,7 @@ import logging
import os
from config import CONFIG_PATH
from services import sentry, warmer
from services import waechter, warmer
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
@@ -65,8 +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 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
@@ -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
+4
View File
@@ -48,7 +48,11 @@ main() {
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
# 4.5 Hermes Skills synchronisieren
echo "Synchronisiere Hermes Skills..."
+16
View File
@@ -43,6 +43,9 @@ if [ "${1:-}" = "--nur-stimme" ]; then
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
-o "$TON" -w '%{http_code}' 2>/dev/null)
# Sprechfassung wegraeumen (Grund siehe Textfassung unten) — auch wenn die Stimme scheiterte.
mv -f "$SPRECHDATEI" "${SPRECHDATEI%.txt}.gesendet.txt" 2>/dev/null || true
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
@@ -57,8 +60,16 @@ fi
# ------------------------------------------- Erster Durchgang: Text + Start ---
TEXTDATEI="${1:-/tmp/news-text.md}"
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
GESENDET="${TEXTDATEI%.md}.gesendet.md"
if [ ! -s "$TEXTDATEI" ]; then
# Zweiter Aufruf desselben Laufs, nachdem der erste den Bericht schon verschickt und
# weggeraeumt hat? Dann ist nichts zu tun (die Doppelversand-Sperre unten sieht die
# Datei nicht mehr).
if [ -s "$GESENDET" ] && [ $(( $(date +%s) - $(stat -c %Y "$GESENDET" 2>/dev/null || echo 0) )) -lt 7200 ]; then
echo "gemeldet (dieser Bericht ging bereits raus — nicht doppelt geschickt)"
exit 0
fi
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
exit 1
fi
@@ -78,6 +89,11 @@ find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
# Bericht wegraeumen (23.09.2026): Hermes' write_file ueberschreibt keine vorhandene Datei.
# Blieb der Bericht vom Vortag liegen, scheiterte jeder Morgenlauf erst an
# „Refusing to overwrite /tmp/news-text.md“ (4 von 5 Versuchen, jeden Tag seit Wochen).
mv -f "$TEXTDATEI" "$GESENDET" 2>/dev/null && touch "$GESENDET" 2>/dev/null || true
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
if [ -s "$SPRECHDATEI" ]; then
+16 -7
View File
@@ -9,8 +9,14 @@ models:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
# 17.09.2026: Uncensored-Variante (HauhauCS Aggressive, Q4_K_M) mit demselben giocom-DFlash-Draft. Prüfstand: 100,8 t/s
# @13k (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreißer), Hermes-Smoke grün. Original bleibt in
# Qwen3.6-35B-A3B-MTP-GGUF liegen — Rückweg = Pfad zurück.
# --reasoning-budget 2048 (17.09.2026): Deckel für Denk-Tokens — die abliterierte Variante hatte im Prüfstand 1/12
# Denk-Ausreißer ohne Antwort; mit Deckel wird daraus eine kürzer bedachte Antwort statt Stille. Normale Denkphasen
# des Hirns liegen bei ein paar hundert Tokens.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf --reasoning-budget 2048
ttl: 0
aliases:
- hermes
@@ -27,8 +33,11 @@ models:
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
# 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5)
# mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3,
# Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück.
cmd: |
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
ttl: 5400
aliases:
- coder
@@ -44,7 +53,7 @@ models:
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
cmd: |
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
ttl: 600
aliases:
- debugger
@@ -55,7 +64,7 @@ models:
context: 65536
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --load-mode none -c 8192
ttl: 0
aliases:
- embed
@@ -63,7 +72,7 @@ models:
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --jinja --cache-reuse 256 -cram 16384
ttl: 600
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
@@ -74,7 +83,7 @@ models:
context: 32768
Qwen3-VL-30B-A3B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 900
aliases:
- vision
@@ -87,7 +96,7 @@ models:
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
cmd: |
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --load-mode none -c 8192
ttl: 0
aliases:
- reranker
+28
View File
@@ -0,0 +1,28 @@
# systemd-USER-Unit für das Modell-Radar (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
# Sucht höchstens einmal am Tag nach neuen Modellen für Hirn und Coder und testet nachts im Fenster
# 00:3002:30 höchstens einen neuen Kandidaten pro Woche (backend/radar_lauf.py, services/radar.py).
# Um 03:00 braucht der NerdQuiz-Nachtlauf das Hirn: Der Lauf hört um 02:30 selbst auf, um 02:35 zieht
# seine Notbremse, und RuntimeMaxSec beendet ihn spätestens 02:40 samt llama-server (control-group).
# Type=simple statt oneshot: Bei oneshot greift RuntimeMaxSec nicht.
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
# Ablage: ~/.config/systemd/user/mc2-radar.service (+ mc2-radar.timer) ; dann:
# systemctl --user daemon-reload && systemctl --user enable --now mc2-radar.timer
[Unit]
Description=MC2 Modell-Radar (Suche + nächtlicher Prüfstand 00:3002:30)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python radar_lauf.py
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
Environment=MC_LOCAL_TZ=Europe/Berlin
# Kein Nice=: der Kandidaten-Server erbt die Priorität — gemessen wird gegen das Live-Modell.
RuntimeMaxSec=2h10min
KillMode=control-group
TimeoutStopSec=60
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Nächtliches Modell-Radar (00:30, Test-Fenster bis 02:30)
[Timer]
OnCalendar=*-*-* 00:30:00
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+10
View File
@@ -91,6 +91,16 @@ for name in $REPOS; do
continue
fi
# Leeres Repo auf Gitea (angelegt, aber nie etwas gepusht): Es gibt keinen Branch, also
# scheitert jeder Pull mit „no such ref was fetched“. So lief projekte-sync ab dem
# 07.09.2026 stuendlich rot (TTT2-Toolbox_ClaudeEdition). Leer ist kein Fehler.
# Scheitert ls-remote selbst (Netz weg), geht es normal weiter und der Pull meldet es.
if kopf="$(git -C "$checkout" ls-remote --heads origin 2>/dev/null)" && [ -z "$kopf" ]; then
log " - $name: auf Gitea noch leer — nichts zu holen"
verknuepfe "$checkout" "$name"
continue
fi
if [ $DRY -eq 1 ]; then
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
else
+27
View File
@@ -0,0 +1,27 @@
{
"_hinweis": "Merkliste des Modell-Radars (backend/services/radar.py). Einträge hier werden vor den Funden der Hugging-Face-Entdeckung getestet, in dieser Reihenfolge. Felder: name, rolle (hirn|coder), repo, quant (Standard Q4_K_M), eng (true = passt nur knapp neben das Warm-Set), notiz (erscheint in der Begründung), ueberspringen (Grund; Eintrag wird nicht getestet), optional draft {repo, datei, typ, n_max} und flags (zusätzliche llama-server-Flags). Getestet wird nur, was ein mmproj hat und neben das Warm-Set passt (27 GB + Kandidat inkl. KV-Cache <= 115 GB).",
"kandidaten": [
{
"name": "Ornith-1.5-35B-A3B",
"rolle": "hirn",
"repo": "ornith-ai/Ornith-1.5-35B-A3B-GGUF",
"quant": "Q4_K_M",
"notiz": "MoE mit 3B aktiven Parametern (Architektur qwen35moe), mmproj im Repo."
},
{
"name": "Qwen3.8-Flash-Next",
"rolle": "coder",
"repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
"quant": "UD-IQ3_XXS",
"eng": true,
"notiz": "UD-IQ3_XXS ist ca. 82 GB groß und passt nur knapp neben das Warm-Set (UD-IQ4_XS mit 94 GB passt nicht)."
},
{
"name": "K2-Horizon-MoVA-36B-A4B",
"rolle": "hirn",
"repo": "IFM/K2-Horizon-MoVA-36B-A4B-GGUF",
"quant": "Q4_K_M",
"ueberspringen": "läuft noch nicht im offiziellen llama.cpp."
}
]
}
+13 -1
View File
@@ -53,6 +53,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
- **`pkill -f` über SSH matcht die eigene Remote-Shell** → Muster als `'[g]en…'` klammern.
- **Deploy restartet voice-service** → Übernacht-Jobs mit STT-QA-Gate brauchen
Wiederanlauf-Logik (Backoff-Retry im hear()-Pfad).
- **llama.cpp streicht Flags ohne Vorwarnung:** `--no-mmap` gibt es seit b10936 nicht mehr
(`error: invalid argument`) → JEDES Modell stirbt 2 s nach dem Start, llama-swap meldet nur
„upstream command exited prematurely", der Stack-Check nur „rot". Ersatz: `--load-mode none`
(gleiche Leistung, gemessen 17.09.2026: Coder 32 t/s, Hirn 85 t/s). Vor einem Engine-Sprung
jede Config-Kommandozeile mit dem neuen `llama-server` parsen lassen (Port 5899, `timeout 6`,
`${PORT}` ersetzen) — Argumentfehler kommen sofort, vor dem Laden.
## Hermes
@@ -68,7 +74,7 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
- **Cron-Fallen:** kein TZ-Feld (next_run_at = System-TZ zum Anlege-Zeitpunkt; nach
TZ-Wechsel `cron edit --schedule "<gleich>"` zum Neuberechnen) · Prompt/Positionsargumente
MÜSSEN vor die Flags · `--script` allein reicht nicht (braucht Prompt oder Skill) ·
Cron-Kontext hat `HERMES_CRON_SESSION=1` und `approvals.cron_mode: deny`.
Cron-Kontext hat `HERMES_CRON_SESSION=1` (`approvals.cron_mode` ist seit dem KISS-Umbau `auto`, nicht mehr `deny`).
- **Feed-Skripte: Pipe + Heredoc gleichzeitig = Heredoc gewinnt stdin** → JSON via Temp-Datei
übergeben. Außerdem ehrlichen Blind-Pfad einbauen (API kaputt → „AUSGEFALLEN", nicht „0 Funde").
- **Hooks:** Consent persistiert NUR über CLI-/Gateway-Start mit `HERMES_ACCEPT_HOOKS=1`;
@@ -84,6 +90,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
Erst `git add -A`, dann `git diff --cached`, „Diff leer → STOPP".
- **Werkstatt-Gate:** `git -C ~/mission-control-v2 status --porcelain -uno` MUSS leer sein
(curl /api/health allein ist ein Loch — alter Code läuft im RAM weiter).
- **`hermes update` meldet Exit 1 trotz Erfolg:** nach seinem eigenen Gateway-Neustart erwartet es
Zeilen vom „Fleet version check"; unter systemd kommen keine → Exit 1 (#93406). Der MC2-Job
bricht dann die `&&`-Kette ab (kein doctor, UI ohne `/hermes-ui/`-Basis), autoupdate.sh rollt
zurück und PINNT — obwohl der Gehirn-Check grün war (06. und 17.09.2026). Deshalb
`--no-gateway-restart`: Neustart und Urteil gehören dem Job. Pins stehen in
`/srv/models/mc2-pins.json` und werden NUR von Hand gelöst — gepinnt = still eingefroren.
## Lucy / Windows-PC
+10 -2
View File
@@ -62,8 +62,8 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
## Lucy
- **Raphael-Umbau (04.09.2026) — zwei Branches offen, Reihenfolge zählt (Branch → Ampel →
User-Merge → deploy.sh bzw. `npm run dist`, NICHT übers Auftragsbuch):** erst MC2
- **Raphael-Umbau (04.09.2026) — AUSGEROLLT 19:10 (MC2 `f9f2e11` deployt, Lucy `be686c4` gebaut),
Details [RAPHAEL.md](RAPHAEL.md) „Ausgerollt". Ursprünglicher Plan:** erst MC2
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
@@ -76,9 +76,17 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
blieben: `deploy/werkstatt-SOUL.md` + `projektstart-SOUL.md` (Werkstatt-Persona, seit Kanban-Aus
ohnehin tot — eigener Aufräum-Faden), Chronik-Kategorie „auftragsbuch" (historische Einträge).
Auf der Box: `mc2-bagatell.timer` deaktiviert, PC-Executor-URL in `~/.hermes/config.yaml` auf .22.
- **Ampel-Runner tot seit 28.08.:** alle Läufe „Waiting to run", kein act_runner auf der Box, Gitea-LXC 104
prüfen (`act_runner`-Dienst). Bis dahin gelten lokale Gates.
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
- **Nächster Stimm-Kandidat: Qwen3-TTS über audio.cpp (Vulkan)** — Einstieg liegt bereit: Binaries
v0.7.1 unter `~/audiocpp-test/bin` auf der Box (audiocpp_cli/audiocpp_server, Vulkan), Modelle von
`huggingface.co/audio-cpp/audio.cpp-gguf` (Qwen3-TTS-12Hz-1.7B-Base q8_0 2,7 GB; CustomVoice/VoiceDesign
ebenfalls), CLI: `--task tts --family qwen3_tts --backend vulkan --voice-ref <wav> --reference-text <Transkript>
--language de`. Prüfstand: `lucy-tts/referenz/zonos2_bench.py` um einen audio.cpp-Aufrufer erweitern
(Server: `POST /v1/audio/speech`). Messlatte = pocket neu: TTFB 1,25 s, RTF 0,97, WER 10 %, 0 Kollaps.
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
+17
View File
@@ -106,6 +106,23 @@ beschreibt nur noch den manuellen Weg. Wer `wartung/auftragsbuch-ausbau` merged,
**Nächster Hebel (braucht User-Ja, systemd-Unit):** `Environment=LUCY_WORKERS=2` und
`OMP_NUM_THREADS` 4 → 8 in `lucy-stimme.service` — der PC-Pfad lief mit 2 Workern.
## Ausgerollt (04.09.2026, 19:10, auf User-Anweisung „deploye erstmal den Stand")
- **MC2 main = `f9f2e11`** (Proxy `/api/lucy/stimme/*` + Auftragsbuch-Ausbau + Doku), per `deploy.sh`
auf der Box; Health, Proxy-Health (workers=2), Frontend, alle Dienste grün. Die Ampel war seit
28.08. ohne Runner („Waiting to run") — Gate waren die lokalen Läufe: eslint 0 Fehler, 59 Tests,
tsc + vite build, py_compile, ruff.
- **Lucy main = `be686c4`**, dist am PC gebaut (`npm ci && npm run dist`), Box-Checkout `~/lucy` nachgezogen,
`~/.lucy-stimme/app/pocket_server.py` identisch mit dem Repo (Sperren-Fix live).
- **Stimmserver:** Referenz **Artoria v2**, `OMP_NUM_THREADS=8`, `LUCY_WORKERS=2` (User-Ja); gemessen
TTFB median 1,25 s (vorher 2,04), RTF 0,97 (vorher 1,64), 0 Kollaps, 0 Drift, Ähnlichkeit 0,98.
‼️ Die Box kann NICHT klonen (nur das Modell ohne Voice-Cloning im Cache, kein HF-Token) — Klon-Zustände
werden am PC exportiert (Anleitung `lucy-tts/referenz/README.md`, Abschnitt 5). Rückfall-Dateien
(`ref.mp3.bak-saber-alt`, `lucy_voice_saber-alt.safetensors`, Unit-Backup) liegen daneben.
- **ZONOS2 geprüft und verworfen** (Sandkasten `~/zonos2-test`, Server gestoppt): Vulkan-Tempo top
(TTFB 0,88 s), aber deutscher Klon unzuverlässig (WER 35 %, 8/30 Läufe Brabbeln, kühlerer Sampler
schlimmer). Nächster Kandidat: Qwen3-TTS über audio.cpp (Vulkan), gleicher Prüfstand.
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
+28 -13
View File
@@ -1,7 +1,8 @@
# Stack-Wahrheiten — Infrastruktur, Dienste, Modelle
_Live gegen die Box verifiziert am **19.08.2026** (Dienste-Liste, llama-swap-Config, hermes
--version v0.20.4, llama.cpp b10502, /api/health). Bei Widerspruch zu älteren Docs gewinnt
--version, /api/health); Engine, Hermes und llama-swap-Config nachgemessen am **17.09.2026**
(llama.cpp b11026, Hermes v0.21.3, `--load-mode none`). Bei Widerspruch zu älteren Docs gewinnt
diese Datei. Wer sie ändert: erst messen, dann schreiben._
## Maschinen & Zugänge
@@ -28,11 +29,11 @@ diese Datei. Wer sie ändert: erst messen, dann schreiben._
| Dienst | Port | Zweck |
|---|---|---|
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b10502) |
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b11026 seit 17.09.) |
| `mission-control-2` (User) | `:9001` | MC2 Cockpit (FastAPI + React Frontend) & Steuerpult |
| `mc2-gateway` (User) | `:9010` | `/v1`-Datenpfad (model:auto Routing + native Bildweiche) |
| `mc2-steward` (User) | — | Hintergrundwächter (Re-Warm, Health-Sentry, Mem0-Dedupe) |
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.20.4, Bot Mode) |
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.21.3, Bot Mode) |
| `hermes-builtin-ui` (User) | `:9119` (loopback) | Eingebaute Hermes-GUI; LAN-Zugang via MC2-Proxy `/hermes-ui/` |
| `mem0-service` (User) | `:8765` | Semantischer Chroma-Gedächtnis-Sidecar |
| `voice-service` (User) | `:8650` | STT (faster-whisper) + TTS (Piper/Chatterbox) |
@@ -48,15 +49,22 @@ Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauer
| Alias/Rolle | Modell | ttl | Notizen |
|---|---|---|---|
| `hermes` + `fast` | Qwen3.6-35B-A3B (UD-Q4_K_M, DFlash) | 0 | Agent-Hirn. `-c 131072 --parallel 2`**65536/Slot**, KV **q8_0**, ~7090 t/s |
| `coder` | Qwen3.8-27B (Q4_K_M, mmproj BF16) | 5400 | **131072 ctx**`--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, ~12,7 t/s (dicht = bandbreitengebunden) |
| `hermes` + `fast` | Qwen3.6-35B-A3B **Uncensored** (HauhauCS Aggressive, Q4_K_M, giocom-DFlash) — seit 17.09.2026 | 0 | Agent-Hirn. `-c 131072 --parallel 2`**65536/Slot**, KV **q8_0**, ~94101 t/s (Prüfstand 17.09.). Original (MTP-GGUF UD-Q4_K_M) liegt daneben, Rückweg = Pfad |
| `coder` | Qwen3.8-27B **Uncensored** (JonathanColetti/Heretic, Q4_K_M, eigene mmproj F16) — seit 17.09.2026 | 5400 | **131072 ctx**`--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, **~32 t/s mit DFlash2-Draft** (17.09.), ohne Draft 12,7 (dicht = bandbreitengebunden) |
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026.
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026 (Pfade 17.09.2026).
>
> **Prüfstand 17.09.2026** (`deploy/bench/pruefstand-hirn.py`; Kandidat auf `:5899`, Hermes spricht per `--provider pruefstand`
> aus `providers:` der Hermes-Config dagegen — Live-Stack bleibt unberührt): Uncensored-3.6 = 100,8 t/s @13k (Original 92,8),
> Tools 11/12, Hermes-Smoke grün · Uncensored-27B = 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3 · **Nemotron 3.5 Lightning
> 30B-A3B** (Q4_0 + MTP-Draft, `/srv/models/Nemotron-3.5-Lightning-30B-A3B-GGUF/`) = Hirn-Klasse: 10,5 s Prefill @13k, 91,8 t/s,
> Tools 3/3, Hermes grün — liegt als Reserve-Kandidat bereit, nicht besetzt. ‼ **Bild + DFlash2 = HTTP 500** („failed to process
> speculative batch") beim Coder — Vorbestand seit 04.09., unabhängig vom Modell; Bilder laufen über die Gateway-Bildweiche zu `vision`.
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
@@ -68,11 +76,15 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
## Hermes (Agent)
- **v0.20.6** (2026.8.27, git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
- **v0.21.3** (2026.9.14; git-Install `~/.hermes/hermes-agent`, main @ dd13b475 vom 17.09.2026), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
- `approvals.mode: smart`, `approvals.cron_mode: deny`.
- `approvals.mode: 'off'`, `approvals.cron_mode: auto`**User-Entscheid** (so seit spätestens dem KISS-Umbau
21.08.2026, bestätigt 17.09.2026: „approvals bleibt off"). Lucy fragt nicht nach Freigaben; die Grenze ziehen
ufw, PC-Executor-Token und command_allowlist (siehe Security). Frühere Fassungen nannten `smart`/`deny` (Stand 03.07.).
- Config-Schema **v45** (migriert 17.09.2026): `connections`-Toolset (Nous-Konnektoren, `manage_connections`) bewusst in
`agent.disabled_toolsets`; Curator archiviert ungenutzte Skills nach 30 Tagen (`skills/.archive/`, `hermes curator restore`).
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
@@ -97,10 +109,12 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
| 10 9, 16.08. | **Gedaechtnis-Check** (einmalig: Gedächtnis-Schutt räumen, alte Einträge bereinigen) |
| So 04:30 | **Auto-Update** Router→Engine→Hermes (Fangnetz: Backup→Postcheck→Auto-Rollback+Pin) |
**`mc2-autoupdate.timer` ist AN (User-Entscheid 10.07.,** ersetzt „nur bestätigt" vom 04.07.):
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen; der User klickt sie
zusätzlich oft manuell im Wartungs-Drawer. deploy.sh enablet den Timer bewusst NICHT selbst —
der Schaltzustand wird nur von Hand geändert. Manueller Lauf: `bash deploy/autoupdate.sh`.
**Sonntags-Update = Hermes-Cron `0ec52231783b`** (`~/.hermes/scripts/sonntags-update.sh`
`deploy/autoupdate.sh`, seit dem KISS-Umbau 21.08.); `mc2-autoupdate.timer` ist disabled — gewollt.
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen (User-Entscheid 10.07.); der
User klickt sie zusätzlich oft manuell im Wartungs-Drawer. Rot ⇒ Rollback + Pin in
`/srv/models/mc2-pins.json`; gepinnte Ebenen werden **still übersprungen**, bis der Pin von Hand
gelöst wird (06.17.09.2026 stand die Box so zwei Wochen still). Manueller Lauf: `bash deploy/autoupdate.sh`.
OS-Sicherheitsupdates laufen separat via unattended-upgrades.
## Security (Stufe 0, live)
@@ -109,7 +123,8 @@ PC-Executor nur mit Bearer `HERMES_PC_TOKEN` (fail-closed) · ufw default deny,
LAN→22/9001/8080 (+Alt-Port 7681) · Prompt-Injection-Guard `mcp/guard.py` (wrappt
Web-/Bildschirm-Text) · mc2-memory lernt nicht aus untrusted Turns · command_allowlist =
5 exakt gepinnte curls (fnmatch; Shell-Operatoren hart geblockt — NIE offenes `curl *`) ·
Box-sudo braucht Passwort; NOPASSWD nur für update-engine/swap (`/etc/sudoers.d/mc2-autonomie`).
Box-sudo ist **NOPASSWD: ALL** (live `sudo -n -l`, 04.09. und 17.09.2026); die alte Whitelist in
`/etc/sudoers.d/mc2-autonomie` (update-engine/swap, Dienst-Restarts, journalctl) steht daneben weiter.
**Security-Config (approvals/Tokens/ufw) NIE ohne explizites User-Ja ändern.**
## Deploy & Pipeline
+4 -2
View File
@@ -84,8 +84,10 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
- **Zed ACP / Hermes-im-Editor: NICHT machen.** Editor-Agent + Box-coder-Modell + AGENTS.md
ist der Sweet Spot; Hermes dazwischen = 70-KB-Prompt-Overhead. (Zed selbst ist inzwischen
vom PC entfernt — Hermes Desktop übernahm.)
- **`approvals.cron_mode: deny` bleibt** (Autonomie-Härtung 1c, User wählte Option A):
Gefahr-Befehle + execute_code sind im Cron-/Autonom-Kontext geblockt, interaktiv `smart`.
- **Approvals bleiben AUS** (`approvals.mode: 'off'`, `cron_mode: auto`; so seit spätestens dem KISS-Umbau
21.08.2026, vom User am 17.09.2026 bestätigt). Die Schutzlinie liegt außen — ufw, PC-Executor-Token,
command_allowlist — nicht in der Freigabe-Frage. *Überholt damit:*`approvals.cron_mode: deny` bleibt"
(Autonomie-Härtung 1c, Option A: Gefahr-Befehle + execute_code im Cron-Kontext geblockt, interaktiv `smart`).
- **Fremd-Kritik via `fremdblick.sh`** (Ein-Schuss an leichtes Fremdmodell), NIE via
delegate_task→heavy (64k-Floor + Lade-Tod). Raster: prose=GLM-4.7-Flash (kritiker),
code=Coder-Next. Harte Regel: **REPRODUZIERT-ODER-ABGELEHNT** — Freispruch nur mit
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{a as e,c as t,i as n,s as r,t as i,u as a}from"./button-Dd8hfusv.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-CgRgLCFz.js";var p=r();a();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,r),t[3]=r,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(t){let r=(0,p.c)(15),a,o,s,l;r[0]===t?(a=r[1],o=r[2],s=r[3],l=r[4]):({className:o,children:a,showCloseButton:l,...s}=t,r[0]=t,r[1]=a,r[2]=o,r[3]=s,r[4]=l);let d=l===void 0||l,f;r[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),r[5]=f):f=r[5];let h;r[6]===o?h=r[7]:(h=n(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),r[6]=o,r[7]=h);let v;r[8]===d?v=r[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(i,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(e,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),r[8]=d,r[9]=v);let y;return r[10]!==a||r[11]!==s||r[12]!==h||r[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[a,v]})]}),r[10]=a,r[11]=s,r[12]=h,r[13]=v,r[14]=y):y=r[14],y}function y(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col gap-2`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),r,a,o,s;t[0]===e?(r=t[1],a=t[2],o=t[3],s=t[4]):({className:a,showCloseButton:s,children:r,...o}=e,t[0]=e,t[1]=r,t[2]=a,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===a?u=t[6]:(u=n(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,a),t[5]=a,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(i,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==r||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[r,d]}),t[9]=r,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`font-heading text-base leading-none font-medium`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function C(e){let t=(0,p.c)(26),{offen:n,titel:r,text:a,knopf:o,gefahr:s,onJa:c,onNein:l,children:u}=e,d;t[0]===l?d=t[1]:(d=e=>!e&&l(),t[0]=l,t[1]=d);let f;t[2]===r?f=t[3]:(f=(0,m.jsx)(x,{className:`schild text-xl`,children:r}),t[2]=r,t[3]=f);let g;t[4]===a?g=t[5]:(g=(0,m.jsx)(S,{className:`text-[15px] leading-relaxed text-text-2`,children:a}),t[4]=a,t[5]=g);let _;t[6]!==f||t[7]!==g?(_=(0,m.jsxs)(y,{children:[f,g]}),t[6]=f,t[7]=g,t[8]=_):_=t[8];let C;t[9]===l?C=t[10]:(C=(0,m.jsx)(i,{variant:`outline`,onClick:l,children:`Abbrechen`}),t[9]=l,t[10]=C);let w=s?`gefahr`:`default`,T;t[11]!==o||t[12]!==c||t[13]!==w?(T=(0,m.jsx)(i,{variant:w,onClick:c,children:o}),t[11]=o,t[12]=c,t[13]=w,t[14]=T):T=t[14];let E;t[15]!==C||t[16]!==T?(E=(0,m.jsxs)(b,{className:`gap-2 sm:gap-2`,children:[C,T]}),t[15]=C,t[16]=T,t[17]=E):E=t[17];let D;t[18]!==u||t[19]!==_||t[20]!==E?(D=(0,m.jsxs)(v,{className:`border-linie bg-panel sm:max-w-lg`,children:[_,u,E]}),t[18]=u,t[19]=_,t[20]=E,t[21]=D):D=t[21];let O;return t[22]!==n||t[23]!==d||t[24]!==D?(O=(0,m.jsx)(h,{open:n,onOpenChange:d,children:D}),t[22]=n,t[23]=d,t[24]=D,t[25]=O):O=t[25],O}export{C as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
+1
View File
@@ -0,0 +1 @@
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{u,ae as m,k as x,j as s,y as n,af as b,b as p,n as f,q as h}from"./index-DMY9lUCb.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
-1
View File
@@ -1 +0,0 @@
import{O as h,u as g,P as p,r as d,j as e,b as x,T as j,S as N,n as v,U as m,V as w,q as f}from"./index-DMY9lUCb.js";import{E as k}from"./external-link-DcJao8h4.js";import{R as y}from"./refresh-cw-hAcZthO8.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-B-ORi4lf.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{c as e,s as t}from"./button-Dd8hfusv.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-DWG2Q04V.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`w-full border-linie bg-panel sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
-1
View File
@@ -1 +0,0 @@
import{j as e}from"./index-DMY9lUCb.js";function n({icon:t,children:s}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(t,{className:"h-3.5 w-3.5"})," ",s]})}export{n as S};
-6
View File
@@ -1,6 +0,0 @@
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a6 as Q,a7 as A,a8 as M,a9 as B,aa as P,u as J,r as f,ab as F,ac as I,c as T,ad as U,j as s,J as D,T as H,b as K,L,Q as V,q as _,n as R}from"./index-DMY9lUCb.js";import{J as z}from"./JobsBar-95gsUJZs.js";var c,g,o,h,m,w,C,q,G=(q=class extends Q{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),A(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??B();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(F)},[n]);if(l.error&&I(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=J(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:_.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(z,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(V,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft ":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as r}from"./index-DMY9lUCb.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=r("ArrowRight",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]);export{o as A};
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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