phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ampel / ampel (push) Successful in 26s

Ballast raus:
- 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy,
  system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan,
  voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60.
- Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt
  10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/.
- Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext-
  Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...).

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:08:44 +02:00
co-authored by Claude Opus 5.5
parent c8718fcde0
commit e9f488b56c
42 changed files with 799 additions and 2197 deletions
+15 -9
View File
@@ -21,10 +21,9 @@ import httpx
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from routers import ( from routers import (
agent,
boxwart, boxwart,
events, events,
gateway_proxy, gateway_proxy,
@@ -39,7 +38,8 @@ from routers import (
zeitmaschine, zeitmaschine,
) )
from routers import reminders as reminders_router from routers import reminders as reminders_router
from services import metrics_history, reminders, warmer from services import reminders, warmer
from services.herkunft import erlaubt
from starlette.requests import Request from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
@@ -63,14 +63,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL, warmer.INTERVAL,
) )
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und # Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
# Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern. # gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
if os.environ.get("MC_PROBELAUF", "") != "1": if os.environ.get("MC_PROBELAUF", "") != "1":
tasks.append(asyncio.create_task(reminders.reminders_loop())) 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: else:
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.") log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # 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). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
@@ -96,6 +94,15 @@ app.add_middleware(
) )
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
@app.middleware("http")
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
return JSONResponse({"detail": "Diese Aktion geht nur von der Box-Wart-Seite selbst aus."}, status_code=403)
return await call_next(request)
@app.middleware("http") @app.middleware("http")
async def no_cache( async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
@@ -113,7 +120,6 @@ app.include_router(models.router)
app.include_router(routing.router) app.include_router(routing.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(agent.router)
app.include_router( app.include_router(
voice.router voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) ) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
-19
View File
@@ -9,8 +9,6 @@ Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
import os import os
from pathlib import Path from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) ----------------------------------------------------- # --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
@@ -103,14 +101,6 @@ VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:865
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter # UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy. # /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/") LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit # Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback: # Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein # ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
@@ -123,14 +113,9 @@ BOX_CONSOLE_PATH = "/console/"
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. # liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/") HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/" HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
# --- Server ------------------------------------------------------------------ # --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000")) PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; # Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher. # im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
@@ -138,7 +123,3 @@ FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resol
# Version (Phase 0 — Greenfield-Skeleton). # Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8" VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
-57
View File
@@ -1,57 +0,0 @@
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from config import CONFIG_PATH
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
-52
View File
@@ -1,52 +0,0 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import agent_aktivitaet
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
class SetBrainReq(BaseModel):
model_id: str
@router.get("/agent/status")
def status() -> dict:
return agent_status()
@router.get("/agent/brain")
def brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
return hermes_brain_info()
@router.post("/agent/brain/set")
def set_brain(body: SetBrainReq) -> dict:
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
res = set_agent_brain(body.model_id)
if not res.get("ok"):
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
return res
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
@router.get("/agent/aktivitaet")
def aktivitaet(limit: int = 60) -> dict:
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
"""
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
+23 -10
View File
@@ -5,7 +5,6 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
GET /api/hinweise Hinweise + Verlauf des Wächters GET /api/hinweise Hinweise + Verlauf des Wächters
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen 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/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 GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
@@ -40,11 +39,12 @@ router = APIRouter(prefix="/api", tags=["boxwart"])
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_updates_lock = threading.Lock() _updates_lock = threading.Lock()
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False} _updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
UPDATES_CACHE_S = 600 UPDATES_CACHE_S = 600
def _updates_holen() -> None: def _updates_holen() -> None:
stand = maintenance.update_stand
try: try:
daten = maintenance.updates() daten = maintenance.updates()
except Exception: except Exception:
@@ -53,6 +53,7 @@ def _updates_holen() -> None:
if daten is not None or _updates_cache["daten"] is None: if daten is not None or _updates_cache["daten"] is None:
_updates_cache["daten"] = daten or {} _updates_cache["daten"] = daten or {}
_updates_cache["ts"] = time.time() _updates_cache["ts"] = time.time()
_updates_cache["stand"] = stand
_updates_cache["laeuft"] = False _updates_cache["laeuft"] = False
@@ -61,7 +62,10 @@ def _updates_gecacht() -> dict:
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
zunächst nichts), frisch geholt wird im Hintergrund.""" zunächst nichts), frisch geholt wird im Hintergrund."""
with _updates_lock: with _updates_lock:
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S # Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
# statt bis zu 10 Minuten „Aktualisieren“ für schon eingespielte Updates zu zeigen (24.09.2026).
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
or _updates_cache["stand"] != maintenance.update_stand)
if veraltet and not _updates_cache["laeuft"]: if veraltet and not _updates_cache["laeuft"]:
_updates_cache["laeuft"] = True _updates_cache["laeuft"] = True
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start() threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
@@ -83,6 +87,15 @@ def _bausteine(u: dict) -> list[dict]:
return liste return liste
NAMEN_UNKLAR = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
def _unklar(u: dict) -> list[dict]:
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt“ statt „aktuell“."""
return [{"id": k, "name": NAMEN_UNKLAR.get(k, k), "grund": grund}
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
def _zeit_kurz(ts: float | None) -> str: def _zeit_kurz(ts: float | None) -> str:
if not ts: if not ts:
return "" return ""
@@ -143,12 +156,17 @@ def _lampen(stand: dict, u: dict) -> list[dict]:
n = len(_bausteine(u)) n = len(_bausteine(u))
fest = len(update_verlauf.festgehalten()) fest = len(update_verlauf.festgehalten())
unklar = len(_unklar(u))
if fest: if fest:
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten")) lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
elif _updates_cache["daten"] is None: elif _updates_cache["daten"] is None:
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft")) lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
elif n:
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
elif unklar:
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
else: else:
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell")) lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
return lampen return lampen
@@ -183,7 +201,7 @@ def start() -> dict:
"verlauf": stand["verlauf"][:20], "verlauf": stand["verlauf"][:20],
"lampen": _lampen(stand, u), "lampen": _lampen(stand, u),
"box": _box(), "box": _box(),
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [], "updates": {"bausteine": _bausteine(u), "unklar": _unklar(u),
"festgehalten": update_verlauf.festgehalten(), "festgehalten": update_verlauf.festgehalten(),
"gelesen": _updates_cache["daten"] is not None}, "gelesen": _updates_cache["daten"] is not None},
"flugplan": zeitplan.flugplan(), "flugplan": zeitplan.flugplan(),
@@ -208,11 +226,6 @@ def nutzung() -> dict:
return modell_nutzung.nutzung() return modell_nutzung.nutzung()
@router.get("/zeitplan")
def zeitplan_route() -> dict:
return zeitplan.flugplan()
@router.get("/updates/verlauf") @router.get("/updates/verlauf")
def updates_verlauf(anzahl: int = 8) -> dict: def updates_verlauf(anzahl: int = 8) -> dict:
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box).""" """Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
+6 -6
View File
@@ -28,7 +28,6 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from config import MODELS_DIR
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -69,8 +68,6 @@ def _fingerprints() -> dict[str, object]:
) )
except Exception: except Exception:
pass pass
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp return fp
@@ -80,7 +77,9 @@ async def _strom(request: Request, mit_metrik: bool):
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
ein globaler Snapshot würde bei mehreren Clients Events verschlucken. ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
""" """
alt = _fingerprints() # Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
alt = await asyncio.to_thread(_fingerprints)
yield ": verbunden\n\n" yield ": verbunden\n\n"
seit_abdruck = 0.0 seit_abdruck = 0.0
@@ -97,7 +96,8 @@ async def _strom(request: Request, mit_metrik: bool):
if mit_metrik: if mit_metrik:
try: try:
from services.system import metrik_punkt from services.system import metrik_punkt
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n" punkt = await asyncio.to_thread(metrik_punkt)
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
seit_ping = 0.0 seit_ping = 0.0
except Exception: except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt # Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
@@ -106,7 +106,7 @@ async def _strom(request: Request, mit_metrik: bool):
if seit_abdruck >= ABDRUCK_S: if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0 seit_abdruck = 0.0
neu = _fingerprints() neu = await asyncio.to_thread(_fingerprints)
keys = [k for k, v in neu.items() if k in alt and v != alt[k]] keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu) alt.update(neu)
if keys: if keys:
+20
View File
@@ -161,10 +161,20 @@ def _inject_language(body: dict, alias: str) -> None:
elif isinstance(first_sys.get("content"), list): elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
können damit umgehen und zeigen den Grund an."""
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
status_code=status, headers=headers)
@router.get("/models") @router.get("/models")
async def models(request: Request): async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
try:
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
try: try:
data = r.json() data = r.json()
except ValueError: except ValueError:
@@ -275,7 +285,10 @@ async def _proxy(path: str, request: Request):
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
if body.get("stream"): if body.get("stream"):
req = client.build_request("POST", url, json=body, timeout=None) req = client.build_request("POST", url, json=body, timeout=None)
try:
r = await client.send(req, stream=True) r = await client.send(req, stream=True)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
if r.status_code != 200: if r.status_code != 200:
await r.aread() await r.aread()
try: try:
@@ -293,8 +306,15 @@ async def _proxy(path: str, request: Request):
await r.aclose() await r.aclose()
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
try:
r = await client.post(url, json=body, timeout=600.0) r = await client.post(url, json=body, timeout=600.0)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
try:
resp_json = r.json() resp_json = r.json()
except ValueError:
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
if isinstance(resp_json, dict): if isinstance(resp_json, dict):
record_usage(resp_json.get("usage"), alias) record_usage(resp_json.get("usage"), alias)
if any(isinstance(c, dict) and c.get("finish_reason") == "length" if any(isinstance(c, dict) and c.get("finish_reason") == "length"
-5
View File
@@ -78,11 +78,6 @@ def update_all() -> dict:
return maintenance.update_all_job() return maintenance.update_all_job()
@router.post("/maintenance/reboot")
def reboot() -> dict:
return maintenance.reboot()
@router.post("/maintenance/restart") @router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict: def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service) return maintenance.restart_service(body.service)
+10 -102
View File
@@ -1,11 +1,12 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups.""" """Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
(ohne Nutzer seit dem Box-Wart-Umbau)."""
import psutil import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -29,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
return {**data, "sys_ram_gb": round(ram, 1)} return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
name: str = "", role: str = "") -> dict:
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
ram = _ram_gb()
pb = params_b if params_b > 0 else budget.params_b_for(name)
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
return {
"params_b": round(pb, 1),
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel): class RegisterReq(BaseModel):
model_path: str model_path: str
role: str | None = None role: str | None = None
@@ -61,6 +41,10 @@ class RegisterReq(BaseModel):
@router.post("/models/register") @router.post("/models/register")
def register(req: RegisterReq) -> dict: def register(req: RegisterReq) -> dict:
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
for pfad in (req.model_path, req.mmproj_path):
if pfad and not llamaswap.im_modellordner(pfad):
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
try: try:
model_id = llamaswap.register_model( model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl, req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
@@ -148,8 +132,9 @@ def install(req: InstallReq) -> dict:
env = dict(HF_DOWNLOAD_ENV) env = dict(HF_DOWNLOAD_ENV)
if token := geheimnisse.hf_token(): if token := geheimnisse.hf_token():
env["HF_TOKEN"] = token env["HF_TOKEN"] = token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env, # Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
on_done=_apply_role if role else None) job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download",
on_done=_apply_role if role else None, zeitlimit_s=6 * 3600)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])} "total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -169,14 +154,6 @@ class RoleReq(BaseModel):
role: str | None = None role: str | None = None
@router.get("/roles/{role}/recommend")
def recommend_role(role: str) -> dict:
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
from services import roles
return roles.recommend_for_role(role)
@router.post("/models/{model_id}/role") @router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict: def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower() new_role = (body.role or "").strip().lower()
@@ -201,54 +178,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
return {"ok": True} return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.get("/models/{model_id}/ctx/auto")
def auto_ctx(model_id: str) -> dict:
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
if not m:
raise HTTPException(404, "Modell nicht gefunden")
saw = budget.setup_aware_ctx_for_model(m)
return {"model_id": model_id, "current_ctx": m.get("ctx"),
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
"role": m.get("role"), **saw}
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/models/drafts")
def list_drafts(target: str = "") -> dict:
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
return llamaswap.drafts_for(target)
class DraftReq(BaseModel):
draft_path: str | None = None
@router.post("/models/{model_id}/draft")
def set_model_draft(model_id: str, body: DraftReq) -> dict:
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
try:
res = llamaswap.set_spec_draft(model_id, body.draft_path)
except PermissionError as exc:
raise HTTPException(500, str(exc))
if not res["ok"]:
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
return res
@router.post("/models/unload") @router.post("/models/unload")
def unload_all_models() -> dict: def unload_all_models() -> dict:
import httpx import httpx
@@ -297,24 +226,3 @@ def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id): if not llamaswap.delete_model(model_id):
raise HTTPException(404, "Modell nicht gefunden") raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True} return {"ok": True}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
+3 -33
View File
@@ -1,9 +1,8 @@
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload).""" """Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter
from pydantic import BaseModel
from services import gateway from services import gateway
from services.routing_policy import policy_meta, save_policy
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -11,32 +10,3 @@ router = APIRouter(prefix="/api")
@router.get("/routing") @router.get("/routing")
def routing() -> dict: def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()} return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
@router.get("/routing/policy")
def get_policy() -> dict:
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
return policy_meta()
class PolicyPatch(BaseModel):
fast: str | None = None
heavy: str | None = None
coder: str | None = None
coder_lite: str | None = None
heavy_chars: int | None = None
coding_escalate_chars: int | None = None
fast_no_think: bool | None = None
@router.put("/routing/policy")
def put_policy(patch: PolicyPatch) -> dict:
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
try:
new_policy = save_policy(fields)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
return {"policy": new_policy}
+1 -30
View File
@@ -1,4 +1,4 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box). """System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
@@ -26,22 +26,11 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status") @router.get("/system/status")
def status() -> dict: def status() -> dict:
return system_status() return system_status()
@router.get("/system/history")
def history(minutes: int = 60) -> dict:
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
from services import metrics_history
return metrics_history.history(minutes)
def _voice_reachable() -> bool: def _voice_reachable() -> bool:
try: try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
@@ -124,15 +113,6 @@ def backups() -> dict:
return {"backups": backup_svc.list_backups()} return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc:
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
@@ -147,15 +127,6 @@ def restart(req: RestartReq) -> dict:
return maintenance.restart_service(req.service) return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats") @router.get("/system/token-stats")
def token_stats() -> dict: def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
+39 -175
View File
@@ -1,20 +1,22 @@
""" """
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar). Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + 24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
eigenem Gedächtnis wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. `X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste,
Klon-Referenz und das alte Piper/Chatterbox-TTS.
""" """
import json
import logging import logging
import os import os
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys import sys as _sys
import time
import httpx import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
@@ -22,14 +24,6 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from services import announce from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") _GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path: if _GUARD_DIR not in _sys.path:
@@ -43,17 +37,22 @@ except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung # Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell # Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2). # Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
def _sse_fehler(text: str) -> bytes:
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
async def _describe_images(image_urls: list[str], hint: str) -> str: async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). """Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler.""" Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1 multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
@@ -65,8 +64,8 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
for u in image_urls: for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}}) content.append({"type": "image_url", "image_url": {"url": u}})
try: try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas # 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen. # Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
@@ -78,21 +77,11 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return "" return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel):
text: str
engine: str = "piper"
voice: str = ""
language: str = ""
ref_path: str = ""
class ChatIn(BaseModel): class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis) text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
session_key: str = "" # optional: Langzeit-Memory-Scope session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = "" model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
@@ -115,9 +104,8 @@ class AlarmIn(BaseModel):
def alarm(body: AlarmIn) -> dict: def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only auch nachts sofort raus (notify.sh)."""
wie alle MC2-Endpoints."""
text = (body.text or "").strip() text = (body.text or "").strip()
if not text: if not text:
raise HTTPException(400, "Leere Meldung.") raise HTTPException(400, "Leere Meldung.")
@@ -132,8 +120,8 @@ def alarm(body: AlarmIn) -> dict:
@router.post("/voice/announce") @router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict: def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter, """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints.""" (Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
try: try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc: except ValueError as exc:
@@ -147,43 +135,6 @@ def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
return announce.list_after(after, limit) return announce.list_after(after, limit)
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc:
out["error"] = str(exc)
return out
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices")
def voice_voices() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt") @router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt).""" """Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
@@ -192,12 +143,8 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
raise HTTPException(400, "Leeres Audio.") raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
_t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -213,7 +160,6 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -223,55 +169,6 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
return {"complete": True, "probability": 1.0, "engine": "fallback"} return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference")
def voice_get_reference() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc:
return {"active": False, "error": str(exc)}
@router.delete("/voice/reference")
def voice_clear_reference() -> dict:
try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat") @router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse: async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
@@ -289,31 +186,14 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
headers["X-Hermes-Session-Key"] = body.session_key headers["X-Hermes-Session-Key"] = body.session_key
async def gen(): async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung zu EINEM # Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
# Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger. # bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
trace = TurnTrace(session_id=body.session_id, kind="voice") # Request hängt, während das Bild-Modell beschreibt.
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text user_text = body.text
imgs = [u for u in (body.images or []) if u] imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs: if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text) desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc: if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM") safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
@@ -321,11 +201,9 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
if body.system: if body.system:
messages.append({"role": "system", "content": body.system}) messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text}) messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, # Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB). # generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py).
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"): if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False} payload["chat_template_kwargs"] = {"enable_thinking": False}
try: try:
@@ -335,34 +213,21 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
) as r: ) as r:
if r.status_code != 200: if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500] detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}" yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return return
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk yield chunk
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
trace.error = "verbindung" yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
# --------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts # Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit # german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten. # die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate). # /tts/stream -> PCM16 + X-Sample-Rate).
# LAN-only wie alle MC2-Endpoints.
class LucyTtsIn(BaseModel): class LucyTtsIn(BaseModel):
text: str text: str
@@ -385,7 +250,6 @@ async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen.""" """Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
with Timer("lucy_tts"):
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True)) r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
r.raise_for_status() r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"), return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
-11
View File
@@ -31,17 +31,6 @@ def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()} return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore") @router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict: def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein """Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""
Parse systemd timeout errors for mission-control-2.service from journalctl.
Captures lines containing "State 'stop-sigterm' timed out" and appends them
to ~/logs/mc2-timeout.log with ISO timestamps.
Permission errors are handled gracefully.
Uses user journal (systemctl --user) to query logs.
"""
import subprocess
from datetime import datetime, timezone
from pathlib import Path
LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "mc2-timeout.log"
SERVICE_NAME = "mission-control-2.service"
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
JOURNALCTL_LIMIT = 1000
def ensure_log_dir() -> None:
"""Create log directory if it doesn't exist."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
def get_timeout_entries() -> list[str]:
"""Run journalctl --user and return lines matching the timeout pattern."""
cmd = [
"journalctl",
"--user",
"-u", SERVICE_NAME,
"--no-pager",
"-n", str(JOURNALCTL_LIMIT),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
print("journalctl timed out")
return []
except PermissionError:
print("Permission denied: journalctl requires access to user logs")
return []
if result.returncode != 0:
if "Permission denied" in result.stderr:
print("Permission denied: journalctl requires access to user logs")
else:
print(f"journalctl failed: {result.stderr.strip()}")
return []
return [
line
for line in result.stdout.splitlines()
if SEARCH_PATTERN in line
]
def write_to_log(entries: list[str]) -> int:
"""Append entries to log file with ISO timestamps. Returns count written."""
ensure_log_dir()
timestamp = datetime.now(timezone.utc).isoformat()
written = 0
with LOG_FILE.open("a", encoding="utf-8") as f:
for entry in entries:
f.write(f"[{timestamp}] {entry}\n")
written += 1
return written
def main() -> None:
entries = get_timeout_entries()
if not entries:
print("No timeout entries found.")
return
count = write_to_log(entries)
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
if __name__ == "__main__":
main()
+40 -92
View File
@@ -6,31 +6,18 @@ Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
import logging import logging
import os import os
import re
import httpx import httpx
from config import ( from config import (
BOX_CONSOLE_PATH,
BOX_CONSOLE_UPSTREAM,
HERMES_API_URL, HERMES_API_URL,
HERMES_BUILTIN_UI_PATH, HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME, HERMES_HOME,
PC_EXECUTOR_URL,
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str: def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try: try:
@@ -106,67 +93,18 @@ def _reach(url: str, path: str = "") -> bool:
return False return False
def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict: def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" """Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
return { return {
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
"hermes_ui_url": HERMES_BUILTIN_UI_PATH, "hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
"pc_executor_url": PC_EXECUTOR_URL,
} }
@@ -220,49 +158,59 @@ def set_agent_brain(model_id: str) -> dict:
def update_brain_model(new_model: str) -> bool: def update_brain_model(new_model: str) -> bool:
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
Hermes-Gateway neu.
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
import shutil
import time as _time
from config import HERMES_HOME from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {}
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
return False
r_yaml = YAML()
r_yaml.preserve_quotes = True
r_yaml.width = 100
r_yaml.indent(mapping=2, sequence=4, offset=2)
try:
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f)
except Exception:
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
return False
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
cfg = {} log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
return False
if "model" not in cfg or not isinstance(cfg["model"], dict): if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {} cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt). # Beide setzen, sonst greift die Umschaltung nicht.
cfg["model"]["default"] = new_model cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model cfg["model"]["model"] = new_model
tmp = config_path.with_suffix(".yaml.neu")
try: try:
from ruamel.yaml import YAML shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
r_yaml = YAML() with tmp.open("w", encoding="utf-8") as f:
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f) r_yaml.dump(cfg, f)
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
os.replace(tmp, config_path)
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
tmp.unlink(missing_ok=True)
return False
# Restart the user-space service to apply changes
try: try:
from services import maintenance from services import maintenance
maintenance.restart_service("hermes-gateway") maintenance.restart_service("hermes-gateway")
except Exception: except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True return True
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
return False
-176
View File
@@ -1,176 +0,0 @@
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
zu patchen.
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
nützlicher als der Fließtext ihrer Gedanken.
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
halten kann.
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger(__name__)
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
LOG_PFAD = Path(os.path.expanduser(
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
LESE_BYTES = 512 * 1024
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
#
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
_FERTIG = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
_FEHLER = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
def _schwanz(pfad: Path, n: int) -> list[str]:
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
try:
groesse = pfad.stat().st_size
with pfad.open("rb") as f:
f.seek(max(0, groesse - n))
roh = f.read()
zeilen = roh.decode("utf-8", errors="replace").splitlines()
return zeilen[1:] if groesse > n and zeilen else zeilen
except OSError:
return []
def _kurz(detail: str | None) -> str | None:
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
JSON ab; interessant ist daran nur das `error`-Feld."""
if not detail:
return None
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
text = treffer.group(1) if treffer else detail.strip()
return (text[:297] + "") if len(text) > 300 else text
def rufe(limit: int = 60) -> list[dict]:
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
ergebnis: list[dict] = []
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
m = _FERTIG.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": int(m.group("zeichen")),
"ok": True,
"fehler": None,
})
continue
m = _FEHLER.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": None,
"ok": False,
"fehler": _kurz(m.group("detail")),
})
return ergebnis[-limit:]
def _iso(s: str) -> str:
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
Falle, die erst zur Zeitumstellung zuschnappt."""
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
except ValueError:
return s
def uebersicht(limit: int = 60) -> dict:
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
wiederkehrenden Fehler zusammengefasst.
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
liste = rufe(limit)
if not LOG_PFAD.exists():
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
"werkzeuge": [], "laeufe": []}
bilanz: dict[str, dict] = {}
for r in liste:
b = bilanz.setdefault(r["werkzeug"], {
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
"dauer_summe": 0.0, "letzter_fehler": None,
})
b["rufe"] += 1
b["dauer_summe"] += r["dauer_s"]
if not r["ok"]:
b["fehler"] += 1
b["letzter_fehler"] = r["fehler"]
werkzeuge = sorted(
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
for b in bilanz.values()),
key=lambda b: (-b["fehler"], -b["rufe"]),
)
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
laeufe: list[dict] = []
gesehen: dict[str, dict] = {}
for r in liste:
schluessel = r["lauf"] or "(interaktiv)"
if schluessel not in gesehen:
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
"rufe": 0, "fehler": 0}
laeufe.append(gesehen[schluessel])
e = gesehen[schluessel]
e["bis"] = r["zeit"]
e["rufe"] += 1
if not r["ok"]:
e["fehler"] += 1
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
"werkzeuge": werkzeuge, "laeufe": laeufe}
-8
View File
@@ -112,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True) log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict: def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen """Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern.""" Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
+1
View File
@@ -129,6 +129,7 @@ def loeschen(art: str, name: str) -> dict:
if art == "ordner": if art == "ordner":
frei = _ordner_loeschen(name) frei = _ordner_loeschen(name)
else: else:
with llamaswap.config_sperre():
cfg = llamaswap.read_config() cfg = llamaswap.read_config()
models = cfg.get("models") or {} models = cfg.get("models") or {}
vorher = _genutzte_ordner(models) vorher = _genutzte_ordner(models)
-5
View File
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
return snaps[0] if snaps else None return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]: def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI).""" """Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try: try:
-34
View File
@@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
} }
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
-2
View File
@@ -25,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str: def _categorize(repo_id: str) -> str:
low = repo_id.lower() low = repo_id.lower()
-5
View File
@@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8) return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+9 -3
View File
@@ -7,6 +7,7 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
import json import json
import logging import logging
import threading
import time import time
from services.token_stats import increment_tokens from services.token_stats import increment_tokens
@@ -37,12 +38,17 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None:
return return
_trunc_last[model] = now _trunc_last[model] = now
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model) log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
def _melde_abriss(model: str) -> None:
try: try:
from services import announce from services import announce
announce.add( announce.add(
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen " f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß " f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
"[Kontext-Limit]", "gateway", "silent") "[Kontext-Limit]", "gateway", "silent")
except Exception: except Exception:
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True) log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
+21
View File
@@ -0,0 +1,21 @@
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
"""
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
return True
if not origin or origin in ("null", "file://"):
return True
wirt = origin.split("://", 1)[-1].rstrip("/")
return wirt == (host or "") or wirt in ENTWICKLUNG
+12 -9
View File
@@ -33,13 +33,12 @@ def list_quants(repo: str) -> list[str]:
def search(q: str = "", limit: int = 24) -> list[dict]: def search(q: str = "", limit: int = 24) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern).""" """Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
url = (f"https://huggingface.co/api/models?filter=gguf" params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
f"&sort=downloads&direction=-1&limit={limit}")
if q and q.strip(): if q and q.strip():
url += f"&search={q.strip()}" params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
try: try:
with httpx.Client(timeout=12.0) as c: with httpx.Client(timeout=12.0) as c:
data = c.get(url).json() data = c.get("https://huggingface.co/api/models", params=params).json()
except Exception: except Exception:
return [] return []
out = [] out = []
@@ -80,15 +79,19 @@ def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
# mmproj separat (Vision-Projektor) # mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None) mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()] model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# bevorzugt den gewünschten Quant # Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
pref = [e for e in model if q in e["path"].lower()] # bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
chosen = pref or model chosen = [e for e in model if q in e["path"].lower()]
if not chosen: if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False} return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe. # Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
split = any("-of-" in e["path"].lower() for e in chosen) split = any("-of-" in e["path"].lower() for e in chosen)
if split: if split:
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"]) def _gruppe(pfad: str) -> str:
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
key=lambda e: e["path"])
files = [e["path"] for e in parts] files = [e["path"] for e in parts]
first = files[0] first = files[0]
total = sum(_size(e) for e in parts) total = sum(_size(e) for e in parts)
+93 -19
View File
@@ -1,11 +1,22 @@
""" """
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt. Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job. Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
Seit 24.09.2026:
- Jobs laufen in einer eigenen Prozessgruppe; „Abbrechen“ beendet die ganze Gruppe (vorher nur
die bash-Hülle — sudo, apt und hf liefen weiter, während der Job schon „abgebrochen“ hieß).
- Jede Job-Art hat ein Zeitlimit; ein hängendes apt hält den Wartungs-Riegel nicht mehr ewig.
- `start_job_exklusiv` prüft und trägt unter EINER Sperre ein: zwei Klicks starten nicht mehr
zwei Updates derselben Gruppe.
- Beendete Jobs werden nach einem Tag bzw. ab 40 Stück aufgeräumt.
Noch offen (Phase 2): Jobs überleben keinen Neustart von MC2, sie gehören in einen eigenen Worker.
""" """
import glob import glob
import os import os
import shlex import shlex
import signal
import subprocess import subprocess
import threading import threading
import time import time
@@ -14,6 +25,11 @@ import uuid
JOBS: dict[str, dict] = {} JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {} _PROCS: dict[str, subprocess.Popen] = {}
_LOG_CAP = 400 _LOG_CAP = 400
_LOCK = threading.Lock()
BEHALTEN_MAX = 40 # so viele beendete Jobs bleiben sichtbar
BEHALTEN_S = 24 * 3600 # beendete Jobs verschwinden nach einem Tag
STANDARD_ZEITLIMIT_S = 3 * 3600
_ENDE = ("done", "failed", "canceled")
def _append_log(job: dict, line: str) -> None: def _append_log(job: dict, line: str) -> None:
@@ -57,34 +73,64 @@ def _pump_output(job: dict, stream) -> None:
commit() commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None): def _beende_gruppe(proc: subprocess.Popen) -> None:
"""Den Job-Prozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
try:
if os.name == "posix":
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
else:
proc.terminate()
except (ProcessLookupError, PermissionError, OSError):
pass
def _run_job(job_id: str, args: list[str], env: dict | None = None, zeitlimit_s: int | None = None):
"""Job-Prozess starten und mitschreiben. """Job-Prozess starten und mitschreiben.
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an v3-Umbau P1 (28.08.2026): Auf der Box läuft sudo passwortlos (`NOPASSWD: ALL`), der Job
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben). braucht keine stdin-Pipe. DEVNULL sorgt dafür, dass ein Job, der wider Erwarten nach einem
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn Passwort fragt, sofort scheitert statt still zu hängen."""
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
job = JOBS[job_id] job = JOBS[job_id]
job["state"] = "running" job["state"] = "running"
wecker: threading.Timer | None = None
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
bufsize=0, bufsize=0,
env={**os.environ, **(env or {})}, env={**os.environ, **(env or {})},
start_new_session=(os.name == "posix"),
) )
_PROCS[job_id] = proc _PROCS[job_id] = proc
grenze = zeitlimit_s or STANDARD_ZEITLIMIT_S
def _zu_lang() -> None:
job["zeitlimit"] = True
_append_log(job, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Job wird beendet.")
_beende_gruppe(proc)
wecker = threading.Timer(grenze, _zu_lang)
wecker.daemon = True
wecker.start()
_pump_output(job, proc.stdout) _pump_output(job, proc.stdout)
proc.wait() proc.wait()
job["returncode"] = proc.returncode job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") if job.get("canceled"):
job["state"] = "canceled"
elif job.get("zeitlimit"):
job["state"] = "failed"
job["error"] = "Zeitlimit überschritten"
else:
job["state"] = "done" if proc.returncode == 0 else "failed"
except Exception as exc: except Exception as exc:
_append_log(job, f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed" job["state"] = "failed"
job["error"] = str(exc)
job["returncode"] = -1 job["returncode"] = -1
finally: finally:
if wecker is not None:
wecker.cancel()
_PROCS.pop(job_id, None) _PROCS.pop(job_id, None)
job["finished_at"] = time.time() job["finished_at"] = time.time()
cb = job.pop("_on_done", None) cb = job.pop("_on_done", None)
@@ -110,7 +156,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
rate = 0.0 rate = 0.0
while True: while True:
j = JOBS.get(job_id) j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"): if not j or j["state"] in _ENDE:
break break
try: try:
inc = glob.glob(pat, recursive=True) inc = glob.glob(pat, recursive=True)
@@ -137,25 +183,54 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
threading.Thread(target=_watch, daemon=True).start() threading.Thread(target=_watch, daemon=True).start()
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, def _aufraeumen() -> None:
group: str | None = None) -> str: """Alte beendete Jobs entfernen (unter _LOCK aufrufen)."""
jetzt = time.time()
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
key=lambda j: j.get("finished_at") or 0, reverse=True)
for i, j in enumerate(fertig):
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
JOBS.pop(j["id"], None)
def _eintragen(args: list[str], label: str, on_done, group: str | None) -> str:
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
log_args = list(args)
JOBS[job_id] = { JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group, "id": job_id, "label": label, "state": "queued", "group": group,
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)], "log": ["$ " + " ".join(shlex.quote(a) for a in args)],
"returncode": None, "started_at": time.time(), "finished_at": None, "returncode": None, "started_at": time.time(), "finished_at": None,
} }
if on_done: if on_done:
JOBS[job_id]["_on_done"] = on_done JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id return job_id
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
group: str | None = None, zeitlimit_s: int | None = None) -> str:
with _LOCK:
_aufraeumen()
job_id = _eintragen(list(args), label, on_done, group)
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
return job_id
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None, on_done=None,
zeitlimit_s: int | None = None) -> tuple[str | None, dict | None]:
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
Sperre. Rückgabe: (neue Job-ID, None) oder (None, der schon laufende Job)."""
with _LOCK:
if (laeuft := active_in_group(group)) is not None:
return None, laeuft
_aufraeumen()
job_id = _eintragen(list(args), label, on_done, group)
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
return job_id, None
def active_in_group(group: str) -> dict | None: def active_in_group(group: str) -> dict | None:
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None. """Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig.""" Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
for j in JOBS.values(): for j in list(JOBS.values()):
if j.get("group") == group and j.get("state") in ("running", "queued"): if j.get("group") == group and j.get("state") in ("running", "queued"):
return j return j
return None return None
@@ -163,16 +238,13 @@ def active_in_group(group: str) -> dict | None:
def cancel_job(job_id: str) -> bool: def cancel_job(job_id: str) -> bool:
job = JOBS.get(job_id) job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"): if not job or job["state"] in _ENDE:
return False return False
job["canceled"] = True job["canceled"] = True
_append_log(job, "[mc] Abbruch angefordert…") _append_log(job, "[mc] Abbruch angefordert…")
proc = _PROCS.get(job_id) proc = _PROCS.get(job_id)
if proc is not None: if proc is not None:
try: _beende_gruppe(proc)
proc.terminate()
except Exception:
pass
else: else:
job["state"] = "canceled" job["state"] = "canceled"
job["finished_at"] = time.time() job["finished_at"] = time.time()
@@ -181,4 +253,6 @@ def cancel_job(job_id: str) -> bool:
def public_jobs() -> list[dict]: def public_jobs() -> list[dict]:
"""Jobs ohne interne Felder (_on_done) für die API.""" """Jobs ohne interne Felder (_on_done) für die API."""
with _LOCK:
_aufraeumen()
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()] return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
+67 -82
View File
@@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz. `swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
""" """
import functools
import logging import logging
import os import os
import re import re
import threading
from contextlib import contextmanager
from pathlib import Path
import httpx import httpx
from config import ( from config import (
@@ -17,12 +21,18 @@ from config import (
DEFAULT_TTL, DEFAULT_TTL,
DRAFTS_DIR, DRAFTS_DIR,
LLAMA_SWAP_URL, LLAMA_SWAP_URL,
MODELS_DIR,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX, SPEC_DRAFT_N_MAX,
SPEC_TYPE, SPEC_TYPE,
) )
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
try:
import fcntl
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
fcntl = None
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, # Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
@@ -53,6 +63,44 @@ def _gguf_total_size(path: str) -> int | None:
return None return None
# --- Sperre ------------------------------------------------------------------
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
_SPERRE = threading.RLock()
_SPERR_TIEFE = threading.local()
@contextmanager
def config_sperre():
with _SPERRE:
tiefe = getattr(_SPERR_TIEFE, "n", 0)
_SPERR_TIEFE.n = tiefe + 1
datei = None
try:
if tiefe == 0 and fcntl is not None:
try:
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
fcntl.flock(datei, fcntl.LOCK_EX)
except OSError:
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
yield
finally:
_SPERR_TIEFE.n = tiefe
if datei is not None:
fcntl.flock(datei, fcntl.LOCK_UN)
datei.close()
def _mit_sperre(fn):
@functools.wraps(fn)
def huelle(*args, **kwargs):
with config_sperre():
return fn(*args, **kwargs)
return huelle
# --- Lesen ------------------------------------------------------------------- # --- Lesen -------------------------------------------------------------------
def read_config() -> dict: def read_config() -> dict:
if not CONFIG_PATH.exists(): if not CONFIG_PATH.exists():
@@ -236,7 +284,7 @@ def write_config(cfg: dict) -> None:
) from exc ) from exc
@_mit_sperre
def register_model(model_path: str, role: str | None = None, ctx: int = 8192, def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None, ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False, set_alias: bool = True) -> str: jinja: bool = False, set_alias: bool = True) -> str:
@@ -368,70 +416,8 @@ def spec_draft_flags(target_path: str) -> str:
return _spec_flags_for_draft(d) if d else "" return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
"drafts": drafts,
}
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
abgelehnt (idiotensicher). Returns {ok, reason}."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
if not os.path.isabs(draft_path) and "/" not in draft_path:
draft_path = str(DRAFTS_DIR / draft_path)
if not os.path.exists(draft_path):
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
from services import gguf_meta
target = ""
if (mt := _PATH_RE.search(cmd)):
target = mt.group(1).replace("'", "").replace('"', "")
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
if comp is not True:
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
"würde beim Laden scheitern."
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)
return {"ok": True, "reason": ""}
# --- Groups (Ko-Residenz) ---------------------------------------------------- # --- Groups (Ko-Residenz) ----------------------------------------------------
@_mit_sperre
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
@@ -467,6 +453,7 @@ def list_groups() -> dict:
return read_config().get("groups") or {} return read_config().get("groups") or {}
@_mit_sperre
def set_role(model_id: str, role: str | None) -> bool: def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert).""" z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
@@ -478,6 +465,7 @@ def set_role(model_id: str, role: str | None) -> bool:
return True return True
@_mit_sperre
def add_role(model_id: str, role: str) -> bool: 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 """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.""" (Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
@@ -493,22 +481,7 @@ def add_role(model_id: str, role: str) -> bool:
return True return True
def set_ctx(model_id: str, ctx: int) -> bool: @_mit_sperre
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg)
return True
def set_ttl(model_id: str, ttl: int) -> bool: def set_ttl(model_id: str, ttl: int) -> bool:
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss).""" entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
@@ -521,6 +494,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
return True return True
@_mit_sperre
def im_modellordner(pfad: str) -> bool:
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
try:
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
except (OSError, ValueError):
return False
def delete_model(model_id: str) -> bool: def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner. GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
@@ -540,6 +522,9 @@ def delete_model(model_id: str) -> bool:
m = None m = None
if m: if m:
path = m.group(1).replace("'", "").replace('"', "") path = m.group(1).replace("'", "").replace('"', "")
if path and not im_modellordner(path):
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
path = ""
if path: if path:
# 1. Haupt-GGUF-Datei löschen # 1. Haupt-GGUF-Datei löschen
if os.path.exists(path): if os.path.exists(path):
@@ -567,7 +552,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match: if mmproj_match:
m_path = mmproj_match.group(1) m_path = mmproj_match.group(1)
if os.path.exists(m_path) and m_path not in andere: if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
try: try:
os.remove(m_path) os.remove(m_path)
except Exception: except Exception:
@@ -575,7 +560,7 @@ def delete_model(model_id: str) -> bool:
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
try: try:
if not os.listdir(dirname) and os.path.basename(dirname) != "models": if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
os.rmdir(dirname) os.rmdir(dirname)
except Exception: except Exception:
pass pass
+84 -188
View File
@@ -14,9 +14,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx import httpx
import psutil
from services import catalog, discover, jobengine, llamaswap, system from services import jobengine, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"} SYSTEM_SERVICES = {"llama-swap"}
@@ -24,7 +23,16 @@ SYSTEM_SERVICES = {"llama-swap"}
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py). # 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", USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
"projekte-sync", "mc2-backup", "mc2-radar"} "projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"}
# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die
# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
# „aktuell“, obwohl gar nicht geprüft werden konnte.
PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None}
# Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn,
# damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten.
update_stand = 0
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). # 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__), "..", "..")) _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -88,10 +96,6 @@ def _installed_engine_build() -> int | None:
return None return None
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _os_upgradable() -> int: def _os_upgradable() -> int:
try: try:
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt # LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
@@ -99,8 +103,10 @@ def _os_upgradable() -> int:
out = subprocess.run( out = subprocess.run(
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"], ["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
PRUEF_FEHLER["os"] = None
return int((out.stdout or "0").strip() or 0) return int((out.stdout or "0").strip() or 0)
except Exception: except Exception as exc:
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})"
return 0 return 0
@@ -109,8 +115,12 @@ def _engine_update_available() -> bool:
if now - _engine_cache["ts"] < 3600: if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"] return _engine_cache["avail"]
avail = False avail = False
fehler = None
try: try:
rel = _latest_engine_asset_release() or {} rel = _latest_engine_asset_release()
if rel is None:
fehler = "Neueste Engine-Version bei GitHub nicht abrufbar"
rel = rel or {}
tag = str(rel.get("tag_name", "")) tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
installed = _installed_engine_build() installed = _installed_engine_build()
@@ -119,8 +129,12 @@ def _engine_update_available() -> bool:
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
except Exception: elif fehler is None:
fehler = "Installierte Engine-Version nicht lesbar"
except Exception as exc:
avail = False avail = False
fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})"
PRUEF_FEHLER["engine"] = fehler
_engine_cache.update(ts=now, avail=avail) _engine_cache.update(ts=now, avail=avail)
return avail return avail
@@ -144,6 +158,7 @@ def _swap_update_available() -> bool:
if now - _swap_cache["ts"] < 3600: if now - _swap_cache["ts"] < 3600:
return _swap_cache["avail"] return _swap_cache["avail"]
avail = False avail = False
fehler = None
try: try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json() timeout=6, headers={"User-Agent": "MissionControl2"}).json()
@@ -152,8 +167,14 @@ def _swap_update_available() -> bool:
installed = _installed_swap_version() installed = _installed_swap_version()
if latest is not None and installed is not None: if latest is not None and installed is not None:
avail = latest > installed avail = latest > installed
except Exception: elif latest is None:
fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar"
else:
fehler = "Installierte llama-swap-Version nicht lesbar"
except Exception as exc:
avail = False avail = False
fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})"
PRUEF_FEHLER["swap"] = fehler
_swap_cache.update(ts=now, avail=avail) _swap_cache.update(ts=now, avail=avail)
return avail return avail
@@ -198,109 +219,12 @@ def _components_cached() -> list[dict]:
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]: if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
return _comp_cache["data"] return _comp_cache["data"]
data = [_hermes_agent_update()] data = [_hermes_agent_update()]
PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)"
if data[0].get("reachable") is False else None)
_comp_cache.update(ts=now, data=data) _comp_cache.update(ts=now, data=data)
return data return data
def _params_of(m: dict) -> float:
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
from services.fit import QUANT_BYTES_PER_PARAM
caps = m.get("capabilities") or {}
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
def _gen_key(name: str):
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
low = (name or "").lower()
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
"-coder" if ("coder" in low or "-code" in low) else ""
for fam, pat in _FAM_PATS:
m = re.search(pat, low)
if m:
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
return (fam + sub, ver)
return None
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
cm = catalog.meta_for_name(name)
if cm:
return {"family": cm.get("family"), "gen": cm.get("generation"),
"total": float(cm.get("total_params_b") or 0),
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
d = model_dict or {}
g = _gen_key(name)
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
return {"family": (d.get("family") or (g[0] if g else None)),
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
def model_upgrades() -> list[dict]:
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
disc = discover.safe_discover(_ram_gb())
if not disc:
return []
installed = llamaswap.list_models()
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
for c in disc.get("categories", []):
role = c["role"]
im = inst_by_role.get(role)
if im is None:
continue
rec = c.get("recommended")
if not rec:
continue
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
i = _meta(im["name"], im=im)
r = _meta(rec, model_dict=rec_model)
if not i["family"] or not r["family"] or i["family"] != r["family"]:
continue # andere/unbekannte Familie → kein Upgrade
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
continue # ältere Generation → niemals
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
if same_gen:
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
continue # gleiche Gen, nicht größer → kein Upgrade
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
continue
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
continue
base = rec.split("/")[-1].lower()
stem = base.removesuffix("-gguf")
if base in cmds or (stem and stem in cmds):
continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec})
return out
def _last_apt_update() -> float | None: def _last_apt_update() -> float | None:
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]: for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
if os.path.exists(path): if os.path.exists(path):
@@ -312,11 +236,23 @@ def _last_apt_update() -> float | None:
def updates() -> dict: def updates() -> dict:
ups = model_upgrades() """Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das
Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr."""
daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"swap": 1 if _swap_update_available() else 0, "swap": 1 if _swap_update_available() else 0,
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(), "last_check": _last_apt_update(), "components": _components_cached()}
"components": _components_cached()} daten["pruef_fehler"] = dict(PRUEF_FEHLER)
return daten
def _nach_update() -> None:
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
global update_stand
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
update_stand += 1
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── # ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
@@ -630,30 +566,28 @@ def logs(service: str, lines: int = 200) -> dict:
return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": r["ok"], "text": r["out"] or r["err"]}
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
def check_updates_job() -> dict: def _starten(args: list[str], label: str, on_done=None, zeitlimit_s: int | None = None) -> dict:
if err := check_sudo_needs_password(): """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen
return err passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und
Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten."""
def on_done(): job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, on_done=on_done,
_engine_cache.update(ts=0.0, avail=False) zeitlimit_s=zeitlimit_s)
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen if job_id is None:
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
cmd = "sudo apt-get update" "detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def _maintenance_busy() -> dict | None: def check_updates_job() -> dict:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert if err := check_sudo_needs_password():
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts).""" return err
if j := jobengine.active_in_group("maintenance"): # apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit
return {"ok": False, "status": "busy", "running": j.get("label")} # der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf.
return None return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen",
on_done=_nach_update, zeitlimit_s=15 * 60)
def os_update_job() -> dict: def os_update_job() -> dict:
if busy := _maintenance_busy():
return busy
if err := check_sudo_needs_password(): if err := check_sudo_needs_password():
return err return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
@@ -662,49 +596,26 @@ def os_update_job() -> dict:
cmd = ("sudo apt-get update && " cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}") f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", return _starten(["bash", "-c", cmd], "OS-Update (apt)", on_done=_nach_update, zeitlimit_s=90 * 60)
group="maintenance")
return {"ok": True, "job_id": job_id}
def engine_update_job() -> dict | None: def engine_update_job() -> dict | None:
if not ENGINE_UPDATE_CMD: if not ENGINE_UPDATE_CMD:
return None return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den # update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. # neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)",
"Engine-Update (llama.cpp Vulkan)", on_done=_nach_update, zeitlimit_s=60 * 60)
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def swap_update_job() -> dict | None: def swap_update_job() -> dict | None:
if not SWAP_UPDATE_CMD: if not SWAP_UPDATE_CMD:
return None return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer # und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version.
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)",
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], on_done=_nach_update, zeitlimit_s=30 * 60)
"Router-Update (llama-swap)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def _hermes_update_cmd() -> str: def _hermes_update_cmd() -> str:
@@ -753,16 +664,9 @@ def _hermes_update_cmd() -> str:
def hermes_update_job() -> dict: def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern).""" (alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern)."""
if busy := _maintenance_busy(): return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
return busy on_done=_nach_update, zeitlimit_s=45 * 60)
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def update_all_job() -> dict: def update_all_job() -> dict:
@@ -771,8 +675,9 @@ def update_all_job() -> dict:
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift; Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem.""" es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy(): if laeuft := jobengine.active_in_group("maintenance"):
return busy return {"ok": False, "status": "busy", "running": laeuft.get("label"),
"detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."}
upd = updates() upd = updates()
parts: list[tuple[str, str]] = [] parts: list[tuple[str, str]] = []
if upd.get("engine") and ENGINE_UPDATE_CMD: if upd.get("engine") and ENGINE_UPDATE_CMD:
@@ -804,18 +709,9 @@ def update_all_job() -> dict:
if not os_pending: if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = "".join(label for label, _ in parts) labels = "".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done) on_done=_nach_update, zeitlimit_s=3 * 3600)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]} if ergebnis.get("ok"):
ergebnis["parts"] = [label for label, _ in parts]
return ergebnis
def reboot() -> dict:
if err := check_sudo_needs_password():
return err
return _run(["sudo", "reboot"])
-128
View File
@@ -1,128 +0,0 @@
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
Laden fliegt alles raus, was älter als 24 h ist.
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
Lücke pro Deploy sind egal.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from config import MODELS_DIR
log = logging.getLogger(__name__)
SAMPLE_S = 10 # Abtast-Takt
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
FLUSH_S = 300 # höchstens alle 5 min auf Platte
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
_points: deque = deque(maxlen=MAXLEN)
_loaded = False
_last_flush = 0.0
def _load() -> None:
global _loaded
if _loaded:
return
_loaded = True
try:
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
cutoff = time.time() - RETENTION_S
for p in raw if isinstance(raw, list) else []:
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
_points.append(p)
if _points:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError:
pass
except Exception:
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
def _flush() -> None:
global _last_flush
_last_flush = time.time()
try:
tmp = HISTORY_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
tmp.replace(HISTORY_PATH)
except OSError:
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
def _sample() -> None:
import psutil
from services.system import _gpu_sysfs
from services.token_stats import get_stats
vm = psutil.virtual_memory()
disk = None
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
pass
gpu = None
try:
g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None
except Exception:
pass
tp = tc = None
try:
ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception:
pass
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
async def sampler_loop() -> None:
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
_load()
while True:
try:
await asyncio.to_thread(_sample)
except Exception:
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S:
try:
await asyncio.to_thread(_flush)
except Exception:
pass
await asyncio.sleep(SAMPLE_S)
def history(minutes: int = 60) -> dict:
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
_load()
minutes = max(1, min(int(minutes), RETENTION_S // 60))
cutoff = time.time() - minutes * 60
pts = [p for p in _points if p[0] >= cutoff]
stride = max(1, len(pts) // MAX_RETURN_POINTS)
pts = pts[::stride]
return {
"sample_s": SAMPLE_S * stride,
"points": [
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
for p in pts
],
}
+4 -1
View File
@@ -1384,7 +1384,10 @@ def _tausche_ein(k: dict) -> dict:
else: else:
ziel = quelle # schon verschoben (früherer, abgebrochener Versuch) ziel = quelle # schon verschoben (früherer, abgebrochener Versuch)
pfade = _lokale_pfade(k, ziel) pfade = _lokale_pfade(k, ziel)
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), # Eintragen, Befehl ersetzen und Zwilling anlegen unter EINER Config-Sperre (24.09.2026).
with llamaswap.config_sperre():
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle],
ctx=int(k.get("ctx") or CTX_ROLLE[rolle]),
mmproj_path=None, jinja=True, set_alias=False) mmproj_path=None, jinja=True, set_alias=False)
cfg = llamaswap.read_config() cfg = llamaswap.read_config()
try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit) try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit)
-143
View File
@@ -1,143 +0,0 @@
"""
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
und fit.evaluate_fit dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
"""
import psutil
from services import budget, catalog, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _capability_suit(role: str, caps: dict, name: str) -> float:
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
if role == "vision":
if not vision:
return 0.0 # harte Anforderung
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
if role == "coder":
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
if role == "hermes":
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
if "hermes" in low:
return 1.0
return 0.6 if tools else 0.1
if role == "fast":
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
return 1.0 if tools else 0.6
if role == "heavy":
return 1.0
if role == "scout":
return 0.9 if vision else 0.7
return 0.5
def _pref(role: str, params: float, tps: float) -> float:
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
role = (role or "").lower()
if role == "fast":
speed = min(tps / 25.0, 1.0)
size_ok = 1.0 if params <= 50 else 50.0 / params
return speed * size_ok
if role == "heavy":
return min(params / 120.0, 1.0)
if role == "hermes":
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 724B ideal als Hirn
if role == "vision":
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
if role == "coder":
return 0.5 + 0.5 * min(params / 80.0, 1.0)
if role == "scout":
return 1.0 if params <= 40 else 0.5
return 0.5
def _catalog_role_match(role: str, name: str) -> bool:
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
Dann ist es der prinzipien-konforme Pick starker Bonus."""
meta = catalog.meta_for_name(name)
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
incomplete: bool, cat_match: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "coder" and not caps.get("coder"):
return "kein Coder-Modell"
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if cat_match:
bits.append("Katalog-Pick ✓")
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("Hermes" if "hermes" in (name or "").lower()
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
if caps.get("moe"):
bits.append("MoE")
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
return " · ".join(bits)
def recommend_for_role(role: str) -> dict:
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
role = (role or "").strip().lower()
ram = _ram_gb()
out = []
for m in llamaswap.list_models():
caps = m.get("capabilities") or {}
params = budget.params_of_model(m)
quant = m.get("quant") or "Q4_K_M"
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
incomplete = bool(m.get("incomplete"))
fits = (fit["level"] != "too_tight") and not incomplete
tps = fit["tps"] or 0
suit = _capability_suit(role, caps, m["name"])
cat_match = _catalog_role_match(role, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
if not fits:
score -= 5.0
out.append({
"name": m["name"], "current_role": m.get("role"),
"params_b": round(params, 1), "quant": quant,
"fit": fit, "suitable": suitable, "incomplete": incomplete,
"score": round(score, 3),
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
})
out.sort(key=lambda x: -x["score"])
rec = next((o["name"] for o in out if o["suitable"]), None)
for o in out:
o["recommended"] = (o["name"] == rec)
return {"role": role, "recommended": rec, "models": out}
-17
View File
@@ -19,8 +19,6 @@ from services.routing_policy import load_policy
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars # (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code. # sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
LANES = ["coding", "chat"]
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
_HEAVY_KW = re.compile( _HEAVY_KW = re.compile(
@@ -36,18 +34,6 @@ _CODING_HEAVY_KW = re.compile(
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b", r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
re.IGNORECASE, re.IGNORECASE,
) )
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
_CODE_HINT = re.compile(
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
r"npm|pip|git|docker|terminal|shell|command)\b",
re.IGNORECASE | re.MULTILINE,
)
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. # Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
@@ -150,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
return _route_chat(text, n) # chat + alles Unbekannte return _route_chat(text, n) # chat + alles Unbekannte
def choose_model(body: dict) -> tuple[str, str]:
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
return choose_for_lane("chat", body)
-18
View File
@@ -115,21 +115,3 @@ def load_policy() -> dict:
return dict(_CACHE["policy"]) return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
-173
View File
@@ -1,173 +0,0 @@
"""
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
Zwei Sichten auf dieselben Messungen:
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) avg/p50/p95/last je Stufe über die
letzten N Messungen. Gut für Trends (Wie schnell ist STT im Schnitt?").
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) jeder einzelne Chat-Turn als eigener Datensatz mit
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
**Was MC2 messen kann und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern es
gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 unsichtbar, im Generierung"-Bucket.
STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global geparkt" und vom
nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client
nötig.
In-Memory + thread-safe (keine Datei-I/O Latenz-Telemetrie ist transient, Restart = Reset).
"""
import threading
import time
import uuid
from collections import deque
_LOCK = threading.Lock()
_MAX = 200
_STAGES: dict[str, deque] = {}
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None}
def record_stage(stage: str, ms: float) -> None:
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
if ms is None or ms < 0:
return
with _LOCK:
dq = _STAGES.get(stage)
if dq is None:
dq = _STAGES[stage] = deque(maxlen=_MAX)
dq.append(float(ms))
def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken,
damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED:
return
with _LOCK:
_PARKED[kind] = (float(ms), time.perf_counter())
def _take_stt(max_age: float = 20.0) -> float | None:
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
with _LOCK:
v = _PARKED.get("stt")
if v and (time.perf_counter() - v[1]) <= max_age:
_PARKED["stt"] = None
return round(v[0], 1)
return None
class Timer:
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
Funktioniert um `await`-Aufrufe herum (enter await exit)."""
def __init__(self, stage: str) -> None:
self.stage = stage
self._t0 = 0.0
def __enter__(self) -> "Timer":
self._t0 = time.perf_counter()
return self
def __exit__(self, *exc) -> None:
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
class TurnTrace:
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen."""
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
self.id = uuid.uuid4().hex[:8]
self.ts = time.time()
self.perf0 = time.perf_counter()
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
self.session_id = (session_id or "")[:24]
self.kind = kind
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT)
self.had_images = False
self.error: str | None = None
def note_vision(self, ms: float) -> None:
self.vision_ms = round(ms, 1)
record_stage("vision", ms)
def mark_brain_start(self) -> None:
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
self._brain0 = time.perf_counter()
def note_ttfb(self) -> None:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms)
def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0
stt = _take_stt() # rollend bereits in /voice/stt erfasst
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = {
"id": self.id,
"ts": round(self.ts, 3),
"session": self.session_id,
"kind": self.kind,
"had_images": self.had_images,
"stt_ms": stt,
"vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"total_ms": round(total, 1),
"error": self.error,
}
with _LOCK:
_TURNS.append(rec)
return rec
def _summary(vals: list[float]) -> dict:
if not vals:
return {"count": 0}
s = sorted(vals)
n = len(s)
return {
"count": n,
"avg_ms": round(sum(s) / n, 1),
"p50_ms": round(s[n // 2], 1),
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
"last_ms": round(vals[-1], 1),
}
def get_metrics() -> dict:
"""Rollende Zusammenfassung je Stufe."""
with _LOCK:
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
def get_trace(limit: int = 20) -> list[dict]:
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
with _LOCK:
return list(_TURNS)[-limit:][::-1]
+9 -2
View File
@@ -480,8 +480,15 @@ def takt() -> None:
continue # während eines Updates sind Neustarts normal continue # während eines Updates sind Neustarts normal
try: try:
befunde += pruefung() befunde += pruefung()
except Exception: except Exception as exc:
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True) # Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das
# Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert).
log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
befunde.append(Befund(
id=f"pruefung:{pruefung.__name__}", stufe="gelb",
titel="Eine Prüfung des Wächters lief nicht",
text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240],
quelle="mc2-steward"))
with _lock: with _lock:
hinweise: dict = _stand["hinweise"] hinweise: dict = _stand["hinweise"]
+23
View File
@@ -0,0 +1,23 @@
"""Herkunftsprüfung für Knöpfe (24.09.2026)."""
from services.herkunft import erlaubt
BOX = "192.168.178.151:9001"
def test_eigene_seite_und_skripte_duerfen():
assert erlaubt("POST", "/api/maintenance/os-update", "http://192.168.178.151:9001", BOX)
assert erlaubt("POST", "/api/alarm", None, BOX) # Lucy-Watchdog, curl
assert erlaubt("POST", "/api/voice/chat", "null", BOX) # Desktop-Lucy (Electron)
assert erlaubt("POST", "/api/voice/stt", "file://", BOX)
assert erlaubt("POST", "/api/system/restart", "http://localhost:5173", BOX) # Vite-Entwicklung
def test_fremde_webseite_darf_keine_knoepfe_druecken():
assert not erlaubt("POST", "/api/maintenance/update-all", "https://boese.example", BOX)
assert not erlaubt("DELETE", "/api/models/x", "http://192.168.178.99:8080", BOX)
def test_lesen_und_v1_bleiben_offen():
assert erlaubt("GET", "/api/start", "https://boese.example", BOX)
assert erlaubt("POST", "/v1/chat/completions", "http://localhost:3000", BOX)
+19
View File
@@ -756,3 +756,22 @@ def test_immer_warme_gruppe_verdraengt_nichts(monkeypatch):
assert cfg["models"]["h"]["ttl"] == 0 assert cfg["models"]["h"]["ttl"] == 0
llamaswap.set_group("andere", ["h"], swap=True, persist=False) llamaswap.set_group("andere", ["h"], swap=True, persist=False)
assert "exclusive" not in cfg["groups"]["andere"] assert "exclusive" not in cfg["groups"]["andere"]
def test_hf_download_nimmt_nur_den_gewuenschten_quant(monkeypatch):
"""24.09.2026: Fehlt der Quant, lädt der Download NICHTS (vorher: alle Teile aller Varianten)."""
from services import hf
baum = [
{"path": "Q8_0/Modell-Q8_0-00001-of-00002.gguf", "size": 50},
{"path": "Q8_0/Modell-Q8_0-00002-of-00002.gguf", "size": 50},
{"path": "Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "size": 30},
{"path": "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf", "size": 30},
{"path": "mmproj-F16.gguf", "size": 5},
]
monkeypatch.setattr(hf, "_tree", lambda repo: baum)
leer = hf.resolve_gguf("x/y", "IQ3_XXS")
assert leer["first"] is None and leer["files"] == []
q4 = hf.resolve_gguf("x/y", "Q4_K_M")
assert q4["files"] == ["Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf"]
assert q4["total_bytes"] == 65 and q4["mmproj"] == "mmproj-F16.gguf"
+19
View File
@@ -120,3 +120,22 @@ def test_schlafende_dienste_sind_kein_befund(monkeypatch):
assert "dienst:lucy-stimme" in ids assert "dienst:lucy-stimme" in ids
assert waechter.schlaeft(zustaende["voice-service"]) is True assert waechter.schlaeft(zustaende["voice-service"]) is True
assert waechter.schlaeft({"ActiveState": "failed", "UnitFileState": "disabled"}) is False assert waechter.schlaeft({"ActiveState": "failed", "UnitFileState": "disabled"}) is False
def test_abgestuerzte_pruefung_wird_ein_gelber_befund(monkeypatch, tmp_path):
"""24.09.2026: Eine Prüfung, die abstürzt, darf nicht still verschwinden (Cockpit blieb grün)."""
def kaputt():
raise ValueError("jobs.json hat ein neues Format")
kaputt.__name__ = "pruefe_hermes_jobs"
monkeypatch.setattr(waechter, "PRUEFUNGEN", (kaputt,))
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: None)
monkeypatch.setattr(waechter, "FAIL_AFTER", 1)
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "stand": None,
"update_laeuft": False})
waechter.takt()
hinweise = waechter._stand["hinweise"]
assert "pruefung:pruefe_hermes_jobs" in hinweise
assert "neues Format" in hinweise["pruefung:pruefe_hermes_jobs"]["text"]
+1 -1
View File
@@ -13,7 +13,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=%h/mission-control-v2/backend WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001 ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001 --timeout-graceful-shutdown 3
Environment=PYTHONPATH=%h/mission-control-v2 Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_PORT=9001 Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080 Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
-55
View File
@@ -1,55 +0,0 @@
# Skript zur Filterung inaktiver Skills
## Zweck
Das Skript `filter_inactive_skills.py` identifiziert Skills aus dem Hermes Skill-Index, die **nicht** genutzt wurden und **keine** Media-Skills sind.
## Kriterien
Ein Skill gilt als inaktiv, wenn **mindestens eines** der folgenden Kriterien zutrifft:
- `use_count == 0`
- `last_used_at == null` (nie genutzt)
Media-Skills (die im `~/.hermes/skills/media/`-Verzeichnis liegen) werden **ausgeschlossen**:
- gif-search
- heartmula
- songsee
- youtube-content
## Ausgabe
Die Ergebnisse werden als Liste sortiert nach `last_used_at` (älteste Nutzung zuerst) ausgegeben. Skills, die **nie** genutzt wurden (`last_used_at == null`), erscheinen am Ende.
Beispiel-Ausgabe:
```
Inaktive Skills (use_count=0 oder last_used_at=null, ohne Media-Skills): 45
--------------------------------------------------------------------------------
airtable use_count= 0 last_used_at=nie
apple-notes use_count= 0 last_used_at=nie
...
--------------------------------------------------------------------------------
```
## Verwendung
```bash
python3 filter_inactive_skills.py
```
## Dateistruktur
```
t_47fb5aaa/
├── filter_inactive_skills.py # Hauptskript
└── README.md # Diese Dokumentation
```
## Datenquelle
- Skill-Metadaten: `~/.hermes/skills/.usage.json`
- Media-Skills: `~/.hermes/skills/media/`
## Historie
- 2026-07-17: Erstellt im Rahmen von Kanban-Aufgabe t_47fb5aaa
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Prüft, ob der Python-Interpreter im MC2-Virtualenv existiert und ausfuehrbar ist.
# Gibt bei Erfolg 0 zurueck, sonst 1 mit klarer Fehlermeldung.
#
# Aufruf: ./check_venv.sh
# Ziel-Pfad: /home/hitonabi/mission-control-v2/backend/.venv/bin/python
set -euo pipefail
VENV_PYTHON="/home/hitonabi/mission-control-v2/backend/.venv/bin/python"
if [[ ! -f "$VENV_PYTHON" ]]; then
echo "FEHLER: Python-Interpreter nicht gefunden: $VENV_PYTHON" >&2
echo "Hinweis: Virtualenv fuer MC2-Gateway wurde nicht erstellt oder ist beschädigt." >&2
exit 1
fi
if [[ ! -x "$VENV_PYTHON" ]]; then
echo "FEHLER: Python-Interpreter nicht ausfuehrbar: $VENV_PYTHON" >&2
echo "Hinweis: Dateiberechtigungen pruefen." >&2
exit 1
fi
# Optional: Kurze Funktionspruefung (Version ausgeben, ohne Side Effects)
if ! "$VENV_PYTHON" --version >/dev/null 2>&1; then
echo "FEHLER: Python-Interpreter ist defekt oder fehlt essentielle Pakete." >&2
exit 1
fi
echo "OK: Python-Interpreter ist verfuegbar: $VENV_PYTHON ($("$VENV_PYTHON" --version 2>&1 | head -n1))"
exit 0
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""
Skript zur Filterung inaktiver Skills.
Filtert Skills aus ~/.hermes/skills/.usage.json, die:
- use_count == 0 ODER last_used_at == null
- NICHT im media/ Verzeichnis liegen (Media-Skills werden ausgeschlossen)
Ausgabe: Liste sortiert nach last_used_at (älteste zuerst), nur Skills mit use_count 0.
"""
import json
from datetime import datetime
from pathlib import Path
def load_usage_data(path: Path) -> dict:
"""Lädt die .usage.json-Datei."""
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_media_skills(skills_dir: Path) -> set:
"""Ermittelt alle Media-Skills aus dem media/ Verzeichnis."""
media_path = skills_dir / 'media'
if not media_path.is_dir():
return set()
media_skills = set()
for item in media_path.iterdir():
if item.is_dir():
media_skills.add(item.name)
return media_skills
def is_media_skill(skill_name: str, media_skills: set) -> bool:
"""Prüft, ob ein Skill ein Media-Skill ist."""
return skill_name in media_skills
def parse_datetime(dt_str: str | None) -> datetime | None:
"""Parsed einen datetime-string im ISO-Format (mit timezone)."""
if dt_str is None:
return None
# Entferne den timezone-Offset für datetime.fromisoformat (Python 3.11+ supportet +00:00)
# Aber da wir nur sortieren, reicht es, den String direkt zu nutzen
try:
return datetime.fromisoformat(dt_str)
except ValueError:
return None
def filter_inactive_skills(usage: dict, media_skills: set) -> list:
"""
Filtert inaktive Skills.
Kriterien:
- use_count == 0 ODER last_used_at == null
- NICHT in media_skills
Gibt Liste zurück mit (skill_name, use_count, last_used_at)
"""
inactive = []
for skill_name, data in usage.items():
if is_media_skill(skill_name, media_skills):
continue
use_count = data.get('use_count', 0)
last_used_at = data.get('last_used_at')
# Filter: use_count == 0 ODER last_used_at == null
if use_count == 0 or last_used_at is None:
inactive.append({
'name': skill_name,
'use_count': use_count,
'last_used_at': last_used_at
})
# Sortieren nach last_used_at (älteste zuerst, None am Ende)
def sort_key(item):
dt = parse_datetime(item['last_used_at'])
if dt is None:
return (1, '') # None am Ende
return (0, dt.isoformat())
inactive.sort(key=sort_key)
return inactive
def print_results(skills: list) -> None:
"""Gibt die Ergebnisse lesbar aus."""
if not skills:
print("Keine inaktiven Skills gefunden.")
return
print(f"Inaktive Skills (use_count=0 oder last_used_at=null, ohne Media-Skills): {len(skills)}\n")
print("-" * 80)
for skill in skills:
name = skill['name']
use_count = skill['use_count']
last_used = skill['last_used_at'] if skill['last_used_at'] else 'nie'
print(f" {name:30} use_count={use_count:3} last_used_at={last_used}")
print("-" * 80)
def main():
skills_dir = Path.home() / '.hermes' / 'skills'
usage_path = skills_dir / '.usage.json'
if not usage_path.exists():
print(f"Fehler: {usage_path} nicht gefunden.")
return
usage = load_usage_data(usage_path)
media_skills = get_media_skills(skills_dir)
inactive_skills = filter_inactive_skills(usage, media_skills)
print_results(inactive_skills)
if __name__ == '__main__':
main()