From 12dadfe6ef8f4c7b0acc114708c0e860ce02cb99 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Wed, 23 Sep 2026 21:16:18 +0200 Subject: [PATCH] =?UTF-8?q?umbau(boxwart):=20Backend=20auf=20Box-Wart=20um?= =?UTF-8?q?gestellt=20=E2=80=93=20Waechter,=20Modell-Nutzung,=20Zeitplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026). - Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe, Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy. Laeuft im mc2-steward; waehrend eines Updates haelt er still. - Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen), /api/modelle/nutzung, /api/zeitplan. - Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde). - Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events; Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise. - Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot); Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan. - Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung. Co-Authored-By: Claude Opus 5.5 --- backend/app.py | 35 +- backend/routers/boxwart.py | 178 ++++ backend/routers/chronik.py | 28 - backend/routers/connect.py | 17 - backend/routers/console.py | 87 -- backend/routers/events.py | 30 +- backend/routers/ideen.py | 154 ---- backend/routers/skills.py | 102 --- backend/routers/system.py | 8 +- backend/routers/wissen.py | 151 ---- backend/services/backup.py | 4 +- backend/services/connect.py | 156 ---- backend/services/ideen.py | 1215 -------------------------- backend/services/maintenance.py | 5 +- backend/services/modell_nutzung.py | 109 +++ backend/services/sentry.py | 164 ---- backend/services/system.py | 47 +- backend/services/waechter.py | 535 ++++++++++++ backend/services/zeitplan.py | 99 +++ backend/steward.py | 15 +- backend/tests/conftest.py | 7 + backend/tests/test_modell_nutzung.py | 45 + backend/tests/test_waechter.py | 79 ++ deploy/projekte-sync.sh | 10 + mcp/mcp_voice.py | 30 +- 25 files changed, 1131 insertions(+), 2179 deletions(-) create mode 100644 backend/routers/boxwart.py delete mode 100644 backend/routers/chronik.py delete mode 100644 backend/routers/connect.py delete mode 100644 backend/routers/console.py delete mode 100644 backend/routers/ideen.py delete mode 100644 backend/routers/skills.py delete mode 100644 backend/routers/wissen.py delete mode 100644 backend/services/connect.py delete mode 100644 backend/services/ideen.py create mode 100644 backend/services/modell_nutzung.py delete mode 100644 backend/services/sentry.py create mode 100644 backend/services/waechter.py create mode 100644 backend/services/zeitplan.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_modell_nutzung.py create mode 100644 backend/tests/test_waechter.py diff --git a/backend/app.py b/backend/app.py index d140066..c378866 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,6 +4,10 @@ Mission Control 2.0 — dünner FastAPI-Einstieg. Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- Server (proxyt /api hierher), daher CORS für localhost offen. + +Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar. +Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen, +die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben. """ import asyncio @@ -21,25 +25,20 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from routers import ( agent, - chronik, - connect, - console, + boxwart, events, gateway_proxy, health, hermes_ui, - ideen, maintenance, models, routing, - skills, system, voice, - wissen, zeitmaschine, ) from routers import reminders as reminders_router -from services import metrics_history, reminders, sentry, warmer +from services import metrics_history, reminders, warmer from starlette.requests import Request # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration @@ -53,8 +52,9 @@ log = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn - + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram).""" + """Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier, + sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich + sonst nicht selbst melden.""" tasks: list[asyncio.Task[Any]] = [] if warmer.ENABLED: tasks.append(asyncio.create_task(warmer.rewarm_loop())) @@ -62,8 +62,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL, ) - if sentry.ENABLED: - tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop())) # 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert. tasks.append(asyncio.create_task(metrics_history.sampler_loop())) @@ -103,11 +101,11 @@ async def no_cache( app.include_router(health.router) +app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan app.include_router(models.router) app.include_router(routing.router) app.include_router(system.router) -app.include_router(connect.router) app.include_router(agent.router) app.include_router( voice.router @@ -125,18 +123,11 @@ if V1_UPSTREAM: else: app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(maintenance.router) -app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale -app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store) -app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus -app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only -app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached) -app.include_router(skills.router) # Skills & Jobs Dashboard -app.include_router( - console.router -) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all +app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation +app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached) app.include_router( hermes_ui.router -) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all +) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py new file mode 100644 index 0000000..da0c274 --- /dev/null +++ b/backend/routers/boxwart.py @@ -0,0 +1,178 @@ +""" +Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht. + + GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan + GET /api/hinweise Hinweise + Verlauf des Wächters + POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen + GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde) + GET /api/zeitplan Heute gelaufen / demnächst geplant + +Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py. +""" + +import os +import threading +import time +from datetime import datetime +from zoneinfo import ZoneInfo + +import psutil +from config import MODELS_DIR +from fastapi import APIRouter, HTTPException +from services import backup, llamaswap, maintenance, modell_nutzung, system, waechter, zeitplan + +router = APIRouter(prefix="/api", tags=["boxwart"]) +LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) + +_updates_lock = threading.Lock() +_updates_cache: dict = {"ts": 0.0, "daten": None} +UPDATES_CACHE_S = 600 + + +def _updates_gecacht() -> dict: + """maintenance.updates() fragt GitHub, apt und Hugging Face — das darf den Start nicht bremsen.""" + with _updates_lock: + if _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S: + try: + _updates_cache["daten"] = maintenance.updates() + except Exception: + _updates_cache["daten"] = _updates_cache["daten"] or {} + _updates_cache["ts"] = time.time() + return _updates_cache["daten"] or {} + + +def _bausteine(u: dict) -> list[dict]: + """Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft.""" + liste = [] + if u.get("os"): + liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"}) + if u.get("engine"): + liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"}) + if u.get("swap"): + liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"}) + for c in u.get("components") or []: + if c.get("key") == "hermes_agent" and c.get("update"): + liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"}) + return liste + + +def _zeit_kurz(ts: float | None) -> str: + if not ts: + return "–" + dt = datetime.fromtimestamp(ts, LOCAL_TZ) + heute = datetime.now(LOCAL_TZ).date() + if dt.date() == heute: + return f"heute {dt:%H:%M}" + if (heute - dt.date()).days == 1: + return f"gestern {dt:%H:%M}" + return f"{dt:%d.%m. %H:%M}" + + +def _lampen(stand: dict, u: dict) -> list[dict]: + ids = {h.get("id", "") for h in stand["hinweise"]} + versionen = system.check_versions_cached() + + def lampe(lid: str, label: str, zustand: str, wert: str) -> dict: + return {"id": lid, "label": label, "zustand": zustand, "wert": wert} + + engine_ok = llamaswap.engine_reachable() + build = (versionen.get("engine") or {}).get("version_text", "") + lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")] + + hirn = llamaswap.brain_status() if engine_ok else {} + lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler", + "geladen" if hirn.get("ready") else "lädt nicht")) + + laufend = set(llamaswap.get_running_models()) if engine_ok else set() + coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None) + coder_an = bool(coder and coder.get("name") in laufend) + lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf")) + + hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway")) + lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok", + "antwortet nicht" if hermes_weg else "läuft")) + + job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))] + rot = any(h.get("stufe") == "rot" for h in job_hinweise) + lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"), + f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}" + if job_hinweise else "alles gelaufen")) + + try: + sicherungen = backup.list_backups() + except Exception: + sicherungen = [] + letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0) + zu_alt = not letzte or time.time() - letzte > 36 * 3600 + lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte))) + + try: + platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent + except Exception: + platte = None + zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else ( + "warn" if platte < waechter.DISK_ROT_PCT else "fehler") + lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–")) + + n = len(_bausteine(u)) + lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell")) + return lampen + + +def _box() -> dict: + p = system.metrik_punkt() + try: + du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".") + platte = {"used": du.used, "total": du.total, "percent": du.percent} + except Exception: + platte = None + return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"), + "temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"), + "platte": platte, "uptime_s": p.get("uptime_s")} + + +@router.get("/start") +def start() -> dict: + stand = waechter.lese_stand() + u = _updates_gecacht() + rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot") + anzahl = len(stand["hinweise"]) + wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60 + return { + "zustand": { + "stufe": "rot" if rot else ("gelb" if anzahl else "ok"), + "anzahl": anzahl, + "waechter_wach": wach, + "stand": stand["stand"], + "update_laeuft": stand["update_laeuft"], + }, + "hinweise": stand["hinweise"], + "verlauf": stand["verlauf"][:20], + "lampen": _lampen(stand, u), + "box": _box(), + "updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or []}, + "flugplan": zeitplan.flugplan(), + } + + +@router.get("/hinweise") +def hinweise() -> dict: + return waechter.lese_stand() + + +@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}") +def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict: + ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id) + if ergebnis.get("detail") and not ergebnis.get("ok"): + raise HTTPException(status_code=409, detail=ergebnis["detail"]) + return ergebnis + + +@router.get("/modelle/nutzung") +def nutzung() -> dict: + return modell_nutzung.nutzung() + + +@router.get("/zeitplan") +def zeitplan_route() -> dict: + return zeitplan.flugplan() diff --git a/backend/routers/chronik.py b/backend/routers/chronik.py deleted file mode 100644 index d69ebf2..0000000 --- a/backend/routers/chronik.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat. -Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter, -Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons — alle -autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt.""" - -from fastapi import APIRouter -from pydantic import BaseModel -from services import announce - -router = APIRouter(prefix="/api") - - -class ChronikItem(BaseModel): - id: int - ts: float - subject: str - text: str - source: str - priority: str - - -class ChronikResponse(BaseModel): - items: list[ChronikItem] - - -@router.get("/chronik", response_model=ChronikResponse) -def chronik(limit: int = 150) -> dict: - return {"items": announce.list_recent(limit)} diff --git a/backend/routers/connect.py b/backend/routers/connect.py deleted file mode 100644 index 2844fda..0000000 --- a/backend/routers/connect.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets für den Gateway.""" - -from fastapi import APIRouter -from services.connect import DEFAULT_HOST, build_snippets, check_health - -router = APIRouter(prefix="/api") - - -@router.get("/connect") -def connect(host: str = DEFAULT_HOST) -> dict: - return build_snippets(host=host) - - -@router.get("/connect/health") -def connect_health() -> dict: - """Live-Status der drei Leitungen (Gateway, Gedächtnis, Desktop-Gateway) für den Verbinden-Tab.""" - return check_health() diff --git a/backend/routers/console.py b/backend/routers/console.py deleted file mode 100644 index f76beac..0000000 --- a/backend/routers/console.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal). - -Beide ttyd-Dienste binden NUR an Loopback (--base-path /) und werden hier über -den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token) -+ WebSocket (//ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und -alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll -wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die -Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy. -""" - -import asyncio -import logging - -import httpx -import websockets -from config import BOX_CONSOLE_UPSTREAM -from fastapi import APIRouter, Request, WebSocket -from starlette.responses import Response - -log = logging.getLogger(__name__) -router = APIRouter() - - -def _register(base: str, upstream: str) -> None: - """Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/`).""" - ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://") - - @router.websocket(f"/{base}/ws") - async def _proxy_ws(ws: WebSocket) -> None: - await ws.accept(subprotocol="tty") - try: - async with websockets.connect( - f"{ws_upstream}/{base}/ws", subprotocols=["tty"], - open_timeout=10, max_size=None, ping_interval=None, - ) as up: - async def c2u() -> None: - while True: - msg = await ws.receive() - if msg.get("type") == "websocket.disconnect": - return - if (t := msg.get("text")) is not None: - await up.send(t) - elif (b := msg.get("bytes")) is not None: - await up.send(b) - - async def u2c() -> None: - async for m in up: - if isinstance(m, (bytes, bytearray)): - await ws.send_bytes(bytes(m)) - else: - await ws.send_text(m) - - _done, pending = await asyncio.wait( - [asyncio.create_task(c2u()), asyncio.create_task(u2c())], - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - except Exception: - log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True) - finally: - try: - await ws.close() - except Exception: - pass - - async def _proxy_http(request: Request, path: str = "") -> Response: - url = f"{upstream}/{base}/{path}" - try: - async with httpx.AsyncClient(timeout=10.0) as c: - r = await c.request(request.method, url, content=await request.body()) - return Response(content=r.content, status_code=r.status_code, - media_type=r.headers.get("content-type")) - except httpx.HTTPError as exc: - log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc) - return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502, - media_type="text/plain") - - router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root") - router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"], - name=f"{base}_path") - - -# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem -# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.) -_register("console", BOX_CONSOLE_UPSTREAM) diff --git a/backend/routers/events.py b/backend/routers/events.py index 6f284d9..b3b484f 100644 --- a/backend/routers/events.py +++ b/backend/routers/events.py @@ -1,14 +1,10 @@ """Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken. -Zwei Endpunkte, ein Sammler: - - GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten: + GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten: · `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln. · `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler). - GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen, - weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses - nur `/api/events` kennt. Danach entfernen. +Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen. WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und `/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob @@ -56,15 +52,9 @@ def _fingerprints() -> dict[str, object]: """Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen).""" fp: dict[str, object] = {} - try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig - from services import announce - fp["chronik"] = announce.list_after(None)["latest"] - except Exception: - pass - try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts - from services import ideen - d = ideen.list_queue() - fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []]) + try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden + from services import waechter + fp["start"] = _mtime(waechter.STORE_PATH) except Exception: pass try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen @@ -133,14 +123,6 @@ _KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} @router.get("/stream") async def stream(request: Request) -> StreamingResponse: - """Der aktuelle Kanal: Anstöße UND Messpunkte.""" + """Anstöße UND Messpunkte.""" return StreamingResponse(_strom(request, mit_metrik=True), media_type="text/event-stream", headers=_KOPF) - - -@router.get("/events") -async def events(request: Request) -> StreamingResponse: - """Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor - dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen.""" - return StreamingResponse(_strom(request, mit_metrik=False), - media_type="text/event-stream", headers=_KOPF) diff --git a/backend/routers/ideen.py b/backend/routers/ideen.py deleted file mode 100644 index 00e0023..0000000 --- a/backend/routers/ideen.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban). -LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch.""" - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from services import ideen - -router = APIRouter(prefix="/api") - - -class IdeeIn(BaseModel): - titel: str - notiz: str = "" - welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE - tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst) - ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet) - art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken) - - -class TaskIn(BaseModel): - id: str - - -class AntwortIn(BaseModel): - id: str - antwort: str - - -class ArchivAlleIn(BaseModel): - # Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts. - projekt: str = "" - - -class EinordnenIn(BaseModel): - text: str - - -@router.get("/ideen") -def list_queue() -> dict: - return ideen.list_queue() - - -@router.post("/ideen") -def add_idea(body: IdeeIn) -> dict: - res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe, - ziel=body.ziel, art=body.art) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden.")) - return res - - -@router.post("/ideen/antwort") -def answer(body: AntwortIn) -> dict: - res = ideen.answer_task(body.id, body.antwort) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden.")) - return res - - -class PrioIn(BaseModel): - id: str - richtung: str # hoch | runter - - -@router.post("/ideen/stopp") -def stop(body: TaskIn) -> dict: - res = ideen.stop_task(body.id) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen.")) - return res - - -@router.post("/ideen/retry") -def retry(body: TaskIn) -> dict: - res = ideen.retry_task(body.id) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen.")) - return res - - -@router.post("/ideen/prio") -def prio(body: PrioIn) -> dict: - res = ideen.prio_task(body.id, body.richtung) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen.")) - return res - - -class WeiterIn(BaseModel): - id: str - ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet) - tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst) - - -@router.post("/ideen/weiterfuehren") -def weiterfuehren(body: WeiterIn) -> dict: - """„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage.""" - res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden.")) - return res - - -class NachschaerfenIn(BaseModel): - id: str - hinweis: str - - -@router.post("/ideen/nachschaerfen") -def nachschaerfen(body: NachschaerfenIn) -> dict: - """„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept.""" - res = ideen.konzept_ueberarbeiten(body.id, body.hinweis) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden.")) - return res - - -@router.post("/ideen/archivieren") -def archive(body: TaskIn) -> dict: - res = ideen.archive_task(body.id) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen.")) - return res - - -@router.post("/ideen/einordnen") -def einordnen(body: EinordnenIn) -> dict: - """Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false` - und die Oberfläche behält ihre Voreinstellung.""" - return ideen.einordnen(body.text) - - -@router.post("/ideen/archivieren-alle") -def archive_alle(body: ArchivAlleIn) -> dict: - """Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an.""" - res = ideen.archive_done(body.projekt) - if not res.get("ok"): - raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen.")) - return res - - -@router.get("/ideen/{id}/log") -def get_log(id: str) -> dict: - return ideen.log_of(id) - - -@router.get("/ideen/{id}/ergebnis") -def get_ergebnis(id: str) -> dict: - return ideen.ergebnis_of(id) - - -@router.get("/ideen/{id}/konzept") -def get_konzept(id: str) -> dict: - return ideen.konzept_of(id) diff --git a/backend/routers/skills.py b/backend/routers/skills.py deleted file mode 100644 index c3a7352..0000000 --- a/backend/routers/skills.py +++ /dev/null @@ -1,102 +0,0 @@ -import os -import subprocess - -import psutil -from fastapi import APIRouter -from pydantic import BaseModel - -router = APIRouter(prefix="/api") - - -def _hermes_name(ordner: str) -> str: - """Repo-Ordnername -> Hermes-Skillname. - - deploy/deploy.sh kopiert `deploy/skills/` nach `~/.hermes/skills/` - (`tr '-' '_'`), weil Hermes keine Bindestriche in Skillnamen mag. Ohne dieselbe - Umschreibung hier bekam Hermes den Ordnernamen MIT Bindestrich und fand den Skill - nicht - betroffen waren 7 der 12 Skills (betrieb-playbook, konzept-fliessband, - llm-wiki, morning-report, pc-pfad-cache, projekt-start, trend-radar): der Startknopf - meldete Erfolg, ausgefuehrt wurde nichts. deploy.sh bleibt die Quelle der Wahrheit. - """ - return ordner.replace("-", "_") - - -class RunSkillRequest(BaseModel): - skill_name: str - -@router.get("/skills") -def list_skills(): - """Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf.""" - # Pfad relativ zu dieser Datei (backend/routers/skills.py) - current_dir = os.path.dirname(os.path.abspath(__file__)) - repo_root = os.path.dirname(os.path.dirname(current_dir)) - skills_dir = os.path.join(repo_root, "deploy", "skills") - skills = [] - - # Check currently running hermes skill processes - running_skills = set() - for p in psutil.process_iter(['name', 'cmdline']): - try: - cmdline = p.info.get('cmdline') - if cmdline and any("hermes" in arg for arg in cmdline): - for arg in cmdline: - if "Führe den " in arg and " Skill aus" in arg: - # Extract skill name from "Führe den 'skill_name' Skill aus" - import re - match = re.search(r"Führe den '(.+?)' Skill aus", arg) - if match: - running_skills.add(match.group(1)) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - pass - - if os.path.exists(skills_dir) and os.path.isdir(skills_dir): - for entry in os.scandir(skills_dir): - if entry.is_dir(): - # Suche nach SKILL.md für Metadaten - skill_md_path = os.path.join(entry.path, "SKILL.md") - description = "" - if os.path.exists(skill_md_path): - try: - with open(skill_md_path, "r", encoding="utf-8") as f: - # Lese erste Zeilen für Description (YAML Frontmatter) - lines = f.readlines()[:10] - for line in lines: - if "description:" in line.lower(): - description = line.split(":", 1)[1].strip().strip('"\'') - break - elif "author:" in line.lower() and not description: - description = line.strip() - except Exception: - pass - - skills.append({ - "name": entry.name, - # Der Name, unter dem Hermes den Skill kennt - auch fuer den Laufend- - # Vergleich, weil im Prozess-cmdline genau dieser Name steht. - "hermes_name": _hermes_name(entry.name), - "description": description or "Keine Beschreibung verfügbar.", - "running": _hermes_name(entry.name) in running_skills - }) - return {"skills": skills} - -@router.post("/skills/run") -def run_skill(req: RunSkillRequest): - """Startet einen autonomen Skill via Hermes im Hintergrund.""" - hermes_bin = os.path.expanduser("~/.local/bin/hermes") - if not os.path.exists(hermes_bin): - # Fallback falls lokal (auf Windows) entwickelt wird - return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"} - - # Entspricht: hermes -z "Führe den 'X' Skill aus" chat - mit dem Namen, den Hermes - # nach dem Deploy-Sync wirklich kennt (Bindestriche -> Unterstriche, s. _hermes_name). - skill = _hermes_name(req.skill_name) - cmd = [hermes_bin, "-z", f"Führe den '{skill}' Skill aus", "chat"] - try: - log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log") - os.makedirs(os.path.dirname(log_path), exist_ok=True) - with open(log_path, "a", encoding="utf-8") as f: - f.write(f"\n--- Starting Skill: {skill} ---\n") - subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT) - return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."} - except Exception as e: - return {"ok": False, "err": str(e)} diff --git a/backend/routers/system.py b/backend/routers/system.py index 9f8e1f3..291fbf4 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -94,10 +94,10 @@ def services() -> dict: "ok": a["hermes_ui_reachable"], "scope": "user"}, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable(), "scope": "user"}, - # ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste, - # das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf. - {"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/", - "ok": bool(a.get("box_console_reachable")), "scope": "user"}, + # Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte + # sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus. + {"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", + "ok": _user_unit_active("lucy-stimme"), "scope": "user"}, ] return { "services": rows, diff --git a/backend/routers/wissen.py b/backend/routers/wissen.py deleted file mode 100644 index 26cac1f..0000000 --- a/backend/routers/wissen.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares -Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron -(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert.""" - -import os -import re -import time -from pathlib import Path - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -router = APIRouter(prefix="/api") - -VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser() - - -def _available() -> bool: - return VAULT.is_dir() - - -def _title_of(p: Path) -> str: - """Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename.""" - try: - with p.open(encoding="utf-8", errors="replace") as fh: - for line in fh: - s = line.strip() - if s: - return s.lstrip("# ").strip()[:160] - except OSError: - pass - return p.stem - - -class VaultFile(BaseModel): - path: str - name: str - dir: str - title: str - mtime: float - neu: bool - - -class WissenListResponse(BaseModel): - available: bool - files: list[VaultFile] - - -class WissenFileResponse(BaseModel): - path: str - content: str - mtime: float - - -@router.get("/wissen", response_model=WissenListResponse) -def list_vault() -> dict: - """Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter).""" - if not _available(): - return {"available": False, "files": []} - files = [] - now = time.time() - for p in sorted(VAULT.rglob("*.md")): - if ".git" in p.parts or "traeume" in p.parts: - continue - rel = p.relative_to(VAULT).as_posix() - st = p.stat() - files.append({ - "path": rel, - "name": p.stem, - "dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "", - "title": _title_of(p), - "mtime": st.st_mtime, - "neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht" - }) - files.sort(key=lambda f: f["mtime"], reverse=True) - return {"available": True, "files": files} - - -@router.get("/wissen/datei", response_model=WissenFileResponse) -def read_file(pfad: str) -> dict: - """Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to).""" - if not _available(): - raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).") - try: - target = (VAULT / pfad).resolve() - if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file(): - raise HTTPException(404, "Notiz nicht gefunden.") - return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000], - "mtime": target.stat().st_mtime} - except HTTPException: - raise - except (ValueError, OSError): - raise HTTPException(404, "Notiz nicht lesbar.") - - -@router.get("/wissen/graph") -def graph_vault() -> dict: - """Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links).""" - if not _available(): - return {"nodes": [], "edges": []} - - nodes = [] - edges = [] - # Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID) - name_to_id = {} - - # 1. Alle Nodes sammeln - for p in VAULT.rglob("*.md"): - if ".git" in p.parts or "traeume" in p.parts: - continue - rel = p.relative_to(VAULT).as_posix() - name_to_id[p.stem.lower()] = rel - - # Kategorie aus dem Ordner ableiten - cat = "knowledge" - parent = p.parent.name if p.parent != VAULT else "" - if parent == "traeume": - cat = "events" - elif parent == "muster": - cat = "rules" - elif parent == "skill-kandidaten": - cat = "identity" - - nodes.append({ - "id": rel, - "content": p.stem, # stem ist oft kürzer/prägnanter als der Titel - "category": cat, - "source": "wiki" - }) - - # 2. Edges extrahieren (Wiki-Links [[Name]]) - link_rx = re.compile(r"\[\[([^\]]+)\]\]") - for n in nodes: - try: - target = VAULT / n["id"] - content = target.read_text(encoding="utf-8", errors="replace") - # Set, um mehrfache Links auf dieselbe Datei zu entduplizieren - found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)} - - for link in found_links: - target_id = name_to_id.get(link) - if target_id and target_id != n["id"]: - edges.append({ - "source": n["id"], - "target": target_id, - "weight": 1.0 - }) - except OSError: - pass - - return {"nodes": nodes, "edges": edges} diff --git a/backend/services/backup.py b/backend/services/backup.py index 14120f3..c2915cc 100644 --- a/backend/services/backup.py +++ b/backend/services/backup.py @@ -70,9 +70,11 @@ def list_backups() -> list[dict]: return [] out = [] for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True): + st = p.stat() out.append({ "snapshot": _ts(p), "file": p.name, - "size_mb": round(p.stat().st_size / 1_000_000, 2), + "size_mb": round(st.st_size / 1_000_000, 2), + "mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten) }) return out diff --git a/backend/services/connect.py b/backend/services/connect.py deleted file mode 100644 index 4d821af..0000000 --- a/backend/services/connect.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem -LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box -(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1). - -Gedächtnis ist hier bewusst KEINE eigene Leitung mehr: bis August 2026 gab es dafür einen -Memory-MCP-Server gegen MC2s /api/memory; seit dessen Ablösung (docs/wissen/VERDIKTE.md, -07.08.2026) führt Hermes sein Gedächtnis -selbst — es hängt am Agenten, nicht am Gateway. check_health() prüft es weiterhin (Leitung 2), -es ist nur nichts mehr zu konfigurieren. - -Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1 -die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit. -""" - -import json - -import httpx -from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM - -DEFAULT_HOST = "192.168.178.151" - -# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken), -# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1. -PRIMARY_MODEL = "coder" - - -def _caps(tools: bool = True, images: bool = False) -> dict: - """Volle 7-Feld-Capabilities — braucht sie für die Modellwahl.""" - return {"tools": tools, "images": images, "parallel_tool_calls": False, - "prompt_cache_key": False, "chat_completions": True, - "interleaved_reasoning": False, "max_tokens_parameter": False} - - -# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen. -IDE_MODELS = [ - {"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)}, - {"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)}, - {"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)}, -] - - -def _gw(host: str) -> str: - # Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort - # nur noch roh an den eigenständigen mc2-gateway (:9010) durch. - return f"http://{host}:{PORT}/v1" - - -def build_snippets(host: str = DEFAULT_HOST) -> dict: - gw = _gw(host) - mc_url = f"http://{host}:{PORT}" - - # Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt). - # Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus). - kilo = json.dumps({ - "apiProvider": "openai", - "openAiBaseUrl": gw, - "openAiApiKey": "local", - "openAiModelId": PRIMARY_MODEL, - }, indent=2) - - # Hermes Desktop = Remote-IDE, die auf dem Gateway läuft. - # Anleitung: Browser aufweisen → Token aus Datei laden → loslegen. - hermes_desktop = ( - f"# Hermes Desktop — Remote-IDE auf der Box\n"\ - f"#\n"\ - f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\ - f"# (MC2-Proxy auf dem Gateway)\n"\ - f"#\n"\ - f"# 2. Session-Token: auf der Box liegen in\n"\ - f"# ~/.hermes/desktop-gateway-token\n"\ - f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\ - f"#\n"\ - f"# 3. Token im Browser-Login einfügen — fertig.\n"\ - f"#\n"\ - f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)." - ) - - # Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und - # bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer - # (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle. - claude_code = ( - f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n" - f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n" - f"# • claude-code-router (leichtgewichtig, npm)\n" - f"# • oder LiteLLM mit /v1/messages-Bridge\n" - f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n" - f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n" - f"\n" - f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n' - f'export ANTHROPIC_AUTH_TOKEN="local"\n' - f'export ANTHROPIC_MODEL="coder"' - ) - - return { - "host": host, - "gateway_url": gw, - "mc_url": mc_url, - # Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop - # (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus), - # Claude Code (starke Sessions). - # Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal - # oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie. - "tools": { - "hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop, - "note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: " - "Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."}, - "kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo, - "note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. " - "API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · " - "Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."}, - "claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code, - "note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway."}, - }, - } - - -def check_health() -> dict: - """Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box: - Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM, - sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway), - Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI).""" - gateway = {"ok": False, "detail": "nicht erreichbar"} - try: - with httpx.Client(timeout=3.0) as c: - r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models") - if r.status_code == 200: - n = len(r.json().get("data", [])) - gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"} - else: - gateway = {"ok": False, "detail": f"HTTP {r.status_code}"} - except Exception: - pass - - memory = {"ok": False, "detail": "nicht erreichbar"} - try: - with httpx.Client(timeout=3.0) as c: - # Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit - r = c.get("http://127.0.0.1:8642/health") - if r.status_code in (200, 404, 401): - memory = {"ok": True, "detail": "Hermes-Nativ bereit"} - else: - memory = {"ok": False, "detail": f"HTTP {r.status_code}"} - except Exception: - pass - - desktop_gateway = {"ok": False, "detail": "nicht erreichbar"} - try: - with httpx.Client(timeout=3.0) as c: - r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status") - desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200 - else {"ok": False, "detail": f"HTTP {r.status_code}"}) - except Exception: - pass - - return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway} diff --git a/backend/services/ideen.py b/backend/services/ideen.py deleted file mode 100644 index 94b612a..0000000 --- a/backend/services/ideen.py +++ /dev/null @@ -1,1215 +0,0 @@ -""" -Ideen-Queue — DIE eine Sammelstelle für Ideen des Commanders (Entscheid 10.07.2026). - -Die Queue selbst ist das NATIVE Hermes-Kanban (SQLite-Board, Dispatcher im Gateway): -Idee rein (triage) → der Specifier arbeitet sie aus → ein Worker-Profil setzt sie um → -Code-Ergebnisse kommen als Vorschlags-Branch zurück und erscheinen als Karte im -Auftragsbuch. Dieser Service ist nur die MC2-Tür dazu: anlegen + anzeigen + aufräumen, -alles über die Hermes-CLI (stabile --json-Schnittstelle, kein Griff in die kanban.db). - -Türen insgesamt: Telegram/Desktop (natives kanban_create-Tool), Lucy-Voice -(mcp_voice.idee_notieren → POST /api/ideen) und die Zentrale (Auftragsbuch-Tab → hier). - -Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen. -""" - -import json -import logging -import os -import re -import sqlite3 -import subprocess -import time -from pathlib import Path - -import httpx -from config import GATEWAY_URL, V1_UPSTREAM - -log = logging.getLogger(__name__) - -HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser() -# Ketten-Sicht (20.07.): Eltern-Links + Heartbeat-Notizen stehen NICHT im `list --json` -# der CLI — dafür (und NUR dafür) ein read-only-Blick in die kanban.db. Das bricht die -# „alles über die CLI"-Regel bewusst minimal: zwei stabile Tabellen (task_links 2 Spalten, -# task_events), Verbindung strikt mode=ro, jeder Fehler fällt lautlos auf die flache -# Sicht zurück. Schreiben läuft weiterhin ausschließlich über die CLI. -KANBAN_DB = Path(os.environ.get("MC_KANBAN_DB", "~/.hermes/kanban.db")).expanduser() - -_TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$") - -# Die CLI kostet pro Aufruf ein paar Sekunden Python-Start — die UI pollt aber. -_list_cache: dict = {"ts": 0.0, "data": None} -_LIST_EVERY = 15.0 # s - -# Cache für worker log -_log_cache: dict = {"ts": 0.0, "task_id": None, "data": None} -_LOG_EVERY = 4.0 # s - -# Cache für Ergebnis (Abschluss-Zusammenfassung) — ändert sich nach done nie mehr -_erg_cache: dict = {} -_ERG_MAX = 40 # simple Deckelung gegen unbegrenztes Wachsen - - -def _available() -> bool: - return os.name == "posix" and HERMES.is_file() - - -def _hermes(args: list[str], timeout: int = 60) -> subprocess.CompletedProcess: - return subprocess.run([str(HERMES), "kanban", *args], - capture_output=True, text=True, timeout=timeout) - - -def _parse_json(stdout: str): - """CLI-Ausgabe kann Log-Zeilen vor dem JSON enthalten — ab der ersten Klammer parsen.""" - text = stdout or "" - for opener in ("[", "{"): - idx = text.find(opener) - if idx >= 0: - try: - return json.loads(text[idx:]) - except json.JSONDecodeError: - continue - return None - - -def _blocker_frage(task_id: str) -> dict | None: - """Für eine blockierte Aufgabe: die Frage/den Grund des Workers holen (needs_input etc.). - - Die Wahrheit steckt im jüngsten `blocked`-Event (payload.reason/kind); fällt das leer aus, - greifen wir auf den letzten „BLOCKED:"-Kommentar zurück. Ein Extra-`show`-Aufruf pro - hängender Karte ist billig — die gibt es fast nie und wenn, dann nur eine Handvoll. - """ - try: - r = _hermes(["show", task_id, "--json"], timeout=30) - d = _parse_json(r.stdout) - except Exception: - log.warning("ideen: kanban show %s fehlgeschlagen", task_id, exc_info=True) - return None - if not isinstance(d, dict): - return None - - frage, kind = "", "" - for ev in d.get("events") or []: # chronologisch → letztes blocked = aktive Frage - if isinstance(ev, dict) and ev.get("kind") == "blocked": - p = ev.get("payload") or {} - frage = (p.get("reason") or "").strip() - kind = (p.get("kind") or "").strip() - if not frage: # Fallback: letzter „BLOCKED:"-Kommentar - for c in d.get("comments") or []: - body = (c.get("body") or "").strip() - if body.startswith("BLOCKED:"): - frage = body[len("BLOCKED:"):].strip() - return {"frage": frage[:800], "kind": kind} - - -def _kurz(text: str, max_len: int = 80) -> str: - """Auf max_len kürzen — aber an der WORTGRENZE, nicht mitten im Wort (User 21.07.: - „Idee: Recherchiere zu ARM … und lass uns überlegen wie").""" - t = " ".join((text or "").split()) - if len(t) <= max_len: - return t - schnitt = t[:max_len].rsplit(" ", 1)[0].rstrip(" ,;:–-") - return (schnitt or t[:max_len]) + "…" - - -_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-–]?\s*", re.IGNORECASE) - - -def _projekt_titel(titles: list[str]) -> str: - """Anzeigename einer Karten-Familie: gemeinsamer Titel-Präfix (z. B. „Homelab-Dashboard 2.0"), - sonst der AUFGERÄUMTESTE Mitglieds-Titel. Reine Anzeige-Heuristik, nichts hängt davon ab. - - Der kürzeste Titel gewinnt, weil die gepflegten Karten (aus einem Konzept abgeleitet) - kurz sind und der rohe Zuruf des Commanders lang — vorher nahm die Gruppe stur die - älteste Karte und schnitt deren Textwüste bei 80 Zeichen mitten im Wort ab. - """ - if not titles: - return "Projekt" - prefix = titles[0] - for t in titles[1:]: - while prefix and not t.startswith(prefix): - prefix = prefix[:-1] - prefix = prefix.strip(" –-:·") - if len(prefix) >= 10: - return _kurz(prefix) - return _kurz(_TITEL_PRAEFIX_RX.sub("", min(titles, key=len))) - - -def _ketten_anreichern(items: list[dict]) -> list[dict]: - """Ketten-Sicht über die Queue legen (read-only, best-effort). - - Ergänzt pro Karte: `eltern` (offene Vorgänger-IDs), `wartet_auf` (deren Titel), - `notiz` (jüngste Heartbeat-Notiz des Workers bei running) und `projekt` - (Familien-Schlüssel). Liefert die Projekt-Gruppen für die UI: zusammenhängende - Karten (task_links) = ein Projekt; Einzelkarten bleiben ohne `projekt`. - Jeder Fehler (Windows-Dev, Schema-Drift, Lock) → unveränderte flache Sicht. - """ - for it in items: - it.setdefault("eltern", []) - it.setdefault("wartet_auf", []) - it.setdefault("notiz", None) - it.setdefault("projekt", None) - it.setdefault("prio", 0) - it.setdefault("puls_alter", None) - it.setdefault("gestartet", None) - if not items or not KANBAN_DB.is_file(): - return [] - by_id = {it["id"]: it for it in items if it.get("id")} - try: - con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3) - try: - con.execute("PRAGMA busy_timeout=2000") - links = [(p, c) for p, c in con.execute( - "SELECT parent_id, child_id FROM task_links") - if p in by_id or c in by_id] - # Prio (für die Tausch-Knöpfe) + Lebenszeichen (Hänger-Warnung): ein Rutsch. - ph = ",".join("?" * len(by_id)) - jetzt = time.time() - for tid, prio, hb, started, pid in con.execute( - f"SELECT id, priority, last_heartbeat_at, started_at, worker_pid" - f" FROM tasks WHERE id IN ({ph})", list(by_id)): - it = by_id[tid] - it["prio"] = prio or 0 - if it.get("status") == "running": - it["gestartet"] = started - puls = hb or started - it["puls_alter"] = int(jetzt - puls) if puls else None - for it in items: - if it.get("status") != "running": - continue - for (payload,) in con.execute( - "SELECT payload FROM task_events WHERE task_id=? AND kind='heartbeat' " - "AND payload IS NOT NULL ORDER BY created_at DESC LIMIT 5", (it["id"],)): - try: - note = (json.loads(payload) or {}).get("note") - except Exception: - note = None - if note: - it["notiz"] = str(note)[:300] - break - finally: - con.close() - except Exception: - log.debug("ideen: Ketten-Anreicherung fehlgeschlagen", exc_info=True) - return [] - - # Eltern/wartet_auf: nur Vorgänger zählen, die selbst noch offen in der Liste stehen — - # archivierte/fremde Parents gelten als erledigt. - ERLEDIGT = ("done",) - for parent, child in links: - it = by_id.get(child) - p = by_id.get(parent) - if it and p and p.get("status") not in ERLEDIGT: - it["eltern"].append(parent) - if len(it["wartet_auf"]) < 3: - it["wartet_auf"].append((p.get("titel") or parent)[:90]) - - # Familien = zusammenhängende Komponenten über die Links (Union-Find, klein genug). - chef: dict[str, str] = {} - - def boss(x: str) -> str: - while chef.get(x, x) != x: - chef[x] = chef.get(chef[x], chef[x]) - x = chef[x] - return x - - for parent, child in links: - if parent in by_id and child in by_id: - a, b = boss(parent), boss(child) - if a != b: - chef[a] = b - - familien: dict[str, list[dict]] = {} - for it in items: - familien.setdefault(boss(it["id"]), []).append(it) - projekte = [] - for mitglieder in familien.values(): - if len(mitglieder) < 2: - continue - mitglieder.sort(key=lambda i: i.get("erstellt") or 0) - key = mitglieder[0]["id"] - for it in mitglieder: - it["projekt"] = key - fertig = sum(1 for i in mitglieder if i["status"] in ERLEDIGT) - projekte.append({ - "key": key, - "titel": _projekt_titel([i["titel"] for i in mitglieder]), - "gesamt": len(mitglieder), - "fertig": fertig, - "laeuft": sum(1 for i in mitglieder if i["status"] == "running"), - "haengt": sum(1 for i in mitglieder if i["status"] == "blocked"), - }) - # Aktive Projekte zuerst (hängend > laufend > Rest), dann nach Größe. - projekte.sort(key=lambda p: (-p["haengt"], -p["laeuft"], -p["gesamt"])) - return projekte - - -def list_queue() -> dict: - """Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI.""" - if not _available(): - return {"available": False, "items": [], "offen": 0} - - now = time.time() - if _list_cache["data"] is not None and now - _list_cache["ts"] < _LIST_EVERY: - return _list_cache["data"] - - try: - r = _hermes(["list", "--json", "--sort", "created-desc"]) - raw = _parse_json(r.stdout) - except Exception: - log.warning("ideen: kanban list fehlgeschlagen", exc_info=True) - raw = None - if not isinstance(raw, list): - # CLI kaputt/Timeout → ehrlich leer melden, aber nicht cachen (nächster Poll versucht's neu) - return {"available": True, "items": [], "offen": 0, "fehler": "Queue nicht lesbar"} - - items = [] - for t in raw[:60]: - try: - body = t.get("body") or "" - ide = t.get("assignee") == "projektstart" - items.append({ - "id": t.get("id", ""), - "titel": (t.get("title") or "(ohne Titel)")[:200], - "body": body[:600], - "status": t.get("status", "?"), - "assignee": t.get("assignee"), - # IDE-Welt = das Vorbereiter-Profil (Konzept + Repo + Doku, kein Bau). - "welt": "ide" if ide else "box", - # Auftrags-Art innerhalb der IDE-Welt: nur durchdenken oder Projekt - # vorbereiten? Steht nirgends als Feld — der Marker im Body ist die - # Wahrheit (schreibt add_idea, siehe _ART_IDEE). - "art": ("idee" if _ART_MARKER in body else "projekt") if ide else "box", - "erstellt": t.get("created_at"), - "fertig": t.get("completed_at"), - "von": t.get("created_by"), - "frage": None, # bei blocked die Worker-Frage (unten nachgeladen) - "frage_kind": None, - }) - except Exception: - continue - - # Hängende Karten anreichern: die Frage des Workers holen, damit die Zentrale sie - # beantworten kann (Sackgasse-Fix). Deckel gegen Ausreißer, damit ein Poll nie hängt. - for it in [i for i in items if i["status"] == "blocked"][:6]: - info = _blocker_frage(it["id"]) - if info: - it["frage"] = info["frage"] - it["frage_kind"] = info["kind"] - - projekte = _ketten_anreichern(items) - offen = sum(1 for i in items if i["status"] not in ("done",)) - data = {"available": True, "items": items, "offen": offen, "projekte": projekte} - _list_cache.update(ts=now, data=data) - return data - - -# Aufwand-Text je Tiefe, den der Commander per Knopf waehlt — wird dem Auftrag -# vorangestellt und ueberstimmt die Groessen-Selbsteinschaetzung von konzept-fliessband. -_AUFWAND = { - "simpel": ("AUFWAND (vom Commander gewaehlt): SIMPEL — nutze die KLEINE konzept-fliessband-" - "Variante: 2 Fach-Rollen, 1 Denk-Runde + 1 Haertetest, frueh raus bei WASSERDICHT. " - "Das ueberstimmt deine eigene Groessen-Einschaetzung."), - "gruendlich": ("AUFWAND (vom Commander gewaehlt): GRUENDLICH — nutze die VOLLE konzept-fliessband-" - "Kaskade: 3-5 Fach-Rollen, bis 3 Denk-Runden, mehrfacher Advocatus-Diaboli-Haertetest. " - "Das ueberstimmt deine eigene Groessen-Einschaetzung, auch wenn das Projekt klein wirkt."), -} - - -# Infrastruktur des Commanders — ohne das plant jedes Modell nach Industrie-Default -# (Docker/Cloud) statt für die reale Umgebung. -# Umfeld-Kontext je Ziel: Das Heimlabor-Wissen (Box vs. pve, LXCs, PBS) ist NUR fuer -# LXC-Projekte relevant. Ein Docker-Projekt ist bewusst portabel/extern gedacht — dort -# wuerde der Proxmox-Kram das Konzept nur in die falsche Richtung schubsen. -_INFRA_DOCKER = ( - "UMFELD: Dieses Projekt ist bewusst PORTABEL und fuer den Einsatz AUSSERHALB des Heimlabors " - "gedacht. Plane es umgebungsunabhaengig nach Standard-Praxis — NICHT auf die Proxmox-/LXC-Welt " - "des Commanders zugeschnitten, keine Annahmen ueber seine Hosts. Der Code liegt in seiner " - "self-hosted Gitea (CI-Pipelines dort moeglich).") - -_INFRA_LXC = ( - "UMFELD des Commanders — WICHTIG, WO WAS LAEUFT (nicht raten, das sind zwei Maschinen!):\n" - "· AI-Box 192.168.178.151: hier laeufst DU (Agent/Lucy/MC2/Modelle). Das ist nur die WERKBANK " - "— NICHT das Ziel-System. Projekt-Dienste werden hier NIEMALS installiert.\n" - "· Proxmox-Host 'pve' 192.168.178.108: DAS Ziel-System fuers Deployment, ein SEPARATER Rechner. " - "Dort laufen die LXC-Container; ein Proxmox Backup Server sichert sie automatisch. Von der Box " - "aus per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Was dort schon laeuft (Reverse-" - "Proxy, DNS, Gitea usw.), schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht annehmen.\n" - "· Gitea ist self-hosted im Heimnetz (CI-Pipelines moeglich).\n" - "· Alles laeuft lokal — KEINE Cloud, kein fremder Hoster, keine Bezahl-Dienste.") - -_ZIEL_HINWEIS = ("Richte den Tech-Vorschlag an der gewaehlten ZIEL-PLATTFORM aus, " - "nicht am Industrie-Default.") - -# Auftrags-Art „Idee pruefen": Produkt-Blick statt Umsetzungs-Blick, KEIN Projektstart. -# Langer Zuruf → kurzer Kartentitel; der volle Wortlaut steht dann hier im Auftrag. -# Der Worker MUSS wissen, dass der Titel nur eine Kurzfassung ist, sonst arbeitet er -# am abgeschnittenen Satz statt an der ganzen Idee. -_WORTLAUT_KOPF = ("DIE IDEE IM WORTLAUT (der Kartentitel ist nur eine Kurzfassung fuer die " - "Anzeige — DAS hier ist der Auftrag):\n") - -# Die erste Zeile ist zugleich der MARKER, an dem die Queue-Sicht die Art wiedererkennt -# (das Kanban kennt kein eigenes Feld dafuer) — beim Aendern _ART_MARKER mitziehen. -_ART_MARKER = "AUFTRAGS-ART: IDEE PRUEFEN" -_ART_IDEE = ( - "AUFTRAGS-ART: IDEE PRUEFEN (KEIN Projektstart!). Der Commander will diese Produkt-Idee erst " - "einmal DURCHDACHT haben — er hat sich NICHT entschieden, sie zu bauen.\n" - "· Blickwinkel zuerst PRODUKT, nicht Technik: Was ist es genau? Welches Problem, fuer wen? Was " - "gibt es dafuer schon und warum reicht das nicht? Was waere der kleinste sinnvolle Wurf? Was " - "spricht DAGEGEN (Aufwand, Betrieb, Wartung, Risiken)? Technik nur so tief, wie es fuer die " - "Machbarkeit noetig ist.\n" - "· Rollen-Cast entsprechend waehlen — NICHT nur Entwickler-Rollen, sondern auch Zielgruppen-, " - "Abgrenzungs-/Markt- und Betriebs-Sicht. Volle Denk-Kaskade mit Advocatus Diaboli.\n" - "· Ergebnis: NUR ein KONZEPT.md im neuen Repo (plus knappes README). KEINE ROADMAP.md, KEINE " - "AGENTS.md, KEIN SAVEPOINT.md, KEIN Geruest — das entsteht erst, wenn der Commander sich fuers " - "Bauen entscheidet.\n" - "· Uebergabe NICHT als Bau-Aufforderung formulieren: sag, was die Idee waere, was sie braeuchte " - "und was dagegen spricht — die Entscheidung liegt beim Commander.") - -# Ziel-Plattform je Projekt (vom Commander gewaehlt) — ueberstimmt die Annahmen des Modells. -_ZIEL = { - "lxc": ("ZIEL-PLATTFORM (vom Commander gewaehlt): LXC — plane die Umsetzung als Dienst in einem " - "eigenen Proxmox-LXC-Container: systemd-Unit, Dateien im Container, KEIN Docker und KEIN " - "docker compose. Der LXC wird vom Proxmox Backup Server automatisch mitgesichert."), - "docker": ("ZIEL-PLATTFORM (vom Commander gewaehlt): DOCKER — plane NATIVES Docker nach " - "Standard-Praxis: Dockerfile, docker compose, sauber gebaute Images, portabel und " - "CI-tauglich (Build/Deploy ueber Gitea-/GitLab-Pipelines). Nimm eine normale " - "Docker-Umgebung an; KEINE Verrenkungen wie 'Docker im LXC mit Nesting' und keine " - "Proxmox-Sonderwege — dieses Projekt darf bewusst ausserhalb der LXC-Welt leben."), -} -_ZIEL_EHRLICH = ("Traegt die gewaehlte Ziel-Plattform fuer dieses Projekt NICHT (z. B. eine noetige " - "Komponente gibt es nur als Docker-Image), dann sag das im Konzept AUSDRUECKLICH mit " - "Begruendung — ignoriere die Wahl nicht still und scheitere nicht daran.") - - -def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui", - welt: str = "box", tiefe: str = "", ziel: str = "", - art: str = "projekt") -> dict: - """Idee in die Queue legen. - - welt='box' (Default): triage — der Specifier arbeitet sie aus, die Werkstatt baut, - Ergebnis ist ein Patch-Vorschlag im Auftragsbuch (Box-Welt). - welt='ide': geht DIREKT an das Profil `projektstart` (am Triage/Auto-Zerleger vorbei) — - die Box härtet ein Konzept (konzept-fliessband), legt ein leeres Gitea-Repo an und - füllt es NUR mit Doku. Ergebnis ist ein vorbereitetes Projekt, das der Commander in - Agentic IDE selbst baut (IDE-Welt). Bewusst KEIN --triage: so fasst der Auto-Zerleger die - Karte nie an und die Werkstatt baut sie nie (der Fan-out-Fehler vom 20.07.). - tiefe (nur ide): 'simpel' | 'gruendlich' — vom Knopf gewaehlte Konzept-Tiefe; leer = - die Box schaetzt selbst (Fallback fuer Telegram/Lucy). - ziel (nur ide-Projekt): 'lxc' | 'docker' — Ziel-Plattform; leer = die Box entscheidet. - art (nur ide): 'projekt' (Default, Repo+Doku+Roadmap, Bau in IDE) oder 'idee' - (Produkt-Idee nur durchdenken: NUR Konzept, keine Roadmap/Geruest, kein Bau-Druck).""" - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - titel = (titel or "").strip() - if not (3 <= len(titel) <= 200): - return {"ok": False, "error": "Titel bitte zwischen 3 und 200 Zeichen."} - welt = "ide" if str(welt).lower() == "ide" else "box" - notiz = (notiz or "").strip() - # Der Commander schreibt roh — sein Zuruf ist oft ein ganzer Absatz und wurde bisher - # komplett zum Kartentitel (in jeder Liste abgeschnitten, unlesbar). Also: kurzer - # Titel für die Anzeige, der WORTLAUT wandert vollständig in den Auftrag, damit der - # Worker nichts verliert. - wortlaut = "" - if len(titel) > 90: - satz = re.split(r"(?<=[.!?])\s", titel)[0].strip() - wortlaut = titel - titel = satz if 10 <= len(satz) <= 90 else _kurz(titel, 80) - if welt == "ide": - args = ["create", titel, "--assignee", "projektstart", "--created-by", created_by, "--json"] - # Vorspann: Aufwand (Tiefe) + Ziel-Plattform + Infrastruktur — überstimmt die - # Annahmen des Modells und macht das Konzept auf die reale Umgebung passend. - if str(art).lower() == "idee": - # Idee pruefen: Produkt-Blick, volle Kaskade, KEIN Ziel/Geruest/Bau-Druck. - # Sein Heimlabor bleibt Kontext (dort liefe es ja), aber ohne Plattform-Festlegung. - vorspann = [_ART_IDEE, _AUFWAND["gruendlich"], _INFRA_LXC] - else: - zielwahl = _ZIEL.get(str(ziel).lower()) - vorspann = [p for p in (_AUFWAND.get(str(tiefe).lower()), zielwahl) if p] - # Heimlabor-Kontext nur beim LXC-Pfad (und ohne Wahl = seine Standard-Welt); - # Docker-Projekte bekommen bewusst den portablen Kontext. - infra = _INFRA_DOCKER if str(ziel).lower() == "docker" else _INFRA_LXC - vorspann.append(infra + " " + _ZIEL_HINWEIS - + (" " + _ZIEL_EHRLICH if zielwahl else "")) - if wortlaut: - vorspann.insert(0, _WORTLAUT_KOPF + wortlaut) - notiz = "\n\n".join(vorspann) + ("\n\n" + notiz if notiz else "") - else: - args = ["create", titel, "--triage", "--created-by", created_by, "--json"] - if wortlaut: - notiz = _WORTLAUT_KOPF + wortlaut + ("\n\n" + notiz if notiz else "") - if notiz: - args[2:2] = ["--body", notiz[:4000]] - try: - r = _hermes(args) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - task = _parse_json(r.stdout) - if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"): - return {"ok": False, "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]} - _list_cache["ts"] = 0.0 # nächster Poll zeigt die neue Idee sofort - try: - from services import announce - if welt == "ide" and str(art).lower() == "idee": - announce.add(f"Idee wird durchdacht: „{titel}“ ({task['id']}) — die Box arbeitet ein " - "Konzept aus (Produkt-Blick). Danach entscheidest du, ob was draus wird.", - "[Ideen-Queue]", "ideen-queue", "silent") - elif welt == "ide": - announce.add(f"Neues IDE-Projekt in Vorbereitung: „{titel}“ ({task['id']}) — die Box " - "härtet das Konzept und legt das Repo an, dann baust du in der Agentic IDE.", - "[Ideen-Queue]", "ideen-queue", "silent") - else: - announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.", - "[Ideen-Queue]", "ideen-queue", "silent") - except Exception: - pass - return {"ok": True, "id": task["id"], "status": task.get("status"), "welt": welt} - - -def answer_task(task_id: str, antwort: str) -> dict: - """Eine hängende Aufgabe beantworten: Antwort als Kommentar protokollieren + entsperren. - - `unblock --reason` schreibt die Antwort als „UNBLOCK:"-Kommentar und stellt die Karte auf - ready — der Dispatcher spawnt den Worker neu, der die Antwort im Task-Kontext vorfindet und - weitermacht. Genau der Ausweg aus der Sackgasse „Aufgabe fragt, aber niemand kann antworten". - """ - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - antwort = (antwort or "").strip() - if not (1 <= len(antwort) <= 2000): - return {"ok": False, "error": "Bitte eine Antwort zwischen 1 und 2000 Zeichen."} - try: - r = _hermes(["unblock", task_id, "--reason", antwort]) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - if r.returncode != 0: - return {"ok": False, "error": (r.stderr or r.stdout or "Entsperren fehlgeschlagen").strip()[:300]} - _list_cache["ts"] = 0.0 # nächster Poll zeigt den neuen Status (ready/running) sofort - try: - from services import announce - announce.add(f"Deine Antwort ging an die hängende Aufgabe {task_id} — die Box macht weiter.", - "[Ideen-Queue]", "ideen-queue", "silent") - except Exception: - pass - return {"ok": True} - - -def archive_task(task_id: str) -> dict: - """Erledigtes/Verworfenes aus der Sicht räumen (Kanban-Archiv, nichts wird gelöscht).""" - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - try: - r = _hermes(["archive", task_id]) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - if r.returncode != 0: - return {"ok": False, "error": (r.stderr or r.stdout or "Archivieren fehlgeschlagen").strip()[:300]} - _list_cache["ts"] = 0.0 - return {"ok": True} - - -_EINORDNEN_SYS = ( - "Du ordnest eine frisch getippte Projekt-Idee ein. Antworte AUSSCHLIESSLICH mit einem " - "JSON-Objekt, ohne Prosa, ohne Codezaun:\n" - '{"wer":"box"|"ide","tiefe":"schlank"|"gruendlich","nur_denken":true|false}\n\n' - "wer=box: Aufgabe am eigenen Heimlabor des Nutzers — seine Server, seine bestehenden " - "Dienste (Mission Control, Lucy, Rippy), Wartung, Doku, Automatisierung. Die Box baut selbst.\n" - "wer=ide: ein NEUES eigenstaendiges Programm/Werkzeug/App, das der Nutzer danach selbst " - "programmieren will. Er bekommt nur ein vorbereitetes Repo.\n" - "tiefe=gruendlich: gross, vage oder mit vielen offenen Entwurfsfragen. " - "tiefe=schlank: klein und klar umrissen.\n" - "nur_denken=true: der Text stellt eine FRAGE oder waegt ab (\"lohnt sich\", \"waere es sinnvoll\", " - "\"brauche ich\") statt einen Auftrag zu geben." -) - - -def einordnen(text: str) -> dict: - """Grober Einordnungs-VORSCHLAG für eine frisch getippte Idee. - - Nur eine Vorauswahl in der Oberfläche — der Mensch sieht und überschreibt sie. Deshalb - bewusst anspruchslos: ein einziger kurzer Aufruf ans dauerwarme Hirn (69,6 t/s gemessen), - Schnellspur-Marker gegen das Nachdenken, hartes Timeout. Scheitert irgendetwas, kommt - `{"ok": False}` zurück und die Oberfläche behält schlicht ihre Voreinstellung — eine - Einordnung darf das Tippen NIE blockieren oder eine Fehlermeldung erzeugen. - """ - text = (text or "").strip() - if len(text) < 12: - return {"ok": False} - ziel_url = (V1_UPSTREAM or GATEWAY_URL).rstrip("/") - marker = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]") - try: - r = httpx.post(f"{ziel_url}/v1/chat/completions", timeout=12.0, json={ - "model": "hermes", "temperature": 0, "max_tokens": 60, "stream": False, - "messages": [ - {"role": "system", "content": _EINORDNEN_SYS}, - {"role": "user", "content": f"{marker} {text[:600]}"}, - ], - }) - r.raise_for_status() - inhalt = (r.json()["choices"][0]["message"]["content"] or "").strip() - except Exception: - log.debug("ideen: einordnen fehlgeschlagen", exc_info=True) - return {"ok": False} - - # Modelle packen JSON gern in einen Codezaun oder schreiben einen Satz davor. - m = re.search(r"\{.*\}", inhalt, re.DOTALL) - if not m: - return {"ok": False} - try: - d = json.loads(m.group(0)) - except Exception: - return {"ok": False} - - wer = d.get("wer") if d.get("wer") in ("box", "ide") else None - tiefe = d.get("tiefe") if d.get("tiefe") in ("schlank", "gruendlich") else None - if wer is None and tiefe is None: - return {"ok": False} - return {"ok": True, "wer": wer or "ide", "tiefe": tiefe or "schlank", - "nur_denken": bool(d.get("nur_denken"))} - - -def archive_done(projekt: str = "") -> dict: - """Alle FERTIGEN Karten wegräumen — optional nur die eines Projekts. - - Sammel-Aufräumen (25.07.2026): Bei fünf abgeschlossenen Projekt-Ketten wären das - sonst ~18 Einzelklicks. Wie beim Einzel-Archivieren wird nichts gelöscht — die - Karten wandern ins Kanban-Archiv und bleiben dort lesbar. - - ‼️ Bewusst NUR `done`. Laufende, hängende, wartende und in Ausarbeitung befindliche - Karten werden nie angefasst, auch nicht bei leerem `projekt`-Filter — ein Sammel- - Knopf darf niemals versehentlich Arbeit abräumen. - """ - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - q = list_queue() - if not q.get("available"): - return {"ok": False, "error": q.get("fehler") or "Queue nicht lesbar."} - - ziel = [i for i in q.get("items", []) - if i.get("status") == "done" and (not projekt or i.get("projekt") == projekt)] - if not ziel: - return {"ok": True, "archiviert": 0, "fehlgeschlagen": []} - - ok, fehler = 0, [] - for item in ziel: - r = archive_task(item.get("id", "")) - if r.get("ok"): - ok += 1 - else: - # Die Items aus list_queue tragen "titel" (deutsch), nicht das rohe "title". - fehler.append({"id": item.get("id"), "titel": _kurz(item.get("titel") or "", 60), - "error": r.get("error", "")}) - _list_cache["ts"] = 0.0 - return {"ok": True, "archiviert": ok, "fehlgeschlagen": fehler} - - -def ergebnis_of(task_id: str) -> dict: - """Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers. - - User-Wunsch 15.07.: „im Auftragsbuch werden Dinge getestet, ich sehe aber das - Ergebnis nicht." Die Wahrheit liegt beim kanban_complete-Aufruf (summary/result) - im jüngsten `completed`-Event; `list --json` trägt sie nicht → lazy per `show`, - nur wenn der User die Karte aufklappt. Fertige Ergebnisse ändern sich nie → Cache. - """ - if not _available(): - return {"available": False, "summary": "", "result": ""} - if not _TASK_ID_RX.match(task_id or ""): - return {"available": True, "summary": "", "result": ""} - if task_id in _erg_cache: - return _erg_cache[task_id] - - try: - r = _hermes(["show", task_id, "--json"], timeout=30) - d = _parse_json(r.stdout) - except Exception: - log.warning("ideen: kanban show %s (ergebnis) fehlgeschlagen", task_id, exc_info=True) - return {"available": True, "summary": "", "result": ""} - summary, result = "", "" - if isinstance(d, dict): - if isinstance(d.get("result"), str): - result = d["result"].strip() - for ev in d.get("events") or []: # chronologisch → letztes completed gewinnt - if isinstance(ev, dict) and ev.get("kind") == "completed": - s = ((ev.get("payload") or {}).get("summary") or "").strip() - if s: - summary = s - data = {"available": True, "summary": summary[:4000], "result": result[:4000]} - if len(_erg_cache) >= _ERG_MAX: - _erg_cache.pop(next(iter(_erg_cache))) - _erg_cache[task_id] = data - return data - - -# ── Karten-Steuerung (Stopp / Neuer Versuch / Prio) — User-Wunsch 20.07. ───── -# Die CLI kann blocken/entsperren, aber weder haengende Worker beenden noch die -# Prioritaet aendern → fuer genau diese zwei Faelle eine eng begrenzte Direkt- -# Schreibstelle in die kanban.db (BEGIN IMMEDIATE, nur tasks-Spalten). Alles -# andere laeuft weiter ueber die CLI. - - -def _task_row(task_id: str) -> dict | None: - try: - con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3) - try: - con.row_factory = sqlite3.Row - r = con.execute("SELECT id, status, worker_pid, priority FROM tasks WHERE id=?", - (task_id,)).fetchone() - return dict(r) if r else None - finally: - con.close() - except Exception: - return None - - -def _kill_tree(pid: int) -> None: - """Worker samt Kind-Prozessen beenden (TERM, dann KILL). Ohne das bleiben - Test-Server (uvicorn & Co.) als Waisen zurueck — live gesehen 20.07.""" - import signal - - def kinder(p: int) -> list[int]: - try: - out = subprocess.run(["pgrep", "-P", str(p)], capture_output=True, text=True, timeout=5) - return [int(x) for x in out.stdout.split()] - except Exception: - return [] - - def baum(p: int) -> list[int]: - alle = [p] - for k in kinder(p): - alle.extend(baum(k)) - return alle - - pids = baum(pid) - for p in reversed(pids): - try: - os.kill(p, signal.SIGTERM) - except OSError: - pass - time.sleep(2) - for p in reversed(pids): - try: - os.kill(p, signal.SIGKILL) - except OSError: - pass - - -def _requeue(task_id: str) -> None: - """Karte sauber zurueck auf todo (Claim/PID/Zaehler leeren) — der Dispatcher - spawnt sie beim naechsten Tick frisch (Caps gelten).""" - con = sqlite3.connect(str(KANBAN_DB), timeout=15) - try: - con.execute("PRAGMA busy_timeout=10000") - con.execute("BEGIN IMMEDIATE") - con.execute( - "UPDATE tasks SET status='todo', claim_lock=NULL, claim_expires=NULL," - " worker_pid=NULL, current_run_id=NULL, last_heartbeat_at=NULL," - " started_at=NULL, consecutive_failures=0 WHERE id=?", (task_id,)) - con.commit() - finally: - con.close() - - -def stop_task(task_id: str) -> dict: - """Karte gezielt anhalten: generischer Block (bleibt liegen, kein Auto-Unblock- - Kind) + laufenden Worker-Prozessbaum beenden.""" - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - row = _task_row(task_id) - if not row: - return {"ok": False, "error": "Karte nicht gefunden."} - try: - r = _hermes(["block", task_id, "Vom", "Commander", "gestoppt"]) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - if r.returncode != 0: - return {"ok": False, "error": (r.stderr or r.stdout or "Blocken fehlgeschlagen").strip()[:300]} - if row.get("status") == "running" and row.get("worker_pid"): - _kill_tree(int(row["worker_pid"])) - _list_cache["ts"] = 0.0 - return {"ok": True} - - -def retry_task(task_id: str) -> dict: - """Neuer Versuch: haengende/laufende Karte → Worker-Baum beenden + frisch auf todo; - blockierte Karte → entsperren. Der Dispatcher uebernimmt den Rest (Caps gelten).""" - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - row = _task_row(task_id) - if not row: - return {"ok": False, "error": "Karte nicht gefunden."} - status = row.get("status") - try: - if status == "blocked": - r = _hermes(["unblock", task_id, "--reason", "Neuer Versuch vom Commander"]) - if r.returncode != 0: - return {"ok": False, "error": (r.stderr or r.stdout or "Entsperren fehlgeschlagen").strip()[:300]} - else: - if status == "running" and row.get("worker_pid"): - _kill_tree(int(row["worker_pid"])) - _requeue(task_id) - try: - _hermes(["comment", task_id, "Neuer", "Versuch", "vom", "Commander", "(Karte zurueckgesetzt)"], timeout=20) - except Exception: - pass - except Exception as exc: - return {"ok": False, "error": f"Neuer Versuch fehlgeschlagen: {exc}"} - _list_cache["ts"] = 0.0 - try: - from services import announce - announce.add(f"Karte {task_id} wurde auf Commander-Wunsch neu angestoßen.", - "[Ideen-Queue]", "ideen-queue", "silent") - except Exception: - pass - return {"ok": True} - - -def prio_task(task_id: str, richtung: str) -> dict: - """Prio hoch/runter (Dispatcher zieht ready-Karten nach `priority DESC, created ASC` - → hoch = frueher dran). Direkt-Schreibstelle, die CLI kennt keine Prio-Aenderung.""" - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - if richtung not in ("hoch", "runter"): - return {"ok": False, "error": "richtung muss 'hoch' oder 'runter' sein."} - delta = 1 if richtung == "hoch" else -1 - try: - con = sqlite3.connect(str(KANBAN_DB), timeout=15) - try: - con.execute("PRAGMA busy_timeout=10000") - con.execute("BEGIN IMMEDIATE") - cur = con.execute("UPDATE tasks SET priority = COALESCE(priority,0) + ? WHERE id=?", - (delta, task_id)) - con.commit() - if cur.rowcount == 0: - return {"ok": False, "error": "Karte nicht gefunden."} - finally: - con.close() - except Exception as exc: - return {"ok": False, "error": f"Prio-Änderung fehlgeschlagen: {exc}"} - _list_cache["ts"] = 0.0 - return {"ok": True} - - -# ── Konzept-Ansicht (IDE-Welt) — KONZEPT.md aus dem vorbereiteten Repo holen ── -# Der projektstart-Worker legt ein Gitea-Repo an und schreibt das wasserdichte -# Konzept als KONZEPT.md hinein. Hier holen wir es (Gitea-API + Auth aus -# ~/.git-credentials), damit der Commander es DIREKT im Auftragsbuch liest, -# ohne das Repo aufzumachen (User-Wunsch 20.07.). -_GITEA_HOST = "git.tobisniceshomelab.ddnsfree.com" -# Kein Raten am Zeilenende: greedy bis zum ersten Nicht-Repo-Zeichen matchen und den -# Treffer HINTERHER säubern. (Der Worker schreibt die URL gern in Markdown-Backticks — -# eine Lookahead-Liste erwischt sowas nie zuverlässig, Bug-Fund 20.07.) -_GITEA_REPO_RX = re.compile( - re.escape(_GITEA_HOST) + r"/([A-Za-z0-9_.-]+/[A-Za-z0-9_][A-Za-z0-9_.-]*)" -) -_REPO_FALLBACK_RX = re.compile(r"[Rr]epo\S*[:\s]+[`'\"]?([A-Za-z0-9_.-]+/[A-Za-z0-9_][A-Za-z0-9_.-]*)") - - -def _clean_repo(raw: str) -> str | None: - """Trailing Markdown-/Satzzeichen und .git vom Treffer abschneiden.""" - r = (raw or "").strip().strip("`'\"") - r = r.rstrip(".,;:)»>") - r = re.sub(r"\.git$", "", r) - r = r.rstrip(".,;:") - return r or None -_KONZEPT_NAMES = ("KONZEPT.md", "Konzept.md", "konzept.md", "CONCEPT.md") -_konzept_cache: dict = {} - -# Zweiter Ablage-Ort (Bug-Fund 21.07.): der `konzept-fliessband`-Skill endet mit -# „cp … ~/konzepte/-konzept.md" — Worker folgen dieser Skill-Anweisung auch dann, -# wenn ihre Profil-SOUL eigentlich ein Repo verlangt. Das Konzept war dann fertig, aber -# im Auftragsbuch unsichtbar („noch kein Repo hinterlegt"). Also: liegt kein Repo vor, -# hier nachsehen. Bewusst eng — nur ~/konzepte, nur .md. -_KONZEPT_DIR = Path("~/konzepte").expanduser() -_KONZEPT_PFAD_RX = re.compile(r"(?:~|/home/[A-Za-z0-9_.-]+)?/?konzepte/([A-Za-z0-9_.-]+\.md)") - - -def _lokales_konzept(text: str, titel: str = "") -> tuple | None: - """(pfad, inhalt) einer Konzept-Datei aus ~/konzepte — aus dem Text oder per Titel geraten. - - `titel` nur setzen, wenn die Karte FERTIG ist: sonst hängt die Raterei einer gerade - laufenden Karte das Konzept ihres Vorgängers an (live gesehen 21.07. — eine neue - ARM-Karte zeigte das alte ARM-Konzept, als wäre es ihr Ergebnis). - """ - kandidaten: list[str] = [] - m = _KONZEPT_PFAD_RX.search(text or "") - if m: - kandidaten.append(m.group(1)) - # Kein Pfad genannt (aeltere Karten): ueber den Titel raten — Wortschnittmenge zwischen - # Dateiname und Kartentitel. Streng: EIN Treffer, und ein einzelnes kurzes Allerweltswort - # („arm") reicht nicht — sonst passt irgendeine Datei auf irgendeine Karte. - if not kandidaten and titel and _KONZEPT_DIR.is_dir(): - worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())} - treffer = [p.name for p in _KONZEPT_DIR.glob("*.md") - if len(g := worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower()))) >= 2 - or any(len(w) >= 6 for w in g)] - if len(treffer) == 1: - kandidaten.append(treffer[0]) - for name in kandidaten: - pfad = (_KONZEPT_DIR / name).resolve() - if pfad.parent != _KONZEPT_DIR.resolve() or not pfad.is_file(): - continue - try: - return str(pfad), pfad.read_text(encoding="utf-8", errors="replace") - except Exception: - log.warning("ideen: Konzept-Datei %s nicht lesbar", pfad, exc_info=True) - return None - - -def _gitea_creds() -> tuple | None: - """(user, token) fuer den oeffentlichen Gitea-Host aus ~/.git-credentials.""" - try: - cred = Path("~/.git-credentials").expanduser() - for line in cred.read_text(encoding="utf-8").splitlines(): - m = re.match(r"https://([^:]+):([^@]+)@" + re.escape(_GITEA_HOST), line.strip()) - if m: - return m.group(1), m.group(2) - except Exception: - pass - return None - - -def _extract_repo(text: str) -> str | None: - """Ziel-Repo (owner/name) aus dem Ergebnis-/Body-Text der Karte ziehen.""" - if not text: - return None - m = _GITEA_REPO_RX.search(text) - if m: - return _clean_repo(m.group(1)) - m = _REPO_FALLBACK_RX.search(text) - return _clean_repo(m.group(1)) if m else None - - -def konzept_of(task_id: str) -> dict: - """Das ausgearbeitete Konzept einer IDE-Karte holen — aus dem Gitea-Repo ODER, - falls der Worker keins angelegt hat, aus seiner Datei in ~/konzepte.""" - if not _available(): - return {"available": False} - if not _TASK_ID_RX.match(task_id or ""): - return {"available": True, "error": "Ungültige Aufgaben-Nummer."} - if task_id in _konzept_cache: - return _konzept_cache[task_id] - - # Repo aus Ergebnis + Body der Karte ziehen (show --json trägt beides). - try: - r = _hermes(["show", task_id, "--json"], timeout=30) - d = _parse_json(r.stdout) - except Exception: - return {"available": True, "error": "Karte nicht lesbar."} - # `kanban show --json` legt die Task-Felder verschachtelt unter "task" ab und - # liefert die jüngste Abschluss-Zusammenfassung als "latest_summary". - haystack, task_status, titel = "", None, "" - if isinstance(d, dict): - t = d.get("task") if isinstance(d.get("task"), dict) else d - task_status = t.get("status") - titel = str(t.get("title") or "") - haystack = "\n".join(str(x or "") for x in ( - t.get("result"), t.get("body"), d.get("latest_summary"))) - for ev in d.get("events") or []: - if isinstance(ev, dict) and ev.get("kind") == "completed": - haystack += "\n" + str((ev.get("payload") or {}).get("summary") or "") - - def _datei_antwort(repo: str | None = None) -> dict | None: - """Konzept aus ~/konzepte als vollwertige Antwort — der zweite Ablage-Ort.""" - # Titel-Raterei nur bei fertigen Karten (sonst erbt eine laufende Karte das - # Konzept ihres Vorgängers); läuft sie noch, zählt allein ein genannter Pfad. - lokal = _lokales_konzept(haystack, titel if task_status == "done" else "") - if not lokal: - return None - pfad, inhalt = lokal - return {"available": True, "repo": repo, "quelle": "datei", "pfad": pfad, - "datei": Path(pfad).name, "konzept": inhalt[:60000]} - - repo = _extract_repo(haystack) - if not repo: - data = _datei_antwort() - if data: - if task_status == "done": - if len(_konzept_cache) >= 20: - _konzept_cache.pop(next(iter(_konzept_cache))) - _konzept_cache[task_id] = data - return data - return {"available": True, "repo": None, - "error": "Noch kein Konzept hinterlegt — die Box arbeitet noch daran."} - - creds = _gitea_creds() - auth = creds if creds else None - konzept, used_name = "", None - try: - import httpx - with httpx.Client(timeout=10.0, follow_redirects=True) as c: - for name in _KONZEPT_NAMES: - url = f"https://{_GITEA_HOST}/{repo}/raw/branch/main/{name}" - resp = c.get(url, auth=auth) - if resp.status_code == 200 and resp.text.strip(): - konzept, used_name = resp.text, name - break - except Exception: - log.warning("ideen: KONZEPT-Abruf für %s (%s) fehlgeschlagen", task_id, repo, exc_info=True) - return (_datei_antwort(repo) - or {"available": True, "repo": repo, "error": "Konzept konnte nicht geladen werden."}) - - if not konzept: - # Repo genannt, aber (noch) ohne KONZEPT.md → die lokale Ablage kann trotzdem stehen. - return (_datei_antwort(repo) - or {"available": True, "repo": repo, - "error": "KONZEPT.md im Repo (noch) nicht gefunden."}) - clone_url = f"https://{_GITEA_HOST}/{repo}.git" - data = {"available": True, "repo": repo, "clone_url": clone_url, "quelle": "repo", - "datei": used_name, "konzept": konzept[:60000]} - # Fertige Konzepte ändern sich selten → cachen (aber nur, wenn die Karte done ist). - if task_status == "done": - if len(_konzept_cache) >= 20: - _konzept_cache.pop(next(iter(_konzept_cache))) - _konzept_cache[task_id] = data - return data - - -# ── „Gefaellt mir" — aus der geprueften Idee ein IDE-Projekt machen (21.07.) ── -# Ohne das war die Idee eine Sackgasse: der Commander haette den Titel neu eintippen -# muessen und die Box haette das Konzept von vorn gedacht. Hier bekommt die neue Karte -# das FERTIGE Konzept als Vorlage mit — plus die Kette zur Idee-Karte. -_AUS_IDEE_KOPF = ( - "AUFTRAGS-ART: PROJEKT AUS GEPRUEFTER IDEE. Der Commander hat diese Idee erst durchdenken " - "lassen — das Konzept gefaellt ihm, jetzt soll daraus ein startklares Repo werden, das er " - "SELBST in der Agentic IDE baut.") -_AUS_IDEE_FUSS = ( - "· Denk das Konzept NICHT neu. Es ist bereits mit voller Kaskade + Advocatus Diaboli " - "gehaertet. Lies es vollstaendig und uebernimm es; eine kurze Nachschaerf-Runde fuer die " - "Bau-Sicht (Etappen, Ziel-Plattform) ist erlaubt, eine komplette neue Denk-Kaskade ist " - "Verschwendung.\n" - "· Der Rest ist deine normale Projektstart-Rolle: das Repo enthaelt am Ende NUR Doku " - "(KONZEPT.md, ROADMAP.md, AGENTS.md, README.md, SAVEPOINT.md, .aiexclude) — KEIN Code.\n" - "· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in der Agentic IDE loslegen kann.") - - -def _wo_liegt(konz: dict) -> str: - """Eine Zeile: WO das Konzept wirklich liegt — für den Auftrag an den Worker. - - Wichtig ist der Unterschied zwischen „Repo bekannt" und „Konzept KOMMT aus dem Repo": - findet die Repo-Datei nicht statt (Push verpufft, Datei anders benannt), liest MC2 die - lokale Fassung. Wer dann trotzdem „liegt im Repo als " in den Auftrag - schreibt, schickt den Worker auf eine Datei, die es dort nie gab. - """ - repo, pfad = konz.get("repo"), konz.get("pfad") - url = konz.get("clone_url") or (f"https://{_GITEA_HOST}/{repo}.git" if repo else "") - if konz.get("quelle") == "repo": - return (f"Das gepruefte Konzept liegt im Gitea-Repo `{repo}` ({url}) " - f"als `{konz.get('datei') or 'KONZEPT.md'}`.") - if repo: - return (f"Das gepruefte Konzept liegt als Datei `{pfad}` auf dieser Box. Zur Idee gehoert " - f"bereits das Gitea-Repo `{repo}` ({url}) — dort ist das Konzept aber NICHT (mehr) " - "abrufbar. Pruefe das Repo, bevor du etwas anlegst.") - return f"Das gepruefte Konzept liegt als Datei `{pfad}` auf dieser Box." - - -def _konzept_und_name(task_id: str) -> tuple: - """(Konzept-Daten, kurzer Anzeige-Name, Fehlertext) einer Idee-Karte. - - Der Kartentitel ist der ROHE Zuruf des Commanders („Recherchiere zu … Lass ma…") — - als Name unbrauchbar. Die Ueberschrift des Konzepts ist die aufgeraeumte Fassung - derselben Sache; nur wenn es keine gibt, den Zuruf kappen. - """ - konz = konzept_of(task_id) - if not konz.get("konzept"): - return {}, "", (konz.get("error") - or "Zu dieser Idee gibt es noch kein Konzept — erst durchdenken lassen.") - try: - r = _hermes(["show", task_id, "--json"], timeout=30) - d = _parse_json(r.stdout) - except Exception as exc: - return {}, "", f"Karte nicht lesbar: {exc}" - t = (d.get("task") if isinstance(d, dict) and isinstance(d.get("task"), dict) - else d if isinstance(d, dict) else {}) - quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip()) - if not quell_titel: - return {}, "", "Quell-Karte hat keinen Titel." - h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.MULTILINE) - kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0] - return konz, _kurz(kurz, 85), "" - - -def projekt_aus_idee(task_id: str, ziel: str = "", tiefe: str = "") -> dict: - """Aus einer fertig durchdachten Idee eine IDE-Projekt-Karte machen. - - Das Konzept der Quell-Karte (Repo oder ~/konzepte-Datei) wird als Vorlage in den - Auftrag geschrieben; die neue Karte haengt als Kind an der Idee (task_links), damit - das Auftragsbuch beide als eine Kette zeigt. - """ - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - - konz, kurz, fehler = _konzept_und_name(task_id) - if fehler: - return {"ok": False, "error": fehler} - titel = f"Projekt: {kurz}"[:200] - - if konz.get("repo"): - # Liegt das Konzept schon in einem Gitea-Repo, ist DAS das Projekt-Repo — - # ein zweites anzulegen wuerde den Commander nur mit Dubletten beschenken. - quelle = (f"· {_wo_liegt(konz)}\n" - f"· NUTZE GENAU DIESES REPO als Projekt-Repo — lege KEIN neues an (Schritt 2 " - "entfaellt). Klone es voll, ergaenze die fehlenden Doku-Dateien und pushe auf main.") - else: - quelle = (f"· {_wo_liegt(konz)}\n" - "· Lies es vollstaendig, lege dann wie in Schritt 2 ein frisches Repo an und " - "uebernimm den Inhalt als `KONZEPT.md`.") - - zielwahl = _ZIEL.get(str(ziel).lower()) - infra = _INFRA_DOCKER if str(ziel).lower() == "docker" else _INFRA_LXC - teile = [f"{_AUS_IDEE_KOPF}\n{quelle}\n{_AUS_IDEE_FUSS}"] - if _AUFWAND.get(str(tiefe).lower()): - teile.append(_AUFWAND[str(tiefe).lower()]) - if zielwahl: - teile.append(zielwahl) - teile.append(infra + " " + _ZIEL_HINWEIS + (" " + _ZIEL_EHRLICH if zielwahl else "")) - notiz = "\n\n".join(teile) - - args = ["create", titel, "--body", notiz[:4000], "--assignee", "projektstart", - "--parent", task_id, "--created-by", "mc2-ui", "--json"] - try: - r = _hermes(args) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - task = _parse_json(r.stdout) - if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"): - return {"ok": False, - "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]} - _list_cache["ts"] = 0.0 - try: - from services import announce - announce.add(f"Aus der Idee „{_kurz(kurz, 60)}“ wird ein Projekt: {task['id']} — die Box " - "bereitet das Repo vor, gebaut wird in der Agentic IDE.", - "[Ideen-Queue]", "ideen-queue", "silent") - except Exception: - pass - return {"ok": True, "id": task["id"], "titel": titel} - - -# ── „So nicht" — Konzept mit Rueckmeldung ueberarbeiten lassen (21.07.) ────── -# Vorher gab es nach dem Lesen nur annehmen oder wegwerfen. Ein Konzept wird aber -# selten beim ersten Wurf richtig: der Commander sieht EINEN Punkt, der nicht passt. -# Die Ueberarbeitung ist wieder eine Idee-Karte (kein Bau) und schreibt die neue -# Fassung an DIESELBE Stelle — so zeigt „Konzept" ueberall den frischen Stand. -_UEBERARBEITEN_KOPF = ( - "AUFTRAGS-ART: IDEE PRUEFEN — UEBERARBEITUNG. Es gibt bereits ein durchdachtes, " - "gehaertetes Konzept. Der Commander hat es GELESEN und will gezielte Aenderungen. " - "KEIN Projektstart, KEIN Bau, KEIN Geruest.") -_UEBERARBEITEN_FUSS = ( - "· Arbeite die Rueckmeldung EIN: lies das bestehende Konzept vollstaendig, aendere die " - "genannten Punkte und alles, was dadurch nicht mehr stimmt (Folgewirkungen!). Denk das " - "Ganze NICHT von vorn — was er nicht angesprochen hat, bleibt.\n" - "· Nimm seinen Vorschlag ernst, aber sei ehrlich: schafft er ein Problem, dann bau ihn ein " - "UND schreib die Folge sichtbar ins Konzept (Abschnitt Risiken/offene Punkte). Setz dich " - "nicht stillschweigend darueber hinweg und rede es auch nicht schoen.\n" - "· Ein KURZER Haertetest (Advocatus Diaboli) NUR auf die geaenderten Teile reicht — die " - "volle Kaskade ist Verschwendung, der Rest ist bereits geprueft.\n" - "· Uebergabe: was du geaendert hast, was daraus folgte und was du bewusst gelassen hast.") - - -def konzept_ueberarbeiten(task_id: str, hinweis: str) -> dict: - """Rueckmeldung zum Konzept → neue Idee-Karte, die das Konzept ueberarbeitet. - - Haengt als Kind an der Quell-Karte (Kette im Auftragsbuch) und schreibt die neue - Fassung an dieselbe Stelle wie die alte (Repo bzw. Datei). - """ - if not _available(): - return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} - if not _TASK_ID_RX.match(task_id or ""): - return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} - hinweis = (hinweis or "").strip() - if not (3 <= len(hinweis) <= 2000): - return {"ok": False, "error": "Bitte schreib zwischen 3 und 2000 Zeichen, was anders werden soll."} - - konz, kurz, fehler = _konzept_und_name(task_id) - if fehler: - return {"ok": False, "error": fehler} - - if konz.get("quelle") == "repo": - quelle = (f"· {_wo_liegt(konz)}\n" - "· Die NEUE FASSUNG gehoert an dieselbe Stelle: Repo voll klonen, die Datei " - "ueberschreiben, committen und auf main pushen (Push mit `git ls-remote origin main` " - "BEWEISEN, nicht annehmen). KEIN neues Repo anlegen — der Commander vergleicht die " - "Fassungen ueber die Git-Historie.") - else: - quelle = (f"· {_wo_liegt(konz)}\n" - "· Die NEUE FASSUNG gehoert an dieselbe Stelle. Sichere die alte Fassung vorher " - "als `.bak`, dann schreib die ueberarbeitete Fassung in die Originaldatei." - + (" Gibt es das genannte Repo wirklich, gehoert die neue Fassung ZUSAETZLICH " - "als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)." - if konz.get("repo") else "")) - - teile = [(f"{_UEBERARBEITEN_KOPF}\n{quelle}\n" - f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n „{hinweis}“\n" - f"{_UEBERARBEITEN_FUSS}"), - _INFRA_LXC] - titel = f"Konzept nachschärfen: {kurz}"[:200] - args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart", - "--parent", task_id, "--created-by", "mc2-ui", "--json"] - try: - r = _hermes(args) - except Exception as exc: - return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} - task = _parse_json(r.stdout) - if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"): - return {"ok": False, - "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]} - # Die alte Fassung wird ueberschrieben → gecachtes Konzept der Quell-Karte verwerfen, - # sonst zeigt die alte Karte nach der Ueberarbeitung noch tagelang den alten Stand. - _konzept_cache.pop(task_id, None) - _list_cache["ts"] = 0.0 - try: - from services import announce - announce.add(f"Konzept „{_kurz(kurz, 60)}“ wird nachgeschärft ({task['id']}): " - f"„{_kurz(hinweis, 120)}“", - "[Ideen-Queue]", "ideen-queue", "silent") - except Exception: - pass - return {"ok": True, "id": task["id"], "titel": titel} - - -def log_of(task_id: str) -> dict: - """Worker-Log einer Idee (hermes kanban log ), ANSI entfernt, letzte ~120 Zeilen.""" - if not _available(): - return {"available": False, "lines": []} - if not _TASK_ID_RX.match(task_id or ""): - return {"available": True, "lines": []} - - now = time.time() - if ( - _log_cache["data"] is not None - and _log_cache["task_id"] == task_id - and now - _log_cache["ts"] < _LOG_EVERY - ): - return _log_cache["data"] - - try: - r = _hermes(["log", task_id]) - except Exception: - log.warning("ideen: kanban log fehlgeschlagen", exc_info=True) - return {"available": True, "lines": []} - if r.returncode != 0: - log.warning("ideen: kanban log exit=%s stderr=%s", r.returncode, r.stderr) - return {"available": True, "lines": []} - - raw = r.stdout or "" - # ANSI-Steuerzeichen entfernen - ansi = re.compile(r"\x1b\[[0-9;]*m") - cleaned = ansi.sub("", raw) - - # Letzte ~120 Zeilen - lines = cleaned.strip().splitlines()[-120:] - data = {"available": True, "lines": lines} - _log_cache.update(ts=now, task_id=task_id, data=data) - return data diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index 4510168..696a599 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -19,8 +19,11 @@ from services import catalog, discover, jobengine, llamaswap, system # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). SYSTEM_SERVICES = {"llama-swap"} +# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt +# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py). USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console", - "hermes-gateway", "hermes-builtin-ui", "voice-service"} + "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", + "projekte-sync", "mc2-backup"} # 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__), "..", "..")) diff --git a/backend/services/modell_nutzung.py b/backend/services/modell_nutzung.py new file mode 100644 index 0000000..8036860 --- /dev/null +++ b/backend/services/modell_nutzung.py @@ -0,0 +1,109 @@ +""" +Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026) + +llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je +Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell +zuletzt geladen wurde ("Health check passed"). + +Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25 +Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben, +bevor jemand nachsieht (gemessen am 23.09.2026). + +Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles, +was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). +""" + +import os +import re +import subprocess +import threading +import time +from collections import Counter, defaultdict +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +CACHE_S = 600 +TAGE = 7 +LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) + +_STANDARD_NAMEN = { + "127.0.0.1": "Lucy und OpenChamber über MC2", + "::1": "Lucy und OpenChamber über MC2", + "192.168.178.28": "NerdQuiz auf Arcane", + "192.168.178.22": "NerdQuiz-Tests vom PC", +} + +_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)') +_GELADEN = re.compile(r"<([^>]+)> Health check passed") + +_lock = threading.Lock() +_cache: dict = {"ts": 0.0, "daten": None} + + +def _namen() -> dict[str, str]: + namen = dict(_STANDARD_NAMEN) + for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"): + if "=" in paar: + ip, name = paar.split("=", 1) + namen[ip.strip()] = name.strip() + return namen + + +def _journal(seit: str) -> list[str]: + """Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen + am Tag. Ohne systemd (Windows-Dev) leer.""" + cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null" + " | grep -E 'POST /v1/|Health check passed'") + try: + out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60) + except Exception: + return [] + return out.stdout.splitlines() + + +def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict: + """Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell.""" + je_absender: Counter = Counter() + je_tag: dict[str, Counter] = defaultdict(Counter) + stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h) + geladen: dict[str, str] = {} + grenze_24h = jetzt - timedelta(hours=24) + for z in zeilen: + try: + ts = datetime.fromisoformat(z.split(" ", 1)[0]) + except ValueError: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=LOCAL_TZ) + if (m := _GELADEN.search(z)): + geladen[m.group(1)] = ts.isoformat() + continue + m = _ANFRAGE.search(z) + if not m or not m.group(2).startswith("/v1/chat/completions"): + continue + wer = namen.get(m.group(1), m.group(1)) + je_absender[wer] += 1 + je_tag[ts.date().isoformat()][wer] += 1 + if ts >= grenze_24h: + stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 + return { + "tage": TAGE, + "absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()], + "je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())}, + "letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))} + for h in range(24)], + "zuletzt_geladen": geladen, + } + + +def nutzung() -> dict: + """Gecachte Auswertung der letzten TAGE Tage (10 min).""" + with _lock: + if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S: + return _cache["daten"] + jetzt = datetime.now(LOCAL_TZ) + seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S") + daten = werte_aus(_journal(seit), jetzt, _namen()) + with _lock: + _cache.update(ts=time.time(), daten=daten) + return daten diff --git a/backend/services/sentry.py b/backend/services/sentry.py deleted file mode 100644 index 9631bdb..0000000 --- a/backend/services/sentry.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Health-Wächter der Box (Lucy-Proaktivität, Faden A3). - -Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, -Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten -(services/announce.py → Lucy spricht es) und via notify.sh (Telegram). - -Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in -Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick -nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert. - -Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend -(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0. -""" - -import asyncio -import logging -import os -import time - -import httpx -import psutil -from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL - -from services import announce, llamaswap - -log = logging.getLogger(__name__) - -ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0" -INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks -START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen -FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min) -REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h -DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90")) - - -def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool: - """Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist - "laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert. - - `headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an - und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log - (gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht — - und er haette einen echten Auth-Fehler unter sich begraben.""" - try: - with httpx.Client(timeout=5.0) as c: - return c.get(f"{url}{path}", headers=headers or {}).status_code < 500 - except Exception: - return False - - -def _hermes_kopf() -> dict[str, str]: - """Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist.""" - return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {} - - -def _check_engine() -> bool: - return llamaswap.engine_reachable() - - -def _check_brain() -> bool: - """Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL — - Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt.""" - st = llamaswap.brain_status() - if st.get("ready"): - return True - return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt - - -def _check_disk() -> bool: - try: - return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT - except Exception: - return True # kein Messwert ≠ Alarm - - -# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache). -CHECKS: dict[str, tuple] = { - "engine": (_check_engine, - "Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.", - "Die Modell-Engine ist wieder da."), - "brain": (_check_brain, - "Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.", - "Mein Gehirn ist wieder geladen. Alles klar bei mir."), - "hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()), - "Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.", - "Der Agent-Dienst läuft wieder."), - "voice": (lambda: _reach(VOICE_SERVICE_URL), - "Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.", - "Der Hör-Dienst läuft wieder."), - "disk": (_check_disk, - f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.", - "Die Platte hat wieder genug Luft."), -} - - -# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward), -# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei -# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit). -if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1": - _MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001") - _GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010") - CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"), - "Das Steuerpult ist ausgefallen — Dashboard und Briefkasten gehen gerade nicht.", - "Das Steuerpult ist wieder da.") - CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"), - "Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.", - "Der Modell-Gateway läuft wieder.") - - -class _Watch: - __slots__ = ("alert_ts", "alerted", "fails") - - def __init__(self) -> None: - self.fails = 0 # Fehl-Ticks in Folge - self.alerted = False # Alarm ist raus, Entwarnung steht aus - self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S) - - -_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS} - - -def _notify_telegram(subject: str, text: str) -> None: - """Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag - legt der Wächter selbst ab.""" - announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)") - - -def _tick() -> None: - now = time.time() - for name, (check, fail_msg, ok_msg) in CHECKS.items(): - w = _watches[name] - try: - ok = bool(check()) - except Exception: - ok = False - if ok: - w.fails = 0 - if w.alerted: - w.alerted = False - announce.add(ok_msg, subject="[Box wieder ok]", source="sentry") - _notify_telegram("[Box wieder ok]", ok_msg) - continue - w.fails += 1 - due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S) - if due: - prefix = "" if not w.alerted else "Immer noch: " - w.alerted = True - w.alert_ts = now - announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry") - _notify_telegram("[Box-Problem]", prefix + fail_msg) - log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails) - - -async def sentry_loop() -> None: - """Endlos-Schleife (Hintergrund-Task im MC2-Lifespan).""" - await asyncio.sleep(START_DELAY) - log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER) - while True: - try: - await asyncio.to_thread(_tick) - except Exception: - log.debug("sentry: Tick fehlgeschlagen", exc_info=True) - await asyncio.sleep(INTERVAL) diff --git a/backend/services/system.py b/backend/services/system.py index 0d855db..fdffa63 100644 --- a/backend/services/system.py +++ b/backend/services/system.py @@ -8,6 +8,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt import glob import os +import re import subprocess import threading import time @@ -127,33 +128,25 @@ def find_hermes_agent_git() -> dict | None: def get_engine_version() -> dict: - engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp") - git_info = get_git_info(engine_path) - if git_info: - return {**git_info, "type": "git"} - - candidates = [ - os.path.join(engine_path, "llama-server"), - os.path.join(engine_path, "bin", "llama-server"), - "/usr/local/bin/llama-server", - "/usr/bin/llama-server", - "llama-server" - ] - - for binary in candidates: - if binary != "llama-server" and not os.path.exists(binary): - continue - try: - res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2) - output = (res.stdout or "").strip() or (res.stderr or "").strip() - if output: - lines = output.splitlines() - ver = lines[0] if lines else "unknown" - return {"version_text": ver, "type": "binary"} - except Exception: - pass - - return {"type": "unknown"} + """Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan). + + Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit + mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die + Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH.""" + engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan") + binary = os.path.join(engine_path, "llama-server") + if not os.path.exists(binary): + return {"type": "unknown"} + try: + env = dict(os.environ, LD_LIBRARY_PATH=engine_path) + res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env) + text = (res.stdout or "") + (res.stderr or "") + except Exception: + return {"type": "unknown"} + if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)): + return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"} + zeile = next((z for z in text.splitlines() if z.startswith("version")), "") + return {"type": "binary", "version_text": zeile or "unbekannt"} _VERSION_CACHE = {"ts": 0.0, "data": {}} diff --git a/backend/services/waechter.py b/backend/services/waechter.py new file mode 100644 index 0000000..193f7c4 --- /dev/null +++ b/backend/services/waechter.py @@ -0,0 +1,535 @@ +""" +Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab. + +Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09. +stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie +gesehen. Dieser Wächter schaut deshalb breiter hin: + + • Dienste systemd-Zustand der Kern-Dienste (System + User) + • Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung) + • Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log) + • Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP + • Platte Füllstand des Modell-Laufwerks + +Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und +Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst +(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein +bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen +zusätzlich an Telegram und in Lucys Briefkasten. + +Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE +Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen +selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis. + +Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update) +hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort +zum Ablauf. +""" + +import asyncio +import json +import logging +import os +import re +import sqlite3 +import subprocess +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +import httpx +import psutil +from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL + +from services import announce, llamaswap, maintenance + +log = logging.getLogger(__name__) + +# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn. +ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0" +INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten +START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen +FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis +REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h +AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2")) +DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90")) +DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80")) +STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json"))) +VERLAUF_MAX = 200 + +# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter). +WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1" +MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001") +GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010") + +# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb. +DIENSTE: dict[str, tuple[bool, bool, str]] = { + "llama-swap": (True, True, "Motor (llama-swap)"), + "hermes-gateway": (False, True, "Hermes"), + "mc2-gateway": (False, True, "Modell-Gateway"), + "mission-control-2": (False, True, "MC2"), + "voice-service": (False, False, "Hör-Dienst"), + "lucy-stimme": (False, False, "Lucys Stimme"), + "hermes-builtin-ui": (False, False, "Hermes-Dashboard"), +} + +# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?) +TIMER_DIENSTE: dict[str, tuple[str, bool]] = { + "projekte-sync": ("Projekte-Abgleich", False), + "mc2-backup": ("Sicherung", True), +} + +HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json" +HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db" +HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log" + +# Prozesse, an denen ein laufendes Update zu erkennen ist. +_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b") + + +@dataclass +class Befund: + """Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis.""" + id: str + stufe: str # "rot" | "gelb" + titel: str + text: str + quelle: str + aktionen: list[dict] = field(default_factory=list) + auto: str | None = None # Name der Selbstreparatur, falls erlaubt + sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde) + + +def _aktion(aid: str, label: str, **extra: str) -> dict: + return {"id": aid, "label": label, **extra} + + +# --- Rohdaten ------------------------------------------------------------------- + +def _systemctl_show(name: str, system: bool) -> dict[str, str]: + """Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos).""" + cmd = ["systemctl"] if system else ["systemctl", "--user"] + cmd += ["show", name, "-p", + "LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp"] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout + except Exception: + return {} + return dict(line.split("=", 1) for line in out.splitlines() if "=" in line) + + +def _journal_fehlerzeile(name: str, system: bool = False) -> str: + """Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit.""" + cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"] + try: + zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines() + except Exception: + return "" + return waehle_fehlerzeile(zeilen) + + +def waehle_fehlerzeile(zeilen: list[str]) -> str: + """Letzte Zeile, die nach Fehler aussieht (projekte-sync markiert Fehler mit „!“).""" + muster = re.compile(r"(^\s*!|fehlgeschlagen|ABBRUCH|error|failed|fatal)", re.IGNORECASE) + for zeile in reversed(zeilen): + if muster.search(zeile): + # Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden + return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300] + return "" + + +def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool: + """Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört.""" + try: + with httpx.Client(timeout=5.0) as c: + return c.get(f"{url}{path}", headers=headers or {}).status_code < 500 + except Exception: + return False + + +def _update_laeuft() -> bool: + """Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund.""" + try: + for p in psutil.process_iter(["cmdline"]): + cmd = " ".join(p.info.get("cmdline") or []) + if cmd and _UPDATE_PROZESSE.search(cmd): + return True + except Exception: + return False + return False + + +# --- Prüfungen ------------------------------------------------------------------ + +def pruefe_dienste() -> list[Befund]: + befunde: list[Befund] = [] + for name, (system, wichtig, anzeige) in DIENSTE.items(): + z = _systemctl_show(name, system) + if not z or z.get("LoadState") in ("not-found", ""): + continue # nicht installiert oder kein systemd (Dev) + zustand = z.get("ActiveState", "") + if zustand in ("active", "activating", "reloading", "deactivating"): + continue + stufe = "rot" if wichtig else "gelb" + aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)] + if zustand == "failed": + befunde.append(Befund( + id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt", + text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.", + quelle=name, aktionen=aktionen, auto="neustart")) + else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten + befunde.append(Befund( + id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt", + text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.", + quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]])) + return befunde + + +def pruefe_timer_dienste() -> list[Befund]: + befunde: list[Befund] = [] + for name, (anzeige, wichtig) in TIMER_DIENSTE.items(): + z = _systemctl_show(name, False) + if not z or z.get("LoadState") in ("not-found", ""): + continue + if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success": + continue + grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}." + befunde.append(Befund( + id=f"timer:{name}", stufe="rot" if wichtig else "gelb", + titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name, + aktionen=[_aktion("protokoll", "Protokoll", dienst=name), + _aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)], + sofort=True)) + return befunde + + +def _hermes_jobs() -> list[dict]: + try: + daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten + if isinstance(jobs, dict): + jobs = list(jobs.values()) + return [j for j in jobs if isinstance(j, dict)] + + +def _letzter_lauf(job_id: str) -> dict | None: + """Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet).""" + if not HERMES_EXEC_DB.exists(): + return None + try: + con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2) + try: + row = con.execute( + "select status, started_at, finished_at, error from executions " + "where job_id=? order by started_at desc limit 1", (job_id,)).fetchone() + finally: + con.close() + except sqlite3.Error: + return None + if not row: + return None + return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]} + + +def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]: + """Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede + Zeile mit [cron___]; der Tag des Laufstarts reicht zur Zuordnung.""" + try: + tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d") + except (TypeError, ValueError): + return 0, "" + marke = f"[cron_{job_id}_{tag}_" + treffer = [z for z in zeilen if marke in z and "returned error" in z] + if not treffer: + return 0, "" + m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0]) + beispiel = "" + if m: + beispiel = f"{m.group(1)}: {m.group(2)}" + if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))): + beispiel = f"{m.group(1)}: {fm.group(1)}" + return len(treffer), beispiel[:220] + + +def _errors_log_ende(max_bytes: int = 400_000) -> list[str]: + try: + with HERMES_ERRORS_LOG.open("rb") as f: + f.seek(0, os.SEEK_END) + f.seek(max(0, f.tell() - max_bytes)) + return f.read().decode("utf-8", "replace").splitlines() + except OSError: + return [] + + +def pruefe_hermes_jobs() -> list[Befund]: + befunde: list[Befund] = [] + jobs = _hermes_jobs() + if not jobs: + return befunde + log_zeilen = _errors_log_ende() + jetzt = time.time() + for job in jobs: + if not job.get("enabled", True): + continue + jid, name = str(job.get("id", "")), str(job.get("name", "Job")) + wichtig = "update" in name.lower() + status = job.get("last_status") + if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0: + befunde.append(Befund( + id=f"job:{jid}", stufe="rot" if wichtig else "gelb", + titel=f"Job „{name}“ ist fehlgeschlagen", + text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300], + quelle=f"hermes-cron:{jid}", + aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid), + _aktion("protokoll", "Protokoll", job=jid)], + sofort=True)) + continue + if job.get("no_agent"): + continue # Skript-Jobs haben keine Werkzeuge + lauf = _letzter_lauf(jid) + if not lauf or not lauf.get("started_at"): + continue + try: + alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp() + except ValueError: + continue + if alter > 26 * 3600: + continue # nur der aktuelle Lauf zählt + anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"]) + if anzahl: + befunde.append(Befund( + id=f"job:{jid}:werkzeug", stufe="gelb", + titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf", + text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.", + quelle=f"hermes-cron:{jid}", + aktionen=[_aktion("protokoll", "Protokoll", job=jid)], + sofort=True)) + return befunde + + +def _hermes_kopf() -> dict[str, str]: + return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {} + + +def pruefe_kern() -> list[Befund]: + """HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)""" + proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok) + engine_ok = llamaswap.engine_reachable() + proben.append(("kern:engine", "Der Motor antwortet nicht", + "llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok)) + if engine_ok: + st = llamaswap.brain_status() + hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models()) + proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen", + "Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok)) + proben.append(("kern:hermes", "Hermes antwortet nicht", + "Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.", + _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()))) + proben.append(("kern:hoeren", "Der Hör-Dienst antwortet nicht", + "Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health"))) + if WATCH_MC2: + proben.append(("kern:mc2", "MC2 antwortet nicht", + "Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health"))) + proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht", + "Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health"))) + return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1]) + for (i, t, x, ok) in proben if not ok] + + +def pruefe_platte() -> list[Befund]: + try: + pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent + except Exception: + return [] + if pct >= DISK_ROT_PCT: + stufe = "rot" + elif pct >= DISK_GELB_PCT: + stufe = "gelb" + else: + return [] + return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll", + text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.", + quelle="platte", sofort=True)] + + +PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte) + + +# --- Zustand, Takt, Selbstreparatur ----------------------------------------------- + +_lock = threading.Lock() +_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0} + + +def _lade() -> None: + global _stand + try: + daten = json.loads(STORE_PATH.read_text(encoding="utf-8")) + if isinstance(daten.get("hinweise"), dict): + _stand = {**_stand, **daten} + except (OSError, ValueError): + pass + + +def _speichere() -> None: + try: + tmp = STORE_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8") + tmp.replace(STORE_PATH) + except OSError: + log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True) + + +def _verlauf(art: str, hinweis_id: str, text: str) -> None: + _stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text}) + del _stand["verlauf"][:-VERLAUF_MAX] + + +def _telegram(betreff: str, text: str) -> None: + announce.add(text, subject=betreff, source="waechter") + announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)") + + +def _auto_erlaubt(schluessel: str, jetzt: float) -> bool: + versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600] + _stand["auto"][schluessel] = versuche + return len(versuche) < AUTO_MAX_PRO_STUNDE + + +def _selbst_beheben(b: Befund, jetzt: float) -> None: + if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt): + return + ergebnis = maintenance.restart_service(b.quelle) + _stand["auto"][b.id].append(jetzt) + ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0)) + _verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)")) + log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen") + + +def takt() -> None: + """Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben.""" + jetzt = time.time() + update = _update_laeuft() + befunde: list[Befund] = [] + for pruefung in PRUEFUNGEN: + if update and pruefung in (pruefe_dienste, pruefe_kern): + continue # während eines Updates sind Neustarts normal + try: + befunde += pruefung() + except Exception: + log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True) + + with _lock: + hinweise: dict = _stand["hinweise"] + kandidaten: dict = _stand["kandidaten"] + aktuell = {b.id for b in befunde} + + for b in befunde: + k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt}) + k["takte"] += 1 + if not update and b.auto: + _selbst_beheben(b, jetzt) + if not (b.sofort or k["takte"] >= FAIL_AFTER): + continue + h = hinweise.get(b.id) + neu = h is None + h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0} + h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle, + aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"]) + hinweise[b.id] = h + if neu: + _verlauf("neu", b.id, b.titel) + if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S): + vorsatz = "" if neu else "Immer noch: " + _telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}") + h["gemeldet"] = jetzt + + # Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und + # Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft). + for hid in list(hinweise): + if hid in aktuell: + continue + if update and hid.startswith(("dienst:", "kern:")): + continue + h = hinweise.pop(hid) + _verlauf("erledigt", hid, h.get("titel", hid)) + if h.get("stufe") == "rot" and h.get("gemeldet"): + _telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.") + for kid in list(kandidaten): + if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))): + kandidaten.pop(kid) + + _stand["stand"] = jetzt + _stand["update_laeuft"] = update + _speichere() + + +async def waechter_loop() -> None: + """Endlos-Schleife im mc2-steward.""" + _lade() + await asyncio.sleep(START_DELAY) + log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)", + INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE) + while True: + try: + await asyncio.to_thread(takt) + except Exception: + log.warning("waechter: Takt fehlgeschlagen", exc_info=True) + await asyncio.sleep(INTERVAL) + + +# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------ + +def lese_stand() -> dict: + """Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf.""" + try: + daten = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + daten = {} + hinweise = sorted((daten.get("hinweise") or {}).values(), + key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0))) + for h in hinweise: + h.pop("gemeldet", None) + return { + "hinweise": hinweise, + "verlauf": list(reversed((daten.get("verlauf") or [])[-50:])), + "stand": daten.get("stand"), + "update_laeuft": bool(daten.get("update_laeuft")), + "aktiv": bool(daten), + } + + +def _job_protokoll(job_id: str) -> dict: + marke = f"[cron_{job_id}_" + zeilen = [z for z in _errors_log_ende() if marke in z][-60:] + return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."} + + +def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict: + """Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet.""" + stand = lese_stand() + hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None) + if hinweis is None: + return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."} + aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None) + if aktion is None: + return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."} + if aktion_id == "neustart": + return maintenance.restart_service(aktion["dienst"]) + if aktion_id == "protokoll": + if aktion.get("job"): + return _job_protokoll(aktion["job"]) + return maintenance.logs(aktion["dienst"], lines=120) + if aktion_id == "job-wiederholen": + try: + r = subprocess.run(["hermes", "cron", "run", aktion["job"]], + capture_output=True, text=True, timeout=30) + return {"ok": r.returncode == 0, + "text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."} + except Exception as e: + return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"} + return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."} diff --git a/backend/services/zeitplan.py b/backend/services/zeitplan.py new file mode 100644 index 0000000..2e60785 --- /dev/null +++ b/backend/services/zeitplan.py @@ -0,0 +1,99 @@ +""" +Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes. + +Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd- +Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen +Anfragen). Alles nur lesend. +""" + +import json +import os +import subprocess +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from services import modell_nutzung, waechter + +TIMER = {"mc2-backup.timer": "Sicherung"} +LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) + + +def _parse_iso(wert) -> datetime | None: + """ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten).""" + if not wert: + return None + try: + dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00")) + except ValueError: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ) + + +def _timer() -> list[dict]: + """systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer.""" + try: + out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"], + capture_output=True, text=True, timeout=10).stdout + daten = json.loads(out or "[]") + except Exception: + return [] + ergebnis = [] + for t in daten if isinstance(daten, list) else []: + name = TIMER.get(t.get("unit", "")) + if not name: + continue + def _us(v): + try: + return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None + except (TypeError, ValueError): + return None + ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))}) + return ergebnis + + +def _nerdquiz_nacht(nutzung: dict) -> dict | None: + """Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h.""" + stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"] + anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n) + if not anfragen: + return None + erste = next((s["stunde"] for s in stunden + if any("NerdQuiz" in n for n in s["je_absender"])), "03") + heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0) + return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn", + "status": "erledigt"} + + +def flugplan() -> dict: + """Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert.""" + jetzt = datetime.now(LOCAL_TZ) + heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0) + gelaufen: list[dict] = [] + geplant: list[dict] = [] + + for job in waechter._hermes_jobs(): + if not job.get("enabled", True): + continue + name = str(job.get("name", "Job")) + letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at")) + if letzter and letzter >= heute_start: + ok = job.get("last_status") in (None, "ok", "success") + gelaufen.append({"zeit": letzter, "titel": name, "text": "", + "status": "erledigt" if ok else "fehler"}) + if naechster and naechster > jetzt: + geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"}) + + for t in _timer(): + if t["letzter"] and t["letzter"] >= heute_start: + gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"}) + if t["naechster"] and t["naechster"] > jetzt: + geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"}) + + if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())): + gelaufen.append(nq) + + def _aus(eintraege: list[dict]) -> list[dict]: + return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])] + + grenze = jetzt + timedelta(days=8) + return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])} diff --git a/backend/steward.py b/backend/steward.py index 134c5fa..b29621d 100644 --- a/backend/steward.py +++ b/backend/steward.py @@ -1,15 +1,16 @@ """ MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2). -Bisher hingen Re-Warm-Wächter und Health-Wächter (sentry) am +Bisher hingen Re-Warm-Wächter und Health-Wächter am Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte -sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte -Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always): +sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener +Mini-Dienst (mc2-steward.service, Restart=always): • warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den In-Process-Nudge aus llamaswap.write_config) - • sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram; + • waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste, + Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur; beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung @@ -30,7 +31,7 @@ import logging import os from config import CONFIG_PATH -from services import sentry, warmer +from services import waechter, warmer logging.basicConfig( level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), @@ -65,8 +66,8 @@ async def main() -> None: tasks.append(asyncio.create_task(config_watch())) log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)", warmer.INTERVAL, CONFIG_WATCH_S) - if sentry.ENABLED: - tasks.append(asyncio.create_task(sentry.sentry_loop())) + if waechter.ENABLED: + tasks.append(asyncio.create_task(waechter.waechter_loop())) if not tasks: log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.") return diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..2c53f65 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,7 @@ +"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) — +für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/backend/tests/test_modell_nutzung.py b/backend/tests/test_modell_nutzung.py new file mode 100644 index 0000000..4c8313f --- /dev/null +++ b/backend/tests/test_modell_nutzung.py @@ -0,0 +1,45 @@ +"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026).""" + +from datetime import datetime + +from services import modell_nutzung + +ZEILEN = [ + '2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 ' + '"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s', + '2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 ' + '"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s', + '2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 ' + '"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s', + '2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 ' + '"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms', + "2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] Health check passed on http://localhost:5805/health", + '2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 ' + '"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s', +] + +NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"} + + +def test_zaehlt_nur_chat_anfragen_je_absender(): + d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) + assert d["absender"] == [ + {"name": "NerdQuiz auf Arcane", "anfragen": 3}, + {"name": "Lucy und OpenChamber über MC2", "anfragen": 1}, + ] + + +def test_letzte_24h_je_stunde_und_zuletzt_geladen(): + d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) + stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]} + assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb + assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1} + assert len(d["letzte_24h"]) == 24 + assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59") + + +def test_unbekannte_ip_bleibt_als_ip_stehen(): + zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 ' + '"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s') + d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) + assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}] diff --git a/backend/tests/test_waechter.py b/backend/tests/test_waechter.py new file mode 100644 index 0000000..6cabc47 --- /dev/null +++ b/backend/tests/test_waechter.py @@ -0,0 +1,79 @@ +"""Tests für die reinen Auswertungen des Wächters — mit echten Zeilen von der Box (23.09.2026).""" + +from services import waechter + +PROJEKTE_SYNC_LOG = [ + "2026-09-23 21:02:44 === Sync-Lauf (14 Repos gemeldet) ===", + "2026-09-23 21:02:45 = NerdQuiz-next: aktuell (a1b2c3d)", + "2026-09-23 21:02:46 ! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen — fatal: no such ref was fetched", + "2026-09-23 21:02:46 === Ende (Fehler: 1) ===", +] + +ERRORS_LOG = [ + '2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: ' + 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: ' + '/tmp/news-text.md exists"}', + '2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: ' + 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}', + "2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None", + '2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: ' + 'Tool web_extract returned error (0.10s): {"error": "timeout"}', +] + + +def test_fehlerzeile_nimmt_die_letzte_fehlerzeile_ohne_zeitstempel(): + zeile = waechter.waehle_fehlerzeile(PROJEKTE_SYNC_LOG) + assert zeile.startswith("! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen") + assert "2026-09-23" not in zeile + + +def test_fehlerzeile_leer_wenn_nichts_nach_fehler_aussieht(): + assert waechter.waehle_fehlerzeile(["alles gut", "=== Ende (Fehler: 0) ==="]) == "" + + +def test_werkzeugfehler_zaehlt_nur_den_lauf_des_tages(): + anzahl, beispiel = waechter.werkzeugfehler_im_lauf( + ERRORS_LOG, "195e479e8e30", "2026-09-23T07:00:17.675474+02:00") + assert anzahl == 2 + assert beispiel.startswith("write_file: Refusing to overwrite /tmp/news-text.md") + + +def test_werkzeugfehler_null_fuer_anderen_job_oder_kaputtes_datum(): + assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "ca7d4ed207c2", "2026-09-23T08:00:00+02:00") == (0, "") + assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "195e479e8e30", "kein-datum") == (0, "") + + +def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp_path): + """Flankenlogik: ein kurzer Aussetzer (1 Takt) erzeugt keinen Hinweis, ein dauerhafter schon. + Rote Hinweise gehen an Telegram, Erledigtes verschwindet und landet im Verlauf.""" + monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json") + monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}) + monkeypatch.setattr(waechter, "_update_laeuft", lambda: False) + meldungen: list[str] = [] + monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff)) + defekt = [True] + + def pruefung(): + if not defekt[0]: + return [] + return [waechter.Befund(id="kern:engine", stufe="rot", titel="Der Motor antwortet nicht", + text="…", quelle="engine")] + + monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,)) + monkeypatch.setattr(waechter, "FAIL_AFTER", 2) + + waechter.takt() + assert waechter._stand["hinweise"] == {} # erster Takt: nur Kandidat + waechter.takt() + assert "kern:engine" in waechter._stand["hinweise"] + assert meldungen == ["[Box-Problem]"] + + defekt[0] = False + waechter.takt() + assert waechter._stand["hinweise"] == {} + assert meldungen == ["[Box-Problem]", "[Box wieder ok]"] + arten = [v["art"] for v in waechter._stand["verlauf"]] + assert arten == ["neu", "erledigt"] + + stand = waechter.lese_stand() + assert stand["aktiv"] is True and stand["hinweise"] == [] diff --git a/deploy/projekte-sync.sh b/deploy/projekte-sync.sh index cbb4348..56bd7e0 100644 --- a/deploy/projekte-sync.sh +++ b/deploy/projekte-sync.sh @@ -91,6 +91,16 @@ for name in $REPOS; do continue fi + # Leeres Repo auf Gitea (angelegt, aber nie etwas gepusht): Es gibt keinen Branch, also + # scheitert jeder Pull mit „no such ref was fetched“. So lief projekte-sync ab dem + # 07.09.2026 stuendlich rot (TTT2-Toolbox_ClaudeEdition). Leer ist kein Fehler. + # Scheitert ls-remote selbst (Netz weg), geht es normal weiter und der Pull meldet es. + if kopf="$(git -C "$checkout" ls-remote --heads origin 2>/dev/null)" && [ -z "$kopf" ]; then + log " - $name: auf Gitea noch leer — nichts zu holen" + verknuepfe "$checkout" "$name" + continue + fi + if [ $DRY -eq 1 ]; then log " ~ $name: wuerde pullen (${checkout/$HOME/\~})" else diff --git a/mcp/mcp_voice.py b/mcp/mcp_voice.py index 160cc56..f844de5 100644 --- a/mcp/mcp_voice.py +++ b/mcp/mcp_voice.py @@ -93,27 +93,19 @@ def box_status() -> str: lines += [f"{'✅' if s.get('ok') else '❌'} {s.get('name')}" for s in svcs] except Exception: lines.append("(Dienste-Liste nicht abrufbar)") + # Hinweise des Wächters (Box-Wart, 09/2026): das, was der Commander tun sollte. + try: + hinweise = _get("/api/hinweise").get("hinweise", []) + except Exception: + hinweise = [] + if hinweise: + lines.append(f"Offene Hinweise ({len(hinweise)}):") + lines += [f"{'🔴' if h.get('stufe') == 'rot' else '🟡'} {h.get('titel')}" for h in hinweise[:5]] + else: + lines.append("Keine offenen Hinweise.") return "\n".join(lines) - -@mcp.tool() -def idee_notieren(titel: str, notiz: str = "") -> str: - """Legt eine Idee/einen Auftrag des Commanders in die Ideen-Queue der Box. Nutzen, sobald - der Commander eine Idee für später äußert ('wäre cool wenn …', 'bau mal irgendwann …', - 'die Box soll …') — NICHT nur versprechen. Die Box arbeitet die Idee selbst aus und baut; - das Ergebnis landet als Branch auf Gitea; gemergt wird nur vom Commander (Ampel gruen). - titel = kurze deutsche Überschrift; notiz = seine Worte plus nötiger Kontext.""" - try: - r = _post("/api/ideen", {"titel": titel, "notiz": notiz}) - except httpx.HTTPStatusError as e: - try: - detail = e.response.json().get("detail", "") - except Exception: - detail = e.response.text[:200] - return f"Fehlgeschlagen: {detail}" - except Exception as exc: - return f"Queue nicht erreichbar ({exc})." - return f"Idee ist in der Queue ({r.get('id', '?')}). Die Box arbeitet sie von allein ab." +# idee_notieren ist mit der Ideen-Warteschlange entfallen (Box-Wart-Umbau 09/2026). if __name__ == "__main__":