Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d46adfd86 | ||
|
|
177c9a311c | ||
|
|
4b226d5d22 | ||
|
|
ed6948429a | ||
|
|
7135042752 | ||
|
|
790de19f1a | ||
|
|
f3af2adec3 | ||
|
|
bfb07a92c3 | ||
|
|
907289d7dc | ||
|
|
3d2c9549fb | ||
|
|
12dadfe6ef | ||
|
|
3669a6e7dc | ||
|
|
cd54fe4a0f | ||
|
|
1e7a6d974f | ||
|
|
61f226e86d | ||
|
|
6809d357c0 | ||
|
|
502a6010f1 | ||
|
|
f9f2e116e0 | ||
|
|
479fecd2c6 | ||
|
|
df8d088fac | ||
|
|
b570e9410d | ||
|
|
e9c2599542 | ||
|
|
a8a6ce3b29 | ||
|
|
0944b1d93e | ||
|
|
1c8f285151 | ||
|
|
041ede7f8d | ||
|
|
33032f8fb0 | ||
|
|
3249c8e942 | ||
|
|
24a0694b80 | ||
|
|
a494d320d7 | ||
|
|
7957cda438 | ||
|
|
dcee0096fe | ||
|
|
b74e98ffbb | ||
|
|
5aa5a484a7 | ||
|
|
464b0d20b8 | ||
|
|
ae69c5ce31 | ||
|
|
8aa22e6591 |
+22
-4
@@ -18,18 +18,36 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"dev",
|
||||||
|
"--",
|
||||||
|
"--port",
|
||||||
|
"5180",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
"env": {
|
||||||
|
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||||
|
},
|
||||||
"autoPort": false,
|
"autoPort": false,
|
||||||
"port": 5180
|
"port": 5180
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "frontend-mock",
|
"name": "frontend-mock",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"dev",
|
||||||
|
"--",
|
||||||
|
"--port",
|
||||||
|
"5181",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
"env": {
|
||||||
|
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||||
|
},
|
||||||
"autoPort": false,
|
"autoPort": false,
|
||||||
"port": 5181
|
"port": 5181
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
#
|
#
|
||||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
|
||||||
|
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
|
||||||
name: Ampel
|
name: Ampel
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
@@ -43,6 +44,64 @@ jobs:
|
|||||||
else
|
else
|
||||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||||
|
|
||||||
|
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
|
||||||
|
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
|
||||||
|
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
|
||||||
|
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
|
||||||
|
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
|
||||||
|
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
|
||||||
|
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
|
||||||
|
if [ $rot -eq 0 ]; then
|
||||||
|
echo "-- Vitest"
|
||||||
|
( cd frontend && npm test --silent ) || rot=1
|
||||||
|
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
|
||||||
|
( cd frontend && npm run lint --silent ) || rot=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
|
||||||
|
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
|
||||||
|
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
|
||||||
|
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
|
||||||
|
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
|
||||||
|
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
|
||||||
|
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
|
||||||
|
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
|
||||||
|
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
|
||||||
|
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
|
||||||
|
# bewusst und einmalig, nicht weil es im Weg stand:
|
||||||
|
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
|
||||||
|
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
|
||||||
|
# hinter lazy() wanderten und das Budget scharf blieb.
|
||||||
|
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
|
||||||
|
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
|
||||||
|
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
|
||||||
|
# ueberlegten Plattform-Schritt.
|
||||||
|
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
|
||||||
|
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
|
||||||
|
budget_dist=3145728 # Stand nach P0: 1 477 852 B
|
||||||
|
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
|
||||||
|
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
|
||||||
|
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
|
||||||
|
# Hash den falschen erwischt — und das Budget waere still immer gruen.
|
||||||
|
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
|
||||||
|
haupt="frontend/dist/$einstieg"
|
||||||
|
if [ -n "$haupt" ]; then
|
||||||
|
gz=$(gzip -c "$haupt" | wc -c)
|
||||||
|
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
|
||||||
|
if [ "$gz" -gt "$budget_gz" ]; then
|
||||||
|
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
|
||||||
|
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
gesamt=$(du -sb frontend/dist | cut -f1)
|
||||||
|
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
|
||||||
|
if [ "$gesamt" -gt "$budget_dist" ]; then
|
||||||
|
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $rot -eq 0 ]; then
|
if [ $rot -eq 0 ]; then
|
||||||
echo "== Frontend: Push nach release-dist =="
|
echo "== Frontend: Push nach release-dist =="
|
||||||
git config --global user.name "Gitea Actions"
|
git config --global user.name "Gitea Actions"
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ __pycache__/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||||
|
|
||||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
|
||||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
|
||||||
frontend/public/avatar.vrm
|
|
||||||
frontend/dist/avatar.vrm
|
|
||||||
|
|
||||||
# Env / local
|
# Env / local
|
||||||
*.env
|
*.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
|||||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
|
||||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
|||||||
|
|
||||||
| Bahn | Was sie tut | Wo |
|
| Bahn | Was sie tut | Wo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue | MC2 `:9001` |
|
||||||
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
||||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
| **Lucy** | Innere Stimme am PC (HUD, kein Avatar) und auf Telegram — eine Stimme, ein Hirn | eigenes Repo `Hitonabi/lucy` |
|
||||||
|
|
||||||
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildwei
|
|||||||
|
|
||||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
||||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
Auto-Update und Self-Smoke. `llama-swap` läuft als System-Unit.
|
||||||
|
|
||||||
## Die Bau-Pipeline
|
## Die Bau-Pipeline
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ Kontext-Komprimierung löscht still die Regeln mit.
|
|||||||
## API (Auswahl)
|
## API (Auswahl)
|
||||||
|
|
||||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||||
memory/* · agent/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
memory/* · agent/* · ideen/* · lucy/stimme/* · chronik · eigenleben · wissen ·
|
||||||
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||||
|
|
||||||
@@ -142,7 +142,6 @@ Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `n
|
|||||||
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
||||||
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
|
||||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||||
|
|
||||||
|
|||||||
+26
-26
@@ -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
|
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-
|
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.
|
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
|
import asyncio
|
||||||
@@ -15,32 +19,27 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from routers import (
|
from routers import (
|
||||||
agent,
|
agent,
|
||||||
auftragsbuch,
|
boxwart,
|
||||||
chronik,
|
|
||||||
connect,
|
|
||||||
console,
|
|
||||||
events,
|
events,
|
||||||
gateway_proxy,
|
gateway_proxy,
|
||||||
health,
|
health,
|
||||||
hermes_ui,
|
hermes_ui,
|
||||||
ideen,
|
|
||||||
maintenance,
|
maintenance,
|
||||||
models,
|
models,
|
||||||
|
radar,
|
||||||
routing,
|
routing,
|
||||||
skills,
|
|
||||||
system,
|
system,
|
||||||
voice,
|
voice,
|
||||||
wissen,
|
|
||||||
zeitmaschine,
|
zeitmaschine,
|
||||||
)
|
)
|
||||||
from routers import reminders as reminders_router
|
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
|
from starlette.requests import Request
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
@@ -54,8 +53,9 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier,
|
||||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich
|
||||||
|
sonst nicht selbst melden."""
|
||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
if warmer.ENABLED:
|
if warmer.ENABLED:
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||||
@@ -63,11 +63,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||||
warmer.INTERVAL,
|
warmer.INTERVAL,
|
||||||
)
|
)
|
||||||
if sentry.ENABLED:
|
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
# Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||||
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||||
|
else:
|
||||||
|
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.")
|
||||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||||
app.state.gw_client = httpx.AsyncClient(
|
app.state.gw_client = httpx.AsyncClient(
|
||||||
@@ -104,11 +107,12 @@ async def no_cache(
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
|
|
||||||
app.include_router(connect.router)
|
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
voice.router
|
voice.router
|
||||||
@@ -126,19 +130,11 @@ if V1_UPSTREAM:
|
|||||||
else:
|
else:
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||||
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(
|
app.include_router(
|
||||||
hermes_ui.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.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
@@ -149,6 +145,10 @@ if FRONTEND_DIST.exists():
|
|||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
def spa(full_path: str):
|
def spa(full_path: str):
|
||||||
|
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
||||||
|
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
||||||
|
if full_path == "api" or full_path.startswith("api/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
||||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||||
try:
|
try:
|
||||||
|
|||||||
+9
-2
@@ -26,9 +26,11 @@ DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
|||||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||||
|
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||||
|
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||||
_DEFAULT_CMD_TEMPLATE = (
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||||
)
|
)
|
||||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||||
if "{model}" not in CMD_TEMPLATE:
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
@@ -96,6 +98,11 @@ HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
|||||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||||
# passen. MC2 proxyt nach außen.
|
# passen. MC2 proxyt nach außen.
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||||
|
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||||
|
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||||
|
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||||
|
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||||
|
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||||
@@ -120,7 +127,7 @@ HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
|||||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
|
||||||
|
|
||||||
# --- Server ------------------------------------------------------------------
|
# --- Server ------------------------------------------------------------------
|
||||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
|
||||||
|
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
|
||||||
|
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
|
||||||
|
Nachtlauf das Hirn braucht. Ablauf:
|
||||||
|
|
||||||
|
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
|
||||||
|
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
|
||||||
|
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
|
||||||
|
|
||||||
|
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
|
||||||
|
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
|
||||||
|
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("radar_lauf")
|
||||||
|
|
||||||
|
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
def _beim_beenden(signum, _frame) -> None:
|
||||||
|
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
|
||||||
|
raise SystemExit(f"Signal {signum}")
|
||||||
|
|
||||||
|
|
||||||
|
def _notbremse(frist: float) -> threading.Timer:
|
||||||
|
def ziehen() -> None:
|
||||||
|
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
|
||||||
|
try:
|
||||||
|
radar.notstopp()
|
||||||
|
finally:
|
||||||
|
os._exit(3)
|
||||||
|
|
||||||
|
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
|
||||||
|
bremse.daemon = True
|
||||||
|
bremse.start()
|
||||||
|
return bremse
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def melde_bestanden(test: dict) -> None:
|
||||||
|
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
|
||||||
|
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
|
||||||
|
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||||
|
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
|
||||||
|
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
|
||||||
|
f"{str(test.get('zusammenfassung') or '')[:300]} "
|
||||||
|
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
|
||||||
|
try:
|
||||||
|
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
beginn = radar.jetzt()
|
||||||
|
if radar.suche_faellig(beginn):
|
||||||
|
try:
|
||||||
|
ergebnis = radar.suche()
|
||||||
|
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
|
||||||
|
except Exception:
|
||||||
|
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
|
||||||
|
else:
|
||||||
|
log.info("Radar: heute schon gesucht.")
|
||||||
|
|
||||||
|
if not radar.im_fenster(radar.jetzt()):
|
||||||
|
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
|
||||||
|
radar.FENSTER_ENDE)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
frist = radar.fenster_ende(radar.jetzt()).timestamp()
|
||||||
|
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||||
|
bremse = _notbremse(frist)
|
||||||
|
try:
|
||||||
|
if (nachgeholt := radar.nachurteilen(frist)):
|
||||||
|
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
|
||||||
|
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
|
||||||
|
if not plan.get("kandidat"):
|
||||||
|
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
|
||||||
|
return 0
|
||||||
|
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
|
||||||
|
test = radar.teste(plan["kandidat"], frist)
|
||||||
|
if test:
|
||||||
|
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
|
||||||
|
if test.get("ergebnis") == "bestanden":
|
||||||
|
melde_bestanden(test)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
bremse.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from services import agent_aktivitaet
|
||||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
|
|||||||
def set_brain_model(body: BrainReq) -> dict:
|
def set_brain_model(body: BrainReq) -> dict:
|
||||||
ok = update_brain_model(body.model)
|
ok = update_brain_model(body.model)
|
||||||
return {"ok": ok}
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent/aktivitaet")
|
||||||
|
def aktivitaet(limit: int = 60) -> dict:
|
||||||
|
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||||
|
|
||||||
|
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
|
||||||
|
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
|
||||||
|
"""
|
||||||
|
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
|
|
||||||
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import auftragsbuch
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class BranchIn(BaseModel):
|
|
||||||
branch: str
|
|
||||||
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
|
|
||||||
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
|
|
||||||
|
|
||||||
|
|
||||||
class KandidatIn(BaseModel):
|
|
||||||
file: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch")
|
|
||||||
def list_proposals() -> dict:
|
|
||||||
return auftragsbuch.list_proposals()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch/diff")
|
|
||||||
def diff(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
res = auftragsbuch.diff_of(branch, repo)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/annehmen")
|
|
||||||
def accept(body: BranchIn) -> dict:
|
|
||||||
res = auftragsbuch.accept(body.branch, body.repo)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/ablehnen")
|
|
||||||
def reject(body: BranchIn) -> dict:
|
|
||||||
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/annehmen")
|
|
||||||
def skill_accept(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_accept(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/ablehnen")
|
|
||||||
def skill_reject(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_reject(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
|
||||||
|
|
||||||
|
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
|
||||||
|
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||||
|
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||||
|
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||||
|
GET /api/zeitplan Heute gelaufen / demnächst geplant
|
||||||
|
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||||
|
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from config import MODELS_DIR
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from services import (
|
||||||
|
backup,
|
||||||
|
llamaswap,
|
||||||
|
maintenance,
|
||||||
|
modell_nutzung,
|
||||||
|
system,
|
||||||
|
update_verlauf,
|
||||||
|
waechter,
|
||||||
|
zeitplan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
_updates_lock = threading.Lock()
|
||||||
|
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False}
|
||||||
|
UPDATES_CACHE_S = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _updates_holen() -> None:
|
||||||
|
try:
|
||||||
|
daten = maintenance.updates()
|
||||||
|
except Exception:
|
||||||
|
daten = None
|
||||||
|
with _updates_lock:
|
||||||
|
if daten is not None or _updates_cache["daten"] is None:
|
||||||
|
_updates_cache["daten"] = daten or {}
|
||||||
|
_updates_cache["ts"] = time.time()
|
||||||
|
_updates_cache["laeuft"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def _updates_gecacht() -> dict:
|
||||||
|
"""maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart
|
||||||
|
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
|
||||||
|
zunächst nichts), frisch geholt wird im Hintergrund."""
|
||||||
|
with _updates_lock:
|
||||||
|
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||||
|
if veraltet and not _updates_cache["laeuft"]:
|
||||||
|
_updates_cache["laeuft"] = True
|
||||||
|
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
|
||||||
|
return _updates_cache["daten"] or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _bausteine(u: dict) -> list[dict]:
|
||||||
|
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
|
||||||
|
liste = []
|
||||||
|
if u.get("os"):
|
||||||
|
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
|
||||||
|
if u.get("engine"):
|
||||||
|
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
|
||||||
|
if u.get("swap"):
|
||||||
|
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
|
||||||
|
for c in u.get("components") or []:
|
||||||
|
if c.get("key") == "hermes_agent" and c.get("update"):
|
||||||
|
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit_kurz(ts: float | None) -> str:
|
||||||
|
if not ts:
|
||||||
|
return "–"
|
||||||
|
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
|
||||||
|
heute = datetime.now(LOCAL_TZ).date()
|
||||||
|
if dt.date() == heute:
|
||||||
|
return f"heute {dt:%H:%M}"
|
||||||
|
if (heute - dt.date()).days == 1:
|
||||||
|
return f"gestern {dt:%H:%M}"
|
||||||
|
return f"{dt:%d.%m. %H:%M}"
|
||||||
|
|
||||||
|
|
||||||
|
def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||||
|
ids = {h.get("id", "") for h in stand["hinweise"]}
|
||||||
|
versionen = system.check_versions_cached()
|
||||||
|
|
||||||
|
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
|
||||||
|
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
|
||||||
|
|
||||||
|
engine_ok = llamaswap.engine_reachable()
|
||||||
|
build = (versionen.get("engine") or {}).get("version_text", "")
|
||||||
|
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
|
||||||
|
|
||||||
|
hirn = llamaswap.brain_status() if engine_ok else {}
|
||||||
|
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
|
||||||
|
"geladen" if hirn.get("ready") else "lädt nicht"))
|
||||||
|
|
||||||
|
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
|
||||||
|
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
|
||||||
|
coder_an = bool(coder and coder.get("name") in laufend)
|
||||||
|
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
|
||||||
|
|
||||||
|
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
|
||||||
|
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
|
||||||
|
"antwortet nicht" if hermes_weg else "läuft"))
|
||||||
|
|
||||||
|
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
|
||||||
|
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
|
||||||
|
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
|
||||||
|
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
|
||||||
|
if job_hinweise else "alles gelaufen"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
sicherungen = backup.list_backups()
|
||||||
|
except Exception:
|
||||||
|
sicherungen = []
|
||||||
|
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
|
||||||
|
zu_alt = not letzte or time.time() - letzte > 36 * 3600
|
||||||
|
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
|
||||||
|
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||||
|
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||||
|
|
||||||
|
n = len(_bausteine(u))
|
||||||
|
fest = len(update_verlauf.festgehalten())
|
||||||
|
if fest:
|
||||||
|
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||||
|
elif _updates_cache["daten"] is None:
|
||||||
|
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||||
|
else:
|
||||||
|
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
|
||||||
|
return lampen
|
||||||
|
|
||||||
|
|
||||||
|
def _box() -> dict:
|
||||||
|
p = system.metrik_punkt()
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
|
||||||
|
platte = {"used": du.used, "total": du.total, "percent": du.percent}
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
|
||||||
|
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
|
||||||
|
"platte": platte, "uptime_s": p.get("uptime_s")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/start")
|
||||||
|
def start() -> dict:
|
||||||
|
stand = waechter.lese_stand()
|
||||||
|
u = _updates_gecacht()
|
||||||
|
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
||||||
|
anzahl = len(stand["hinweise"])
|
||||||
|
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
||||||
|
return {
|
||||||
|
"zustand": {
|
||||||
|
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
|
||||||
|
"anzahl": anzahl,
|
||||||
|
"waechter_wach": wach,
|
||||||
|
"stand": stand["stand"],
|
||||||
|
"update_laeuft": stand["update_laeuft"],
|
||||||
|
},
|
||||||
|
"hinweise": stand["hinweise"],
|
||||||
|
"verlauf": stand["verlauf"][:20],
|
||||||
|
"lampen": _lampen(stand, u),
|
||||||
|
"box": _box(),
|
||||||
|
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [],
|
||||||
|
"festgehalten": update_verlauf.festgehalten(),
|
||||||
|
"gelesen": _updates_cache["daten"] is not None},
|
||||||
|
"flugplan": zeitplan.flugplan(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hinweise")
|
||||||
|
def hinweise() -> dict:
|
||||||
|
return waechter.lese_stand()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||||
|
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
|
||||||
|
if ergebnis.get("detail") and not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis["detail"])
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modelle/nutzung")
|
||||||
|
def nutzung() -> dict:
|
||||||
|
return modell_nutzung.nutzung()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zeitplan")
|
||||||
|
def zeitplan_route() -> dict:
|
||||||
|
return zeitplan.flugplan()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates/verlauf")
|
||||||
|
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||||
|
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||||
|
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||||
|
def festgehalten_freigeben(baustein: str) -> dict:
|
||||||
|
if not update_verlauf.freigeben(baustein):
|
||||||
|
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||||
|
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
|
|
||||||
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
|
|
||||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
|
||||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import announce
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
ts: float
|
|
||||||
subject: str
|
|
||||||
text: str
|
|
||||||
source: str
|
|
||||||
priority: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikResponse(BaseModel):
|
|
||||||
items: list[ChronikItem]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chronik", response_model=ChronikResponse)
|
|
||||||
def chronik(limit: int = 150) -> dict:
|
|
||||||
return {"items": announce.list_recent(limit)}
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
|
|
||||||
|
|
||||||
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
|
|
||||||
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
|
|
||||||
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
|
|
||||||
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
|
|
||||||
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
|
|
||||||
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from config import BOX_CONSOLE_UPSTREAM
|
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _register(base: str, upstream: str) -> None:
|
|
||||||
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
|
|
||||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
|
||||||
|
|
||||||
@router.websocket(f"/{base}/ws")
|
|
||||||
async def _proxy_ws(ws: WebSocket) -> None:
|
|
||||||
await ws.accept(subprotocol="tty")
|
|
||||||
try:
|
|
||||||
async with websockets.connect(
|
|
||||||
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
|
|
||||||
open_timeout=10, max_size=None, ping_interval=None,
|
|
||||||
) as up:
|
|
||||||
async def c2u() -> None:
|
|
||||||
while True:
|
|
||||||
msg = await ws.receive()
|
|
||||||
if msg.get("type") == "websocket.disconnect":
|
|
||||||
return
|
|
||||||
if (t := msg.get("text")) is not None:
|
|
||||||
await up.send(t)
|
|
||||||
elif (b := msg.get("bytes")) is not None:
|
|
||||||
await up.send(b)
|
|
||||||
|
|
||||||
async def u2c() -> None:
|
|
||||||
async for m in up:
|
|
||||||
if isinstance(m, (bytes, bytearray)):
|
|
||||||
await ws.send_bytes(bytes(m))
|
|
||||||
else:
|
|
||||||
await ws.send_text(m)
|
|
||||||
|
|
||||||
_done, pending = await asyncio.wait(
|
|
||||||
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
except Exception:
|
|
||||||
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
|
||||||
url = f"{upstream}/{base}/{path}"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
||||||
r = await c.request(request.method, url, content=await request.body())
|
|
||||||
return Response(content=r.content, status_code=r.status_code,
|
|
||||||
media_type=r.headers.get("content-type"))
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
|
|
||||||
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
|
|
||||||
media_type="text/plain")
|
|
||||||
|
|
||||||
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
|
|
||||||
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
|
|
||||||
name=f"{base}_path")
|
|
||||||
|
|
||||||
|
|
||||||
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
|
|
||||||
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
|
|
||||||
_register("console", BOX_CONSOLE_UPSTREAM)
|
|
||||||
+69
-41
@@ -1,25 +1,26 @@
|
|||||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||||
|
|
||||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
|
||||||
|
|
||||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
|
||||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
|
||||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
|
||||||
invalidate-Event nur Lärm).
|
|
||||||
|
|
||||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
|
||||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
|
||||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
|
||||||
|
|
||||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
|
||||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
|
||||||
selbst und bis dahin pollt die UI wie bisher).
|
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
|
||||||
|
|
||||||
|
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
|
||||||
|
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
|
||||||
|
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
|
||||||
|
|
||||||
|
Frontend-Gegenstück: frontend/src/lib/events.ts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -35,7 +36,8 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
METRIK_S = 1.0 # Takt der Messpunkte
|
||||||
|
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
|
||||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
||||||
|
|
||||||
|
|
||||||
@@ -50,15 +52,9 @@ def _fingerprints() -> dict[str, object]:
|
|||||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
||||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
||||||
fp: dict[str, object] = {}
|
fp: dict[str, object] = {}
|
||||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||||
from services import announce
|
from services import waechter
|
||||||
fp["chronik"] = announce.list_after(None)["latest"]
|
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||||
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 []])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
||||||
@@ -66,35 +62,67 @@ def _fingerprints() -> dict[str, object]:
|
|||||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
|
||||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
from services import jobengine
|
||||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
fp["jobs"] = json.dumps(
|
||||||
|
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs()]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
|
||||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
||||||
return fp
|
return fp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
async def _strom(request: Request, mit_metrik: bool):
|
||||||
async def events(request: Request) -> StreamingResponse:
|
"""Gemeinsamer Kern beider Endpunkte.
|
||||||
async def strom():
|
|
||||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||||
|
"""
|
||||||
alt = _fingerprints()
|
alt = _fingerprints()
|
||||||
yield ": verbunden\n\n"
|
yield ": verbunden\n\n"
|
||||||
|
|
||||||
|
seit_abdruck = 0.0
|
||||||
seit_ping = 0.0
|
seit_ping = 0.0
|
||||||
|
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
return
|
return
|
||||||
await asyncio.sleep(TICK_S)
|
await asyncio.sleep(takt)
|
||||||
seit_ping += TICK_S
|
seit_abdruck += takt
|
||||||
|
seit_ping += takt
|
||||||
|
|
||||||
|
if mit_metrik:
|
||||||
|
try:
|
||||||
|
from services.system import metrik_punkt
|
||||||
|
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
|
||||||
|
seit_ping = 0.0
|
||||||
|
except Exception:
|
||||||
|
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||||
|
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
|
||||||
|
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
|
||||||
|
|
||||||
|
if seit_abdruck >= ABDRUCK_S:
|
||||||
|
seit_abdruck = 0.0
|
||||||
neu = _fingerprints()
|
neu = _fingerprints()
|
||||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||||
alt.update(neu)
|
alt.update(neu)
|
||||||
if keys:
|
if keys:
|
||||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
||||||
seit_ping = 0.0
|
seit_ping = 0.0
|
||||||
elif seit_ping >= KEEPALIVE_S:
|
|
||||||
|
if seit_ping >= KEEPALIVE_S:
|
||||||
yield ": ping\n\n"
|
yield ": ping\n\n"
|
||||||
seit_ping = 0.0
|
seit_ping = 0.0
|
||||||
|
|
||||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def stream(request: Request) -> StreamingResponse:
|
||||||
|
"""Anstöße UND Messpunkte."""
|
||||||
|
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||||
|
media_type="text/event-stream", headers=_KOPF)
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
|
|||||||
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
||||||
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
||||||
|
|
||||||
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||||
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
|
||||||
|
|
||||||
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
||||||
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
||||||
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||||
`:9119/...` weiter.
|
`:9119/...` weiter.
|
||||||
|
|
||||||
|
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
||||||
|
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
||||||
|
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
||||||
|
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
||||||
|
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
|
|||||||
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
||||||
|
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
||||||
|
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
||||||
|
|
||||||
|
|
||||||
|
def praefixiere_ort(ort: str) -> str:
|
||||||
|
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
||||||
|
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
||||||
|
return f"/{_BASE}{ort}"
|
||||||
|
return ort
|
||||||
|
|
||||||
|
|
||||||
|
def praefixiere_html(body: bytes) -> bytes:
|
||||||
|
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
||||||
|
if _BASE_PATH_RE.search(body):
|
||||||
|
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||||
|
elif b"<head>" in body:
|
||||||
|
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||||
|
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
||||||
|
|
||||||
|
|
||||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||||
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
|
|||||||
)
|
)
|
||||||
body = r.content
|
body = r.content
|
||||||
if "text/html" in r.headers.get("content-type", "").lower():
|
if "text/html" in r.headers.get("content-type", "").lower():
|
||||||
if _BASE_PATH_RE.search(body):
|
body = praefixiere_html(body)
|
||||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
resp = Response(content=body, status_code=r.status_code)
|
||||||
elif b"<head>" in body:
|
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
# dürfen nicht zu einer zusammenfallen.
|
||||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
for k, v in r.headers.multi_items():
|
||||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
kl = k.lower()
|
||||||
|
if kl in _DROP:
|
||||||
|
continue
|
||||||
|
if kl == "location":
|
||||||
|
v = praefixiere_ort(v)
|
||||||
|
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"/{_BASE}")
|
@router.get(f"/{_BASE}")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||||
|
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
||||||
|
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
||||||
|
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
||||||
|
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
||||||
|
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
||||||
|
mit einem im Browser geparkten Geheimnis übertüncht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import maintenance
|
from services import geheimnisse, maintenance
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
class SudoReq(BaseModel):
|
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RestartReq(BaseModel):
|
class RestartReq(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
class GeheimnisReq(BaseModel):
|
||||||
|
schluessel: str
|
||||||
|
wert: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/updates")
|
@router.get("/maintenance/updates")
|
||||||
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
|
|||||||
return maintenance.update_details(kind)
|
return maintenance.update_details(kind)
|
||||||
|
|
||||||
@router.post("/maintenance/check-updates")
|
@router.post("/maintenance/check-updates")
|
||||||
def check_updates(body: SudoReq) -> dict:
|
def check_updates() -> dict:
|
||||||
res = maintenance.check_updates_job(body.sudo_password)
|
res = maintenance.check_updates_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/os-update")
|
@router.post("/maintenance/os-update")
|
||||||
def os_update(body: SudoReq) -> dict:
|
def os_update() -> dict:
|
||||||
res = maintenance.os_update_job(body.sudo_password)
|
res = maintenance.os_update_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/engine-update")
|
@router.post("/maintenance/engine-update")
|
||||||
def engine_update(body: SudoReq) -> dict:
|
def engine_update() -> dict:
|
||||||
res = maintenance.engine_update_job(body.sudo_password)
|
res = maintenance.engine_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/swap-update")
|
@router.post("/maintenance/swap-update")
|
||||||
def swap_update(body: SudoReq) -> dict:
|
def swap_update() -> dict:
|
||||||
res = maintenance.swap_update_job(body.sudo_password)
|
res = maintenance.swap_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/update-all")
|
@router.post("/maintenance/update-all")
|
||||||
def update_all(body: SudoReq) -> dict:
|
def update_all() -> dict:
|
||||||
return maintenance.update_all_job(body.sudo_password)
|
return maintenance.update_all_job()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/reboot")
|
@router.post("/maintenance/reboot")
|
||||||
def reboot(body: SudoReq) -> dict:
|
def reboot() -> dict:
|
||||||
return maintenance.reboot(body.sudo_password)
|
return maintenance.reboot()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/restart")
|
@router.post("/maintenance/restart")
|
||||||
def restart(body: RestartReq) -> dict:
|
def restart(body: RestartReq) -> dict:
|
||||||
return maintenance.restart_service(body.service, body.sudo_password)
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/logs")
|
@router.get("/maintenance/logs")
|
||||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
return maintenance.logs(service, lines, x_sudo_password)
|
return maintenance.logs(service, lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_status() -> dict:
|
||||||
|
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||||
|
return geheimnisse.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||||
|
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||||
|
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||||
|
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||||
|
return {"ok": True, **geheimnisse.status()}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import psutil
|
|||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
from services.fit import evaluate_fit, max_ctx_for
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
|
|||||||
quant: str = "Q4_K_M"
|
quant: str = "Q4_K_M"
|
||||||
ctx: int | None = None
|
ctx: int | None = None
|
||||||
jinja: bool = False
|
jinja: bool = False
|
||||||
hf_token: str | None = None
|
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||||
|
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||||
|
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hf/search")
|
@router.get("/hf/search")
|
||||||
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
|
|||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
env = dict(HF_DOWNLOAD_ENV)
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if token := geheimnisse.hf_token():
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = token
|
||||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||||
on_done=_apply_role if role else None)
|
on_done=_apply_role if role else None)
|
||||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
|
||||||
|
|
||||||
|
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
|
||||||
|
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung)
|
||||||
|
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
|
||||||
|
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["radar"])
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(ergebnis: dict) -> dict:
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
|
||||||
|
detail=ergebnis.get("detail") or "Das ging nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/radar")
|
||||||
|
def radar_stand() -> dict:
|
||||||
|
return radar.uebersicht()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/suche")
|
||||||
|
def radar_suche() -> dict:
|
||||||
|
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand."""
|
||||||
|
ergebnis = radar.suche()
|
||||||
|
return {**radar.uebersicht(), "suche": ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/uebernehmen")
|
||||||
|
def radar_uebernehmen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.uebernehmen(kandidat_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/verwerfen")
|
||||||
|
def radar_verwerfen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.verwerfen(kandidat_id))
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
def _hermes_name(ordner: str) -> str:
|
|
||||||
"""Repo-Ordnername -> Hermes-Skillname.
|
|
||||||
|
|
||||||
deploy/deploy.sh kopiert `deploy/skills/<name>` nach `~/.hermes/skills/<name mit _>`
|
|
||||||
(`tr '-' '_'`), weil Hermes keine Bindestriche in Skillnamen mag. Ohne dieselbe
|
|
||||||
Umschreibung hier bekam Hermes den Ordnernamen MIT Bindestrich und fand den Skill
|
|
||||||
nicht - betroffen waren 7 der 12 Skills (betrieb-playbook, konzept-fliessband,
|
|
||||||
llm-wiki, morning-report, pc-pfad-cache, projekt-start, trend-radar): der Startknopf
|
|
||||||
meldete Erfolg, ausgefuehrt wurde nichts. deploy.sh bleibt die Quelle der Wahrheit.
|
|
||||||
"""
|
|
||||||
return ordner.replace("-", "_")
|
|
||||||
|
|
||||||
|
|
||||||
class RunSkillRequest(BaseModel):
|
|
||||||
skill_name: str
|
|
||||||
|
|
||||||
@router.get("/skills")
|
|
||||||
def list_skills():
|
|
||||||
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
|
|
||||||
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
repo_root = os.path.dirname(os.path.dirname(current_dir))
|
|
||||||
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
|
||||||
skills = []
|
|
||||||
|
|
||||||
# Check currently running hermes skill processes
|
|
||||||
running_skills = set()
|
|
||||||
for p in psutil.process_iter(['name', 'cmdline']):
|
|
||||||
try:
|
|
||||||
cmdline = p.info.get('cmdline')
|
|
||||||
if cmdline and any("hermes" in arg for arg in cmdline):
|
|
||||||
for arg in cmdline:
|
|
||||||
if "Führe den " in arg and " Skill aus" in arg:
|
|
||||||
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
|
||||||
import re
|
|
||||||
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
|
||||||
if match:
|
|
||||||
running_skills.add(match.group(1))
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
|
||||||
for entry in os.scandir(skills_dir):
|
|
||||||
if entry.is_dir():
|
|
||||||
# Suche nach SKILL.md für Metadaten
|
|
||||||
skill_md_path = os.path.join(entry.path, "SKILL.md")
|
|
||||||
description = ""
|
|
||||||
if os.path.exists(skill_md_path):
|
|
||||||
try:
|
|
||||||
with open(skill_md_path, "r", encoding="utf-8") as f:
|
|
||||||
# Lese erste Zeilen für Description (YAML Frontmatter)
|
|
||||||
lines = f.readlines()[:10]
|
|
||||||
for line in lines:
|
|
||||||
if "description:" in line.lower():
|
|
||||||
description = line.split(":", 1)[1].strip().strip('"\'')
|
|
||||||
break
|
|
||||||
elif "author:" in line.lower() and not description:
|
|
||||||
description = line.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
skills.append({
|
|
||||||
"name": entry.name,
|
|
||||||
# Der Name, unter dem Hermes den Skill kennt - auch fuer den Laufend-
|
|
||||||
# Vergleich, weil im Prozess-cmdline genau dieser Name steht.
|
|
||||||
"hermes_name": _hermes_name(entry.name),
|
|
||||||
"description": description or "Keine Beschreibung verfügbar.",
|
|
||||||
"running": _hermes_name(entry.name) in running_skills
|
|
||||||
})
|
|
||||||
return {"skills": skills}
|
|
||||||
|
|
||||||
@router.post("/skills/run")
|
|
||||||
def run_skill(req: RunSkillRequest):
|
|
||||||
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
|
|
||||||
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
|
|
||||||
if not os.path.exists(hermes_bin):
|
|
||||||
# Fallback falls lokal (auf Windows) entwickelt wird
|
|
||||||
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
|
|
||||||
|
|
||||||
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat - mit dem Namen, den Hermes
|
|
||||||
# nach dem Deploy-Sync wirklich kennt (Bindestriche -> Unterstriche, s. _hermes_name).
|
|
||||||
skill = _hermes_name(req.skill_name)
|
|
||||||
cmd = [hermes_bin, "-z", f"Führe den '{skill}' Skill aus", "chat"]
|
|
||||||
try:
|
|
||||||
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log")
|
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
||||||
with open(log_path, "a", encoding="utf-8") as f:
|
|
||||||
f.write(f"\n--- Starting Skill: {skill} ---\n")
|
|
||||||
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
|
|
||||||
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
|
||||||
except Exception as e:
|
|
||||||
return {"ok": False, "err": str(e)}
|
|
||||||
@@ -94,10 +94,10 @@ def services() -> dict:
|
|||||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||||
"ok": _voice_reachable(), "scope": "user"},
|
"ok": _voice_reachable(), "scope": "user"},
|
||||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
"ok": _user_unit_active("lucy-stimme"), "scope": "user"},
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"services": rows,
|
"services": rows,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import sys as _sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
|||||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||||
|
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||||
|
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||||
|
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||||
|
# LAN-only wie alle MC2-Endpoints.
|
||||||
|
|
||||||
|
class LucyTtsIn(BaseModel):
|
||||||
|
text: str
|
||||||
|
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lucy/stimme/health")
|
||||||
|
def lucy_stimme_health() -> dict:
|
||||||
|
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lucy/stimme/tts")
|
||||||
|
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||||
|
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||||
|
with Timer("lucy_tts"):
|
||||||
|
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||||
|
r.raise_for_status()
|
||||||
|
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||||
|
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lucy/stimme/tts/stream")
|
||||||
|
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||||
|
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||||
|
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||||
|
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||||
|
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||||
|
try:
|
||||||
|
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||||
|
upstream = await client.send(req, stream=True)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
await client.aclose()
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
if upstream.status_code != 200:
|
||||||
|
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||||
|
await upstream.aclose(); await client.aclose()
|
||||||
|
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
try:
|
||||||
|
async for chunk in upstream.aiter_raw():
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await upstream.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||||
|
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||||
|
|
||||||
|
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
|
||||||
|
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
|
||||||
|
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
|
||||||
|
zu patchen.
|
||||||
|
|
||||||
|
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
|
||||||
|
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
|
||||||
|
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
|
||||||
|
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
|
||||||
|
nützlicher als der Fließtext ihrer Gedanken.
|
||||||
|
|
||||||
|
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
|
||||||
|
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
|
||||||
|
halten kann.
|
||||||
|
|
||||||
|
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
|
||||||
|
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
|
||||||
|
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
LOG_PFAD = Path(os.path.expanduser(
|
||||||
|
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
|
||||||
|
|
||||||
|
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
|
||||||
|
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
|
||||||
|
LESE_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
|
||||||
|
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
|
||||||
|
#
|
||||||
|
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
|
||||||
|
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
|
||||||
|
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
|
||||||
|
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
|
||||||
|
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
|
||||||
|
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
|
||||||
|
|
||||||
|
_FERTIG = re.compile(
|
||||||
|
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
|
||||||
|
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
|
||||||
|
|
||||||
|
_FEHLER = re.compile(
|
||||||
|
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
|
||||||
|
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
|
||||||
|
|
||||||
|
|
||||||
|
def _schwanz(pfad: Path, n: int) -> list[str]:
|
||||||
|
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
|
||||||
|
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
|
||||||
|
try:
|
||||||
|
groesse = pfad.stat().st_size
|
||||||
|
with pfad.open("rb") as f:
|
||||||
|
f.seek(max(0, groesse - n))
|
||||||
|
roh = f.read()
|
||||||
|
zeilen = roh.decode("utf-8", errors="replace").splitlines()
|
||||||
|
return zeilen[1:] if groesse > n and zeilen else zeilen
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(detail: str | None) -> str | None:
|
||||||
|
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
|
||||||
|
JSON ab; interessant ist daran nur das `error`-Feld."""
|
||||||
|
if not detail:
|
||||||
|
return None
|
||||||
|
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
|
||||||
|
text = treffer.group(1) if treffer else detail.strip()
|
||||||
|
return (text[:297] + "…") if len(text) > 300 else text
|
||||||
|
|
||||||
|
|
||||||
|
def rufe(limit: int = 60) -> list[dict]:
|
||||||
|
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
|
||||||
|
ergebnis: list[dict] = []
|
||||||
|
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
|
||||||
|
m = _FERTIG.match(zeile)
|
||||||
|
if m:
|
||||||
|
ergebnis.append({
|
||||||
|
"zeit": _iso(m.group("zeit")),
|
||||||
|
"lauf": m.group("lauf"),
|
||||||
|
"werkzeug": m.group("werkzeug"),
|
||||||
|
"dauer_s": float(m.group("dauer")),
|
||||||
|
"zeichen": int(m.group("zeichen")),
|
||||||
|
"ok": True,
|
||||||
|
"fehler": None,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
m = _FEHLER.match(zeile)
|
||||||
|
if m:
|
||||||
|
ergebnis.append({
|
||||||
|
"zeit": _iso(m.group("zeit")),
|
||||||
|
"lauf": m.group("lauf"),
|
||||||
|
"werkzeug": m.group("werkzeug"),
|
||||||
|
"dauer_s": float(m.group("dauer")),
|
||||||
|
"zeichen": None,
|
||||||
|
"ok": False,
|
||||||
|
"fehler": _kurz(m.group("detail")),
|
||||||
|
})
|
||||||
|
return ergebnis[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(s: str) -> str:
|
||||||
|
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
|
||||||
|
|
||||||
|
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
|
||||||
|
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
|
||||||
|
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
|
||||||
|
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
|
||||||
|
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
|
||||||
|
Falle, die erst zur Zeitumstellung zuschnappt."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def uebersicht(limit: int = 60) -> dict:
|
||||||
|
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
|
||||||
|
wiederkehrenden Fehler zusammengefasst.
|
||||||
|
|
||||||
|
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
|
||||||
|
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
|
||||||
|
liste = rufe(limit)
|
||||||
|
if not LOG_PFAD.exists():
|
||||||
|
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
|
||||||
|
"werkzeuge": [], "laeufe": []}
|
||||||
|
|
||||||
|
bilanz: dict[str, dict] = {}
|
||||||
|
for r in liste:
|
||||||
|
b = bilanz.setdefault(r["werkzeug"], {
|
||||||
|
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
|
||||||
|
"dauer_summe": 0.0, "letzter_fehler": None,
|
||||||
|
})
|
||||||
|
b["rufe"] += 1
|
||||||
|
b["dauer_summe"] += r["dauer_s"]
|
||||||
|
if not r["ok"]:
|
||||||
|
b["fehler"] += 1
|
||||||
|
b["letzter_fehler"] = r["fehler"]
|
||||||
|
|
||||||
|
werkzeuge = sorted(
|
||||||
|
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
|
||||||
|
for b in bilanz.values()),
|
||||||
|
key=lambda b: (-b["fehler"], -b["rufe"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
|
||||||
|
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
|
||||||
|
laeufe: list[dict] = []
|
||||||
|
gesehen: dict[str, dict] = {}
|
||||||
|
for r in liste:
|
||||||
|
schluessel = r["lauf"] or "(interaktiv)"
|
||||||
|
if schluessel not in gesehen:
|
||||||
|
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
|
||||||
|
"rufe": 0, "fehler": 0}
|
||||||
|
laeufe.append(gesehen[schluessel])
|
||||||
|
e = gesehen[schluessel]
|
||||||
|
e["bis"] = r["zeit"]
|
||||||
|
e["rufe"] += 1
|
||||||
|
if not r["ok"]:
|
||||||
|
e["fehler"] += 1
|
||||||
|
|
||||||
|
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
|
||||||
|
"werkzeuge": werkzeuge, "laeufe": laeufe}
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""
|
|
||||||
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
|
|
||||||
|
|
||||||
Quellen der Karten:
|
|
||||||
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
|
|
||||||
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
|
|
||||||
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
|
|
||||||
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
|
|
||||||
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
|
|
||||||
|
|
||||||
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
|
|
||||||
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
|
|
||||||
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
|
|
||||||
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
|
|
||||||
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
|
|
||||||
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
|
|
||||||
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
|
|
||||||
|
|
||||||
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
|
|
||||||
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
|
|
||||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
|
||||||
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
|
|
||||||
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
|
|
||||||
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
|
|
||||||
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
|
|
||||||
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
|
|
||||||
|
|
||||||
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
|
|
||||||
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
|
|
||||||
REPOS: dict[str, dict] = {
|
|
||||||
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
|
|
||||||
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
|
|
||||||
# Muster wie mc2-announce.json unter MODELS_DIR.
|
|
||||||
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
|
|
||||||
|
|
||||||
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
|
|
||||||
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
|
|
||||||
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
|
|
||||||
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
|
|
||||||
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
|
|
||||||
|
|
||||||
_fetch_cache: dict = {}
|
|
||||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
|
||||||
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
|
|
||||||
|
|
||||||
def _gitea_creds() -> tuple | None:
|
|
||||||
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
p = subprocess.run(
|
|
||||||
["git", "credential", "fill"],
|
|
||||||
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
|
|
||||||
capture_output=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
out = p.stdout.decode(errors="replace")
|
|
||||||
u_match = re.search(r"username=(.+)", out)
|
|
||||||
p_match = re.search(r"password=(.+)", out)
|
|
||||||
if u_match and p_match:
|
|
||||||
return u_match.group(1).strip(), p_match.group(1).strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
|
|
||||||
try:
|
|
||||||
cred = Path("~/.git-credentials").expanduser()
|
|
||||||
for line in cred.read_text(encoding="utf-8").splitlines():
|
|
||||||
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
|
|
||||||
if m: return m.group(1), m.group(2)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
|
|
||||||
# Nur auf der Box (wo creds liegen) sinnvoll
|
|
||||||
creds = _gitea_creds()
|
|
||||||
if not creds: return None
|
|
||||||
_user, token = creds
|
|
||||||
cache_key = (repo, branch, head_sha)
|
|
||||||
cached = _CI_CACHE.get(cache_key)
|
|
||||||
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
|
|
||||||
return cached["status"]
|
|
||||||
|
|
||||||
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
|
|
||||||
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
|
||||||
with urllib.request.urlopen(req, timeout=5) as r:
|
|
||||||
data = json.load(r)
|
|
||||||
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
|
|
||||||
if runs:
|
|
||||||
run = runs[0]
|
|
||||||
if run.get("head_sha", "").startswith(head_sha[:7]):
|
|
||||||
st = run.get("status")
|
|
||||||
if st:
|
|
||||||
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
|
|
||||||
return st
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _available() -> bool:
|
|
||||||
return os.name == "posix" and (REPO / ".git").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_ok(repo: str) -> bool:
|
|
||||||
r = REPOS.get(repo)
|
|
||||||
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
|
|
||||||
capture_output=True, text=True, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _status_key(repo: str, branch: str) -> str:
|
|
||||||
return f"{REPOS[repo]['key']}{branch}"
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_throttled(repo: str) -> None:
|
|
||||||
now = time.time()
|
|
||||||
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
|
|
||||||
return
|
|
||||||
_fetch_cache[repo] = now
|
|
||||||
try:
|
|
||||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_announce_branches() -> set:
|
|
||||||
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
|
|
||||||
try:
|
|
||||||
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
|
|
||||||
return set(data) if isinstance(data, list) else set()
|
|
||||||
except Exception:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _save_announce_branches(branches: set) -> None:
|
|
||||||
"""Speichert die Menge gemelder Branch-Namen."""
|
|
||||||
try:
|
|
||||||
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(ANNOUNCE_BRANCHES_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _statuses() -> dict:
|
|
||||||
try:
|
|
||||||
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _set_status(key: str, state: str, detail: str) -> None:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
|
|
||||||
tmp = STATUS_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(STATUS_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _gutachten() -> dict:
|
|
||||||
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
|
|
||||||
try:
|
|
||||||
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _remote_branches(repo: str) -> list[str]:
|
|
||||||
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
|
|
||||||
out = []
|
|
||||||
for line in (r.stdout or "").splitlines():
|
|
||||||
name = line.strip().removeprefix("origin/")
|
|
||||||
if name and name != "HEAD" and name.startswith(PREFIXES):
|
|
||||||
out.append(name)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_branch(branch: str) -> bool:
|
|
||||||
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
|
|
||||||
items = []
|
|
||||||
for branch in _remote_branches(repo):
|
|
||||||
ref = f"origin/{branch}"
|
|
||||||
status = statuses.get(_status_key(repo, branch))
|
|
||||||
try:
|
|
||||||
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
|
|
||||||
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
|
|
||||||
continue # bereits in main enthalten → keine offene Entscheidung mehr
|
|
||||||
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
|
|
||||||
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
|
|
||||||
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
|
|
||||||
# garantierter Fehllauf („refusing to merge unrelated histories").
|
|
||||||
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
|
|
||||||
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
|
|
||||||
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
|
|
||||||
ts_val = int(cts) if cts.strip().isdigit() else None
|
|
||||||
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
|
|
||||||
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
|
|
||||||
stempel = gutachten.get(f"{repo}:{branch}")
|
|
||||||
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
|
||||||
stempel = None
|
|
||||||
|
|
||||||
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
|
|
||||||
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
|
|
||||||
|
|
||||||
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
|
|
||||||
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
|
|
||||||
files = []
|
|
||||||
for line in files_raw[:60]:
|
|
||||||
parts = line.split("\t")
|
|
||||||
if len(parts) >= 2:
|
|
||||||
files.append({"status": parts[0][:2], "path": parts[-1]})
|
|
||||||
paths = [f["path"] for f in files]
|
|
||||||
frontend_src = any(p.startswith("frontend/src") for p in paths)
|
|
||||||
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
|
|
||||||
items.append({
|
|
||||||
"repo": repo,
|
|
||||||
"branch": branch,
|
|
||||||
"kind": branch.split("/", 1)[0],
|
|
||||||
"subject": subject.strip(),
|
|
||||||
"body": body.strip()[:2000],
|
|
||||||
"author": author.strip(),
|
|
||||||
"ts": ts_val,
|
|
||||||
"empfehlung": stempel,
|
|
||||||
"ahead": ahead,
|
|
||||||
"behind": behind,
|
|
||||||
"verwaist": verwaist,
|
|
||||||
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
|
|
||||||
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
|
|
||||||
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
|
|
||||||
"leer": not verwaist and ahead > 0 and not files_raw,
|
|
||||||
"shortstat": stat,
|
|
||||||
"files": files,
|
|
||||||
"files_truncated": len(files_raw) > 60,
|
|
||||||
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
|
|
||||||
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
|
|
||||||
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
|
|
||||||
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
|
|
||||||
"ci_status": ci_status,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def list_proposals() -> dict:
|
|
||||||
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
|
|
||||||
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
|
|
||||||
|
|
||||||
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
|
|
||||||
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
|
|
||||||
if not _available():
|
|
||||||
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
|
|
||||||
|
|
||||||
statuses = _statuses()
|
|
||||||
gutachten = _gutachten()
|
|
||||||
items = []
|
|
||||||
for repo in REPOS:
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
|
|
||||||
_fetch_throttled(repo)
|
|
||||||
items.extend(_repo_items(repo, statuses, gutachten))
|
|
||||||
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
|
|
||||||
|
|
||||||
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
|
|
||||||
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
|
|
||||||
aktuelle_branches = set()
|
|
||||||
for repo in REPOS:
|
|
||||||
if _repo_ok(repo):
|
|
||||||
aktuelle_branches.update(_remote_branches(repo))
|
|
||||||
|
|
||||||
# Bereits gemeldete Branches laden
|
|
||||||
gemeldet = _load_announce_branches()
|
|
||||||
|
|
||||||
# Nur wirklich NEUE Branches pingen
|
|
||||||
neue = aktuelle_branches - gemeldet
|
|
||||||
for branch in neue:
|
|
||||||
# NotifyTelegram best-effort, niemals crashen
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
subject = "[Auftragsbuch]"
|
|
||||||
text = f"Neue Karte wartet auf deinen Klick: {branch}"
|
|
||||||
announce.notify_telegram(subject, text)
|
|
||||||
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
|
|
||||||
announce.add(text, subject, "auftragsbuch", "normal")
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
|
||||||
|
|
||||||
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
|
|
||||||
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
|
|
||||||
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
|
|
||||||
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
|
|
||||||
if aktuelle_branches != gemeldet:
|
|
||||||
_save_announce_branches(aktuelle_branches)
|
|
||||||
|
|
||||||
kandidaten = list_skill_kandidaten()
|
|
||||||
open_count = sum(1 for i in items
|
|
||||||
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
|
|
||||||
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
|
|
||||||
|
|
||||||
|
|
||||||
def diff_of(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Nur auf der Box verfügbar."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
|
|
||||||
text = r.stdout or ""
|
|
||||||
truncated = len(text) > 200_000
|
|
||||||
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
|
|
||||||
|
|
||||||
|
|
||||||
def accept(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
|
|
||||||
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
key = _status_key(repo, branch)
|
|
||||||
state = (_statuses().get(key) or {}).get("state")
|
|
||||||
if state in ("laeuft", "rollback"):
|
|
||||||
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
|
|
||||||
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
|
|
||||||
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
|
|
||||||
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
|
|
||||||
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
|
|
||||||
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
|
|
||||||
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
|
|
||||||
|
|
||||||
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
|
|
||||||
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
|
|
||||||
src = REPO / "deploy" / REPOS[repo]["runner"]
|
|
||||||
if not src.is_file():
|
|
||||||
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
|
|
||||||
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
|
|
||||||
try:
|
|
||||||
shutil.copyfile(src, runner)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
|
|
||||||
|
|
||||||
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
|
|
||||||
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
|
|
||||||
r = subprocess.run(
|
|
||||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
|
||||||
"/bin/bash", str(runner), branch],
|
|
||||||
capture_output=True, text=True, timeout=20)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
|
||||||
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
|
|
||||||
return {"ok": True, "unit": unit}
|
|
||||||
|
|
||||||
|
|
||||||
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
|
|
||||||
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
|
|
||||||
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
|
|
||||||
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
|
|
||||||
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
grund = (grund or "").strip()[:400]
|
|
||||||
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
|
|
||||||
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
|
|
||||||
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
|
|
||||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
detail = "Vom Commander abgelehnt — Branch gelöscht"
|
|
||||||
if grund:
|
|
||||||
detail += f" (Grund: {grund})"
|
|
||||||
_set_status(_status_key(repo, branch), "abgelehnt", detail)
|
|
||||||
try:
|
|
||||||
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
|
|
||||||
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
wo = "im Lucy-Repo " if repo == "lucy" else ""
|
|
||||||
warum = f" Grund: {grund}" if grund else ""
|
|
||||||
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
|
|
||||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def _kandidaten_dir() -> Path:
|
|
||||||
return VAULT / "skill-kandidaten"
|
|
||||||
|
|
||||||
|
|
||||||
def list_skill_kandidaten() -> list[dict]:
|
|
||||||
d = _kandidaten_dir()
|
|
||||||
if os.name != "posix" or not d.is_dir():
|
|
||||||
return []
|
|
||||||
out = []
|
|
||||||
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
||||||
try:
|
|
||||||
text = p.read_text(encoding="utf-8", errors="replace")
|
|
||||||
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
|
|
||||||
out.append({"file": p.name, "title": first[:160],
|
|
||||||
"preview": text[:3000], "mtime": p.stat().st_mtime})
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _vault_git(args: list[str]) -> None:
|
|
||||||
try:
|
|
||||||
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _kandidat_path(fname: str) -> Path | None:
|
|
||||||
if not _KANDIDAT_RX.match(fname):
|
|
||||||
return None
|
|
||||||
p = (_kandidaten_dir() / fname).resolve()
|
|
||||||
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
|
|
||||||
return None
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def skill_accept(fname: str) -> dict:
|
|
||||||
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
|
|
||||||
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
|
|
||||||
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
|
|
||||||
p = _kandidat_path(fname)
|
|
||||||
if not p:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
|
||||||
|
|
||||||
beauftragt = _kandidaten_dir() / "beauftragt"
|
|
||||||
beauftragt.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = beauftragt / p.name
|
|
||||||
try:
|
|
||||||
content = p.read_text(encoding="utf-8", errors="replace")
|
|
||||||
p.rename(target)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
|
||||||
_vault_git(["add", "-A"])
|
|
||||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
|
|
||||||
|
|
||||||
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
|
|
||||||
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
|
|
||||||
+ content)
|
|
||||||
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
|
|
||||||
try:
|
|
||||||
order_file.write_text(order, encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
|
|
||||||
unit = f"mc2-skill-auftrag-{int(time.time())}"
|
|
||||||
r = subprocess.run(
|
|
||||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
|
||||||
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
|
|
||||||
capture_output=True, text=True, timeout=20)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
|
|
||||||
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
|
|
||||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True, "unit": unit}
|
|
||||||
|
|
||||||
|
|
||||||
def skill_reject(fname: str) -> dict:
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
|
|
||||||
p = _kandidat_path(fname)
|
|
||||||
if not p:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
|
||||||
verworfen = _kandidaten_dir() / "verworfen"
|
|
||||||
verworfen.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
p.rename(verworfen / p.name)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
|
||||||
_vault_git(["add", "-A"])
|
|
||||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
|
|
||||||
return {"ok": True}
|
|
||||||
@@ -70,9 +70,11 @@ def list_backups() -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||||
|
st = p.stat()
|
||||||
out.append({
|
out.append({
|
||||||
"snapshot": _ts(p),
|
"snapshot": _ts(p),
|
||||||
"file": p.name,
|
"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
|
return out
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
|
||||||
|
|
||||||
|
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
|
||||||
|
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
|
||||||
|
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
|
||||||
|
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
|
||||||
|
gleichbedeutend mit Root auf der Box gewesen.
|
||||||
|
|
||||||
|
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
|
||||||
|
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
|
||||||
|
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
|
||||||
|
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
|
||||||
|
|
||||||
|
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
|
||||||
|
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
|
||||||
|
erreicht — nicht, dass die Datei unlesbar wäre.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
|
||||||
|
|
||||||
|
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
|
||||||
|
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
|
||||||
|
ERLAUBT = frozenset({"hf_token"})
|
||||||
|
|
||||||
|
|
||||||
|
def _lesen() -> dict[str, str]:
|
||||||
|
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
|
||||||
|
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(PFAD.read_text(encoding="utf-8"))
|
||||||
|
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict[str, str]) -> bool:
|
||||||
|
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
|
||||||
|
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
|
||||||
|
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
|
||||||
|
try:
|
||||||
|
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = PFAD.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
|
||||||
|
tmp.replace(PFAD)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hf_token() -> str | None:
|
||||||
|
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
|
||||||
|
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
|
||||||
|
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
|
||||||
|
|
||||||
|
|
||||||
|
def setzen(schluessel: str, wert: str | None) -> bool:
|
||||||
|
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
|
||||||
|
if schluessel not in ERLAUBT:
|
||||||
|
return False
|
||||||
|
daten = _lesen()
|
||||||
|
if wert:
|
||||||
|
daten[schluessel] = wert
|
||||||
|
else:
|
||||||
|
daten.pop(schluessel, None)
|
||||||
|
return _schreiben(daten)
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict[str, bool]:
|
||||||
|
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
|
||||||
|
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
|
||||||
|
daten = _lesen()
|
||||||
|
return {
|
||||||
|
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
|
||||||
|
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
|
||||||
|
"schreibbar": _schreibbar(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _schreibbar() -> bool:
|
||||||
|
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
|
||||||
|
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
|
||||||
|
try:
|
||||||
|
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return os.access(PFAD.parent, os.W_OK)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,39 +57,29 @@ def _pump_output(job: dict, stream) -> None:
|
|||||||
commit()
|
commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||||
|
"""Job-Prozess starten und mitschreiben.
|
||||||
|
|
||||||
|
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
||||||
|
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
||||||
|
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
||||||
|
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
||||||
|
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
||||||
job = JOBS[job_id]
|
job = JOBS[job_id]
|
||||||
job["state"] = "running"
|
job["state"] = "running"
|
||||||
try:
|
try:
|
||||||
actual_args = list(args)
|
|
||||||
if sudo_password is not None:
|
|
||||||
for i, arg in enumerate(actual_args):
|
|
||||||
if isinstance(arg, str):
|
|
||||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
stdin=subprocess.DEVNULL,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
env={**os.environ, **(env or {})},
|
env={**os.environ, **(env or {})},
|
||||||
)
|
)
|
||||||
_PROCS[job_id] = proc
|
_PROCS[job_id] = proc
|
||||||
|
|
||||||
if sudo_password is not None and proc.stdin:
|
|
||||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
|
||||||
proc.stdin.flush()
|
|
||||||
proc.stdin.close()
|
|
||||||
|
|
||||||
_pump_output(job, proc.stdout)
|
_pump_output(job, proc.stdout)
|
||||||
proc.wait()
|
proc.wait()
|
||||||
job["returncode"] = proc.returncode
|
job["returncode"] = proc.returncode
|
||||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||||
|
|
||||||
# Check if failed due to sudo authorization failure
|
|
||||||
if proc.returncode != 0 and job["log"]:
|
|
||||||
log_str = "\n".join(job["log"])
|
|
||||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
|
||||||
job["sudo_failed"] = True
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_append_log(job, f"[mc] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
group: str | None = None) -> str:
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
# Mask password in log if present in args
|
|
||||||
log_args = list(args)
|
log_args = list(args)
|
||||||
JOBS[job_id] = {
|
JOBS[job_id] = {
|
||||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||||
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
|
|||||||
}
|
}
|
||||||
if on_done:
|
if on_done:
|
||||||
JOBS[job_id]["_on_done"] = on_done
|
JOBS[job_id]["_on_done"] = on_done
|
||||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -474,6 +474,21 @@ def set_role(model_id: str, role: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def add_role(model_id: str, role: str) -> bool:
|
||||||
|
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||||
|
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||||
|
cfg = read_config()
|
||||||
|
models = cfg.get("models") or {}
|
||||||
|
rolle = (role or "").strip().lower()
|
||||||
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||||
|
return False
|
||||||
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||||
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||||
|
models[model_id]["aliases"] = bisher + [rolle]
|
||||||
|
write_config(cfg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
@@ -19,8 +20,11 @@ from services import catalog, discover, jobengine, llamaswap, system
|
|||||||
|
|
||||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||||
SYSTEM_SERVICES = {"llama-swap"}
|
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",
|
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||||
"hermes-gateway", "hermes-builtin-ui", "voice-service"}
|
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||||
|
"projekte-sync", "mc2-backup", "mc2-radar"}
|
||||||
|
|
||||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
@@ -510,6 +514,16 @@ def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
|||||||
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_version(path: str) -> str | None:
|
||||||
|
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
|
||||||
|
try:
|
||||||
|
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
def hermes_update_details() -> dict:
|
def hermes_update_details() -> dict:
|
||||||
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
||||||
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
||||||
@@ -533,6 +547,7 @@ def hermes_update_details() -> dict:
|
|||||||
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
||||||
info["commits"] = commits
|
info["commits"] = commits
|
||||||
info["behind"] = len(commits)
|
info["behind"] = len(commits)
|
||||||
|
info["installed_version"] = _hermes_version(path)
|
||||||
if commits:
|
if commits:
|
||||||
info.update(_summarize_hermes_commits(commits))
|
info.update(_summarize_hermes_commits(commits))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -546,59 +561,59 @@ def update_details(kind: str) -> dict:
|
|||||||
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
def _run(cmd: list[str]) -> dict:
|
||||||
actual_cmd = list(cmd)
|
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
|
||||||
has_sudo = False
|
|
||||||
|
|
||||||
|
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
|
||||||
|
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
|
||||||
|
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
|
||||||
|
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
|
||||||
|
ganze Pfad war also totes Risiko ohne Gegenwert.
|
||||||
|
|
||||||
|
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
|
||||||
|
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
|
||||||
|
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
|
||||||
|
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
|
||||||
|
actual_cmd = list(cmd)
|
||||||
if cmd and cmd[0] == "sudo":
|
if cmd and cmd[0] == "sudo":
|
||||||
has_sudo = True
|
|
||||||
# If we have a password, use -S instead of -n
|
|
||||||
if sudo_password is not None:
|
|
||||||
if "-n" in actual_cmd:
|
|
||||||
actual_cmd = [x for x in actual_cmd if x != "-n"]
|
|
||||||
if "-S" not in actual_cmd:
|
|
||||||
actual_cmd.insert(1, "-S")
|
|
||||||
else:
|
|
||||||
# Force -n to fail cleanly if password is required
|
|
||||||
if "-S" in actual_cmd:
|
if "-S" in actual_cmd:
|
||||||
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
||||||
if "-n" not in actual_cmd:
|
if "-n" not in actual_cmd:
|
||||||
actual_cmd.insert(1, "-n")
|
actual_cmd.insert(1, "-n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
|
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
|
||||||
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
|
|
||||||
|
|
||||||
err_msg = p.stderr or ""
|
err_msg = p.stderr or ""
|
||||||
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
||||||
if sudo_password is not None:
|
return {"ok": False, "status": "password_required", "out": p.stdout or "",
|
||||||
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
|
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
|
||||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
|
||||||
|
|
||||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "out": "", "err": str(exc)}
|
return {"ok": False, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
|
def check_sudo_needs_password() -> dict | None:
|
||||||
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
|
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
|
||||||
res = _run(["sudo", "true"], sudo_password=sudo_password)
|
res = _run(["sudo", "true"])
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
return res
|
return res
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def restart_service(name: str, sudo_password: str | None = None) -> dict:
|
def restart_service(name: str) -> dict:
|
||||||
if name in SYSTEM_SERVICES:
|
if name in SYSTEM_SERVICES:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
|
return _run(["sudo", "systemctl", "restart", name])
|
||||||
if name in USER_SERVICES:
|
if name in USER_SERVICES:
|
||||||
return _run(["systemctl", "--user", "restart", name])
|
return _run(["systemctl", "--user", "restart", name])
|
||||||
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
||||||
|
|
||||||
|
|
||||||
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
lines = max(1, min(lines, 1000))
|
lines = max(1, min(lines, 1000))
|
||||||
if service in USER_SERVICES:
|
if service in USER_SERVICES:
|
||||||
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
@@ -609,15 +624,14 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
|
|||||||
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
if r["ok"]:
|
if r["ok"]:
|
||||||
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
|
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
sudo_password=sudo_password)
|
|
||||||
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||||
|
|
||||||
def check_updates_job(sudo_password: str | None = None) -> dict:
|
def check_updates_job() -> dict:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
|
|
||||||
def on_done():
|
def on_done():
|
||||||
@@ -625,7 +639,7 @@ def check_updates_job(sudo_password: str | None = None) -> dict:
|
|||||||
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
||||||
|
|
||||||
cmd = "sudo apt-get update"
|
cmd = "sudo apt-get update"
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
|
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -637,10 +651,10 @@ def _maintenance_busy() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def os_update_job(sudo_password: str | None = None) -> dict:
|
def os_update_job() -> dict:
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
return busy
|
return busy
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
||||||
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
||||||
@@ -649,11 +663,11 @@ def os_update_job(sudo_password: str | None = None) -> dict:
|
|||||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||||
f"&& bash {STACK_POSTCHECK}")
|
f"&& bash {STACK_POSTCHECK}")
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
||||||
group="maintenance", sudo_password=sudo_password)
|
group="maintenance")
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
def engine_update_job() -> dict | None:
|
||||||
if not ENGINE_UPDATE_CMD:
|
if not ENGINE_UPDATE_CMD:
|
||||||
return None
|
return None
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
@@ -674,7 +688,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
|||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
def swap_update_job() -> dict | None:
|
||||||
if not SWAP_UPDATE_CMD:
|
if not SWAP_UPDATE_CMD:
|
||||||
return None
|
return None
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
@@ -718,10 +732,15 @@ def _hermes_update_cmd() -> str:
|
|||||||
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
||||||
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
||||||
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
||||||
|
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
|
||||||
|
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
|
||||||
|
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
|
||||||
|
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
|
||||||
|
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
|
||||||
return ("RC=0; "
|
return ("RC=0; "
|
||||||
f"bash {backup} || true; "
|
f"bash {backup} || true; "
|
||||||
f"systemctl --user stop hermes-builtin-ui || true; "
|
f"systemctl --user stop hermes-builtin-ui || true; "
|
||||||
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
|
f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
|
||||||
f"&& {py} -m hermes_cli.main doctor "
|
f"&& {py} -m hermes_cli.main doctor "
|
||||||
f"&& {build_cmd}; }} || RC=1; "
|
f"&& {build_cmd}; }} || RC=1; "
|
||||||
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
||||||
@@ -746,12 +765,12 @@ def hermes_update_job() -> dict:
|
|||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def update_all_job(sudo_password: str | None = None) -> dict:
|
def update_all_job() -> dict:
|
||||||
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
||||||
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
||||||
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
||||||
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
||||||
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
|
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
return busy
|
return busy
|
||||||
upd = updates()
|
upd = updates()
|
||||||
@@ -764,9 +783,10 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
|||||||
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
||||||
os_pending = (upd.get("os") or 0) > 0
|
os_pending = (upd.get("os") or 0) > 0
|
||||||
if os_pending:
|
if os_pending:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
|
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
|
||||||
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
|
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
|
||||||
|
# den Fehler ehrlich durchreichen.
|
||||||
if not parts:
|
if not parts:
|
||||||
return err
|
return err
|
||||||
os_pending = False
|
os_pending = False
|
||||||
@@ -791,12 +811,11 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
|||||||
|
|
||||||
labels = " → ".join(label for label, _ in parts)
|
labels = " → ".join(label for label, _ in parts)
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||||
group="maintenance", on_done=on_done,
|
group="maintenance", on_done=on_done)
|
||||||
sudo_password=sudo_password)
|
|
||||||
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
||||||
|
|
||||||
|
|
||||||
def reboot(sudo_password: str | None = None) -> dict:
|
def reboot() -> dict:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
return _run(["sudo", "reboot"], sudo_password=sudo_password)
|
return _run(["sudo", "reboot"])
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
|
||||||
|
|
||||||
|
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
|
||||||
|
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
|
||||||
|
zuletzt geladen wurde ("Health check passed").
|
||||||
|
|
||||||
|
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
|
||||||
|
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
|
||||||
|
bevor jemand nachsieht (gemessen am 23.09.2026).
|
||||||
|
|
||||||
|
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
|
||||||
|
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
CACHE_S = 600
|
||||||
|
TAGE = 7
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
_STANDARD_NAMEN = {
|
||||||
|
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||||
|
"::1": "Lucy und OpenChamber über MC2",
|
||||||
|
"192.168.178.28": "NerdQuiz auf Arcane",
|
||||||
|
"192.168.178.22": "NerdQuiz-Tests vom PC",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
|
||||||
|
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict = {"ts": 0.0, "daten": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _namen() -> dict[str, str]:
|
||||||
|
namen = dict(_STANDARD_NAMEN)
|
||||||
|
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
|
||||||
|
if "=" in paar:
|
||||||
|
ip, name = paar.split("=", 1)
|
||||||
|
namen[ip.strip()] = name.strip()
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
|
def _journal(seit: str) -> list[str]:
|
||||||
|
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
|
||||||
|
am Tag. Ohne systemd (Windows-Dev) leer."""
|
||||||
|
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
|
||||||
|
" | grep -E 'POST /v1/|Health check passed'")
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out.stdout.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
|
||||||
|
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
|
||||||
|
je_absender: Counter = Counter()
|
||||||
|
je_tag: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
|
||||||
|
geladen: dict[str, str] = {}
|
||||||
|
grenze_24h = jetzt - timedelta(hours=24)
|
||||||
|
for z in zeilen:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(z.split(" ", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=LOCAL_TZ)
|
||||||
|
if (m := _GELADEN.search(z)):
|
||||||
|
geladen[m.group(1)] = ts.isoformat()
|
||||||
|
continue
|
||||||
|
m = _ANFRAGE.search(z)
|
||||||
|
if not m or not m.group(2).startswith("/v1/chat/completions"):
|
||||||
|
continue
|
||||||
|
wer = namen.get(m.group(1), m.group(1))
|
||||||
|
je_absender[wer] += 1
|
||||||
|
je_tag[ts.date().isoformat()][wer] += 1
|
||||||
|
if ts >= grenze_24h:
|
||||||
|
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
|
||||||
|
return {
|
||||||
|
"tage": TAGE,
|
||||||
|
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
|
||||||
|
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
|
||||||
|
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
|
||||||
|
for h in range(24)],
|
||||||
|
"zuletzt_geladen": geladen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def nutzung() -> dict:
|
||||||
|
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
|
||||||
|
with _lock:
|
||||||
|
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
|
||||||
|
return _cache["daten"]
|
||||||
|
jetzt = datetime.now(LOCAL_TZ)
|
||||||
|
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
daten = werte_aus(_journal(seit), jetzt, _namen())
|
||||||
|
with _lock:
|
||||||
|
_cache.update(ts=time.time(), daten=daten)
|
||||||
|
return daten
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,152 +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_URL, MODELS_DIR, VOICE_SERVICE_URL
|
|
||||||
|
|
||||||
from services import announce, llamaswap
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
|
||||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
|
||||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
|
||||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
|
||||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
|
||||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
|
||||||
|
|
||||||
|
|
||||||
def _reach(url: str, path: str = "/health") -> bool:
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5.0) as c:
|
|
||||||
return c.get(f"{url}{path}").status_code < 500
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _check_engine() -> bool:
|
|
||||||
return llamaswap.engine_reachable()
|
|
||||||
|
|
||||||
|
|
||||||
def _check_brain() -> bool:
|
|
||||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
|
||||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
|
||||||
st = llamaswap.brain_status()
|
|
||||||
if st.get("ready"):
|
|
||||||
return True
|
|
||||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
|
||||||
|
|
||||||
|
|
||||||
def _check_disk() -> bool:
|
|
||||||
try:
|
|
||||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
|
||||||
except Exception:
|
|
||||||
return True # kein Messwert ≠ Alarm
|
|
||||||
|
|
||||||
|
|
||||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
|
||||||
CHECKS: dict[str, tuple] = {
|
|
||||||
"engine": (_check_engine,
|
|
||||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
|
||||||
"Die Modell-Engine ist wieder da."),
|
|
||||||
"brain": (_check_brain,
|
|
||||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
|
||||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
|
||||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"),
|
|
||||||
"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, Briefkasten und Auftragsbuch gehen gerade nicht.",
|
|
||||||
"Das Steuerpult ist wieder da.")
|
|
||||||
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
|
|
||||||
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
|
|
||||||
"Der Modell-Gateway läuft wieder.")
|
|
||||||
|
|
||||||
|
|
||||||
class _Watch:
|
|
||||||
__slots__ = ("alert_ts", "alerted", "fails")
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.fails = 0 # Fehl-Ticks in Folge
|
|
||||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
|
||||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
|
||||||
|
|
||||||
|
|
||||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_telegram(subject: str, text: str) -> None:
|
|
||||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
|
||||||
legt der Wächter selbst ab."""
|
|
||||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
|
||||||
|
|
||||||
|
|
||||||
def _tick() -> None:
|
|
||||||
now = time.time()
|
|
||||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
|
||||||
w = _watches[name]
|
|
||||||
try:
|
|
||||||
ok = bool(check())
|
|
||||||
except Exception:
|
|
||||||
ok = False
|
|
||||||
if ok:
|
|
||||||
w.fails = 0
|
|
||||||
if w.alerted:
|
|
||||||
w.alerted = False
|
|
||||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
|
||||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
|
||||||
continue
|
|
||||||
w.fails += 1
|
|
||||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
|
||||||
if due:
|
|
||||||
prefix = "" if not w.alerted else "Immer noch: "
|
|
||||||
w.alerted = True
|
|
||||||
w.alert_ts = now
|
|
||||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
|
||||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
|
||||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
|
||||||
|
|
||||||
|
|
||||||
async def sentry_loop() -> None:
|
|
||||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
|
||||||
await asyncio.sleep(START_DELAY)
|
|
||||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_tick)
|
|
||||||
except Exception:
|
|
||||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
|
||||||
await asyncio.sleep(INTERVAL)
|
|
||||||
+73
-25
@@ -8,8 +8,10 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
@@ -126,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_engine_version() -> dict:
|
def get_engine_version() -> dict:
|
||||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||||
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
|
|
||||||
|
|
||||||
|
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"}
|
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": {}}
|
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||||
@@ -197,5 +191,59 @@ def system_status() -> dict:
|
|||||||
"gpu": _gpu_sysfs(),
|
"gpu": _gpu_sysfs(),
|
||||||
"temp": _temps(),
|
"temp": _temps(),
|
||||||
"disk": disk,
|
"disk": disk,
|
||||||
|
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
|
||||||
|
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
|
||||||
|
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
|
||||||
|
"uptime_s": _uptime_s(),
|
||||||
"versions": check_versions_cached(),
|
"versions": check_versions_cached(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def metrik_punkt() -> dict:
|
||||||
|
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
|
||||||
|
|
||||||
|
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
|
||||||
|
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
|
||||||
|
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
|
||||||
|
|
||||||
|
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
|
||||||
|
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
|
||||||
|
|
||||||
|
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
|
||||||
|
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
|
||||||
|
verfaelscht nichts."""
|
||||||
|
vm = psutil.virtual_memory()
|
||||||
|
temp = _temps() or {}
|
||||||
|
gpu = _gpu_sysfs() or {}
|
||||||
|
try:
|
||||||
|
from services.token_stats import get_stats
|
||||||
|
tok = get_stats()
|
||||||
|
except Exception:
|
||||||
|
tok = {}
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||||
|
disk = du.percent
|
||||||
|
except Exception:
|
||||||
|
disk = None
|
||||||
|
return {
|
||||||
|
"cpu": psutil.cpu_percent(interval=None),
|
||||||
|
"ram": vm.percent,
|
||||||
|
"ram_used": vm.used,
|
||||||
|
"ram_total": vm.total,
|
||||||
|
"gpu": gpu.get("busy_percent"),
|
||||||
|
"disk": disk,
|
||||||
|
"temp_cpu": temp.get("cpu"),
|
||||||
|
"temp_gpu": temp.get("gpu"),
|
||||||
|
"uptime_s": _uptime_s(),
|
||||||
|
"tok_p": tok.get("prompt_tokens", 0),
|
||||||
|
"tok_c": tok.get("completion_tokens", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _uptime_s() -> int | None:
|
||||||
|
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
|
||||||
|
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
|
||||||
|
try:
|
||||||
|
return int(time.time() - psutil.boot_time())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
|
||||||
|
|
||||||
|
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
|
||||||
|
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
|
||||||
|
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
|
||||||
|
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
|
||||||
|
|
||||||
|
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
|
||||||
|
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
|
||||||
|
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
|
||||||
|
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||||
|
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
||||||
|
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
||||||
|
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
|
||||||
|
|
||||||
|
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
|
||||||
|
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
|
||||||
|
|
||||||
|
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
|
||||||
|
_BAUSTEIN_IM_TEXT = (
|
||||||
|
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
|
||||||
|
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
|
||||||
|
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
||||||
|
_EINTRAG = re.compile(
|
||||||
|
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||||
|
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
||||||
|
|
||||||
|
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
||||||
|
_UPDATE_MELDUNG = re.compile(
|
||||||
|
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
|
||||||
|
r"|^Auto-Update abgebrochen|^Sonntags-Update")
|
||||||
|
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
|
||||||
|
|
||||||
|
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
|
||||||
|
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
|
||||||
|
_EINORDNUNG = (
|
||||||
|
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
|
||||||
|
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
|
||||||
|
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
|
||||||
|
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
|
||||||
|
re.IGNORECASE)),
|
||||||
|
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
|
||||||
|
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
|
||||||
|
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
|
||||||
|
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
|
||||||
|
)
|
||||||
|
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
|
||||||
|
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
|
||||||
|
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cache: dict = {"schluessel": None, "laeufe": []}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Protokoll lesen ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def eintraege(text: str) -> list[tuple[datetime, str]]:
|
||||||
|
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
|
||||||
|
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
|
||||||
|
ergebnis: list[tuple[datetime, str]] = []
|
||||||
|
for zeile in text.splitlines():
|
||||||
|
if m := _EINTRAG.match(zeile):
|
||||||
|
try:
|
||||||
|
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
ergebnis.append((zeit, m.group(2)))
|
||||||
|
elif ergebnis:
|
||||||
|
zeit, bisher = ergebnis[-1]
|
||||||
|
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _einordnen(text: str) -> str:
|
||||||
|
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
|
||||||
|
|
||||||
|
|
||||||
|
def _baustein_aus(text: str) -> str:
|
||||||
|
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
|
||||||
|
|
||||||
|
|
||||||
|
def _lesbar(text: str) -> str:
|
||||||
|
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
|
||||||
|
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
|
||||||
|
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
|
||||||
|
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
|
||||||
|
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
|
||||||
|
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
||||||
|
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
|
||||||
|
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(baustein: str, text: str) -> dict:
|
||||||
|
ergebnis = _einordnen(text)
|
||||||
|
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
|
||||||
|
|
||||||
|
|
||||||
|
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
||||||
|
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
|
||||||
|
zeilen: list[dict] = []
|
||||||
|
if zusammenfassung:
|
||||||
|
for z in zusammenfassung.splitlines()[1:]:
|
||||||
|
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
|
||||||
|
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
|
||||||
|
for _, text in gruppe:
|
||||||
|
erste = text.splitlines()[0] if text else ""
|
||||||
|
if _NEUSTART.search(erste):
|
||||||
|
zeilen.append(_zeile("Neustart", erste))
|
||||||
|
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
|
||||||
|
zeilen.append(_zeile("Update-Lauf", erste))
|
||||||
|
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
|
||||||
|
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
|
||||||
|
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
|
||||||
|
|
||||||
|
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
|
||||||
|
arten = {z["ergebnis"] for z in zeilen}
|
||||||
|
if stufe == "rot":
|
||||||
|
titel = "Fehler"
|
||||||
|
elif stufe == "gelb":
|
||||||
|
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
|
||||||
|
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
|
||||||
|
elif stufe == "gruen":
|
||||||
|
titel = "Eingespielt"
|
||||||
|
elif stufe == "info":
|
||||||
|
titel = "Neu gestartet"
|
||||||
|
else:
|
||||||
|
titel = "Alles aktuell"
|
||||||
|
start, ende = gruppe[0][0], gruppe[-1][0]
|
||||||
|
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
|
||||||
|
return {"start": start.isoformat(), "ende": ende.isoformat(),
|
||||||
|
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
|
||||||
|
"stufe": stufe, "titel": titel, "zeilen": zeilen}
|
||||||
|
|
||||||
|
|
||||||
|
def laeufe_aus(text: str) -> list[dict]:
|
||||||
|
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
|
||||||
|
relevant = [(z, t) for z, t in eintraege(text)
|
||||||
|
if _ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
|
||||||
|
or _NEUSTART.search(t.splitlines()[0] if t else "")]
|
||||||
|
gruppen: list[list[tuple[datetime, str]]] = []
|
||||||
|
for eintrag in relevant:
|
||||||
|
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
|
||||||
|
gruppen[-1].append(eintrag)
|
||||||
|
else:
|
||||||
|
gruppen.append([eintrag])
|
||||||
|
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
|
||||||
|
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
|
||||||
|
return list(reversed(laeufe))
|
||||||
|
|
||||||
|
|
||||||
|
def _protokoll_ende() -> str:
|
||||||
|
try:
|
||||||
|
with NOTIFY_LOG.open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
groesse = f.tell()
|
||||||
|
f.seek(max(0, groesse - LESE_MAX))
|
||||||
|
roh = f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
text = roh.decode("utf-8", errors="replace")
|
||||||
|
if groesse > LESE_MAX:
|
||||||
|
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def laeufe(anzahl: int = 8) -> list[dict]:
|
||||||
|
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
||||||
|
try:
|
||||||
|
st = NOTIFY_LOG.stat()
|
||||||
|
schluessel = (st.st_mtime_ns, st.st_size)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
with _cache_lock:
|
||||||
|
if _cache["schluessel"] != schluessel:
|
||||||
|
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
||||||
|
_cache["schluessel"] = schluessel
|
||||||
|
return _cache["laeufe"][:anzahl]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
|
||||||
|
|
||||||
|
def _pins_lesen() -> dict:
|
||||||
|
try:
|
||||||
|
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def festgehalten() -> list[dict]:
|
||||||
|
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
|
||||||
|
ergebnis = []
|
||||||
|
for baustein, eintrag in _pins_lesen().items():
|
||||||
|
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
|
||||||
|
continue
|
||||||
|
datum = str(eintrag.get("datum") or "")
|
||||||
|
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
|
||||||
|
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
|
||||||
|
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
|
||||||
|
"version": str(eintrag.get("version") or "?"), "seit": seit,
|
||||||
|
"grund": _lesbar(str(eintrag.get("grund") or ""))})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def freigeben(baustein: str) -> bool:
|
||||||
|
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
|
||||||
|
daten = _pins_lesen()
|
||||||
|
if baustein not in daten:
|
||||||
|
return False
|
||||||
|
daten.pop(baustein)
|
||||||
|
tmp = PINS_FILE.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, PINS_FILE)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""
|
||||||
|
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
|
||||||
|
|
||||||
|
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
|
||||||
|
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
|
||||||
|
gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||||
|
|
||||||
|
• Dienste systemd-Zustand der Kern-Dienste (System + User)
|
||||||
|
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
|
||||||
|
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||||
|
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||||
|
• Platte Füllstand des Modell-Laufwerks
|
||||||
|
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
|
||||||
|
|
||||||
|
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
|
||||||
|
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
|
||||||
|
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
|
||||||
|
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
|
||||||
|
zusätzlich an Telegram und in Lucys Briefkasten.
|
||||||
|
|
||||||
|
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
|
||||||
|
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
|
||||||
|
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
|
||||||
|
|
||||||
|
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
|
||||||
|
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
|
||||||
|
zum Ablauf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import psutil
|
||||||
|
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
|
||||||
|
|
||||||
|
from services import announce, llamaswap, maintenance, update_verlauf
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
||||||
|
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
||||||
|
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
|
||||||
|
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
|
||||||
|
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
|
||||||
|
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
|
||||||
|
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
||||||
|
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
||||||
|
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
||||||
|
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
|
||||||
|
VERLAUF_MAX = 200
|
||||||
|
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
||||||
|
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
||||||
|
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
|
||||||
|
|
||||||
|
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
|
||||||
|
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
|
||||||
|
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||||
|
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||||
|
|
||||||
|
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
|
||||||
|
DIENSTE: dict[str, tuple[bool, bool, str]] = {
|
||||||
|
"llama-swap": (True, True, "Motor (llama-swap)"),
|
||||||
|
"hermes-gateway": (False, True, "Hermes"),
|
||||||
|
"mc2-gateway": (False, True, "Modell-Gateway"),
|
||||||
|
"mission-control-2": (False, True, "MC2"),
|
||||||
|
"voice-service": (False, False, "Hör-Dienst"),
|
||||||
|
"lucy-stimme": (False, False, "Lucys Stimme"),
|
||||||
|
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
|
||||||
|
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||||
|
"projekte-sync": ("Projekte-Abgleich", False),
|
||||||
|
"mc2-backup": ("Sicherung", True),
|
||||||
|
"mc2-radar": ("Modell-Radar", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||||
|
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
|
||||||
|
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
|
||||||
|
|
||||||
|
# Prozesse, an denen ein laufendes Update zu erkennen ist.
|
||||||
|
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Befund:
|
||||||
|
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
|
||||||
|
id: str
|
||||||
|
stufe: str # "rot" | "gelb"
|
||||||
|
titel: str
|
||||||
|
text: str
|
||||||
|
quelle: str
|
||||||
|
aktionen: list[dict] = field(default_factory=list)
|
||||||
|
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
||||||
|
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
||||||
|
return {"id": aid, "label": label, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rohdaten -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
|
||||||
|
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
|
||||||
|
cmd = ["systemctl"] if system else ["systemctl", "--user"]
|
||||||
|
cmd += ["show", name, "-p",
|
||||||
|
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp"]
|
||||||
|
try:
|
||||||
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
|
||||||
|
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
|
||||||
|
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
|
||||||
|
try:
|
||||||
|
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return waehle_fehlerzeile(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
def waehle_fehlerzeile(zeilen: list[str]) -> str:
|
||||||
|
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
|
||||||
|
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
|
||||||
|
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
|
||||||
|
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
|
||||||
|
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
|
||||||
|
stufen = (
|
||||||
|
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
|
||||||
|
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
for muster in stufen:
|
||||||
|
for zeile in reversed(zeilen):
|
||||||
|
if muster.search(zeile) and not rahmen.search(zeile):
|
||||||
|
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
|
||||||
|
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=5.0) as c:
|
||||||
|
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _update_laeuft() -> bool:
|
||||||
|
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
|
||||||
|
try:
|
||||||
|
for p in psutil.process_iter(["cmdline"]):
|
||||||
|
cmd = " ".join(p.info.get("cmdline") or [])
|
||||||
|
if cmd and _UPDATE_PROZESSE.search(cmd):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prüfungen ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pruefe_dienste() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for name, (system, wichtig, anzeige) in DIENSTE.items():
|
||||||
|
z = _systemctl_show(name, system)
|
||||||
|
if not z or z.get("LoadState") in ("not-found", ""):
|
||||||
|
continue # nicht installiert oder kein systemd (Dev)
|
||||||
|
zustand = z.get("ActiveState", "")
|
||||||
|
if zustand in ("active", "activating", "reloading", "deactivating"):
|
||||||
|
continue
|
||||||
|
stufe = "rot" if wichtig else "gelb"
|
||||||
|
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
|
||||||
|
if zustand == "failed":
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
|
||||||
|
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
|
||||||
|
quelle=name, aktionen=aktionen, auto="neustart"))
|
||||||
|
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
|
||||||
|
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
|
||||||
|
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_timer_dienste() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
|
||||||
|
z = _systemctl_show(name, False)
|
||||||
|
if not z or z.get("LoadState") in ("not-found", ""):
|
||||||
|
continue
|
||||||
|
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||||
|
continue
|
||||||
|
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||||
|
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
|
||||||
|
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
|
||||||
|
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
|
||||||
|
sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_jobs() -> list[dict]:
|
||||||
|
try:
|
||||||
|
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
|
||||||
|
if isinstance(jobs, dict):
|
||||||
|
jobs = list(jobs.values())
|
||||||
|
return [j for j in jobs if isinstance(j, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _letzter_lauf(job_id: str) -> dict | None:
|
||||||
|
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
|
||||||
|
if not HERMES_EXEC_DB.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
row = con.execute(
|
||||||
|
"select status, started_at, finished_at, error from executions "
|
||||||
|
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
|
||||||
|
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
|
||||||
|
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
|
||||||
|
try:
|
||||||
|
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0, ""
|
||||||
|
marke = f"[cron_{job_id}_{tag}_"
|
||||||
|
treffer = [z for z in zeilen if marke in z and "returned error" in z]
|
||||||
|
if not treffer:
|
||||||
|
return 0, ""
|
||||||
|
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
|
||||||
|
beispiel = ""
|
||||||
|
if m:
|
||||||
|
beispiel = f"{m.group(1)}: {m.group(2)}"
|
||||||
|
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
|
||||||
|
beispiel = f"{m.group(1)}: {fm.group(1)}"
|
||||||
|
return len(treffer), beispiel[:220]
|
||||||
|
|
||||||
|
|
||||||
|
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
|
||||||
|
try:
|
||||||
|
with HERMES_ERRORS_LOG.open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
f.seek(max(0, f.tell() - max_bytes))
|
||||||
|
return f.read().decode("utf-8", "replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_hermes_jobs() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
jobs = _hermes_jobs()
|
||||||
|
if not jobs:
|
||||||
|
return befunde
|
||||||
|
log_zeilen = _errors_log_ende()
|
||||||
|
jetzt = time.time()
|
||||||
|
for job in jobs:
|
||||||
|
if not job.get("enabled", True):
|
||||||
|
continue
|
||||||
|
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
|
||||||
|
wichtig = "update" in name.lower()
|
||||||
|
status = job.get("last_status")
|
||||||
|
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
|
||||||
|
titel=f"Job „{name}“ ist fehlgeschlagen",
|
||||||
|
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
|
||||||
|
quelle=f"hermes-cron:{jid}",
|
||||||
|
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
|
||||||
|
_aktion("protokoll", "Protokoll", job=jid)],
|
||||||
|
sofort=True))
|
||||||
|
continue
|
||||||
|
if job.get("no_agent"):
|
||||||
|
continue # Skript-Jobs haben keine Werkzeuge
|
||||||
|
lauf = _letzter_lauf(jid)
|
||||||
|
if not lauf or not lauf.get("started_at"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if alter > 26 * 3600:
|
||||||
|
continue # nur der aktuelle Lauf zählt
|
||||||
|
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
|
||||||
|
if anzahl:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"job:{jid}:werkzeug", stufe="gelb",
|
||||||
|
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
|
||||||
|
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
|
||||||
|
quelle=f"hermes-cron:{jid}",
|
||||||
|
aktionen=[_aktion("protokoll", "Protokoll", job=jid)],
|
||||||
|
sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_kopf() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_kern() -> list[Befund]:
|
||||||
|
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
|
||||||
|
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
|
||||||
|
engine_ok = llamaswap.engine_reachable()
|
||||||
|
proben.append(("kern:engine", "Der Motor antwortet nicht",
|
||||||
|
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
|
||||||
|
if engine_ok:
|
||||||
|
st = llamaswap.brain_status()
|
||||||
|
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
|
||||||
|
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
|
||||||
|
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok))
|
||||||
|
proben.append(("kern:hermes", "Hermes antwortet nicht",
|
||||||
|
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
|
||||||
|
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
|
||||||
|
proben.append(("kern:hoeren", "Der Hör-Dienst antwortet nicht",
|
||||||
|
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
|
||||||
|
if WATCH_MC2:
|
||||||
|
proben.append(("kern:mc2", "MC2 antwortet nicht",
|
||||||
|
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
|
||||||
|
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
|
||||||
|
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
|
||||||
|
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
|
||||||
|
for (i, t, x, ok) in proben if not ok]
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_platte() -> list[Befund]:
|
||||||
|
try:
|
||||||
|
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if pct >= DISK_ROT_PCT:
|
||||||
|
stufe = "rot"
|
||||||
|
elif pct >= DISK_GELB_PCT:
|
||||||
|
stufe = "gelb"
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||||
|
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||||
|
quelle="platte", sofort=True)]
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_festgehalten() -> list[Befund]:
|
||||||
|
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
|
||||||
|
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
|
||||||
|
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
|
||||||
|
titel=f"{p['name']} bekommt keine Updates mehr",
|
||||||
|
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
|
||||||
|
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
|
||||||
|
quelle="updates", sofort=True,
|
||||||
|
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
|
||||||
|
for p in update_verlauf.festgehalten()]
|
||||||
|
|
||||||
|
|
||||||
|
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||||
|
pruefe_festgehalten)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _lade() -> None:
|
||||||
|
global _stand
|
||||||
|
try:
|
||||||
|
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(daten.get("hinweise"), dict):
|
||||||
|
_stand = {**_stand, **daten}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _speichere() -> None:
|
||||||
|
try:
|
||||||
|
tmp = STORE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(STORE_PATH)
|
||||||
|
except OSError:
|
||||||
|
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
|
||||||
|
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
|
||||||
|
del _stand["verlauf"][:-VERLAUF_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram(betreff: str, text: str) -> None:
|
||||||
|
if TROCKEN:
|
||||||
|
log.info("waechter (trocken): würde melden %s %s", betreff, text)
|
||||||
|
return
|
||||||
|
announce.add(text, subject=betreff, source="waechter")
|
||||||
|
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
|
||||||
|
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
|
||||||
|
_stand["auto"][schluessel] = versuche
|
||||||
|
return len(versuche) < AUTO_MAX_PRO_STUNDE
|
||||||
|
|
||||||
|
|
||||||
|
def _selbst_beheben(b: Befund, jetzt: float) -> None:
|
||||||
|
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
|
||||||
|
return
|
||||||
|
if TROCKEN:
|
||||||
|
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
|
||||||
|
_stand["auto"][b.id].append(jetzt)
|
||||||
|
return
|
||||||
|
ergebnis = maintenance.restart_service(b.quelle)
|
||||||
|
_stand["auto"][b.id].append(jetzt)
|
||||||
|
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
|
||||||
|
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
|
||||||
|
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
|
def takt() -> None:
|
||||||
|
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
|
||||||
|
jetzt = time.time()
|
||||||
|
update = _update_laeuft()
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for pruefung in PRUEFUNGEN:
|
||||||
|
if update and pruefung in (pruefe_dienste, pruefe_kern):
|
||||||
|
continue # während eines Updates sind Neustarts normal
|
||||||
|
try:
|
||||||
|
befunde += pruefung()
|
||||||
|
except Exception:
|
||||||
|
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
hinweise: dict = _stand["hinweise"]
|
||||||
|
kandidaten: dict = _stand["kandidaten"]
|
||||||
|
aktuell = {b.id for b in befunde}
|
||||||
|
|
||||||
|
for b in befunde:
|
||||||
|
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
|
||||||
|
k["takte"] += 1
|
||||||
|
if not update and b.auto:
|
||||||
|
_selbst_beheben(b, jetzt)
|
||||||
|
if not (b.sofort or k["takte"] >= FAIL_AFTER):
|
||||||
|
continue
|
||||||
|
h = hinweise.get(b.id)
|
||||||
|
neu = h is None
|
||||||
|
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
|
||||||
|
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
|
||||||
|
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
|
||||||
|
hinweise[b.id] = h
|
||||||
|
if neu:
|
||||||
|
_verlauf("neu", b.id, b.titel)
|
||||||
|
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
||||||
|
vorsatz = "" if neu else "Immer noch: "
|
||||||
|
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
|
||||||
|
h["gemeldet"] = jetzt
|
||||||
|
|
||||||
|
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||||
|
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
|
||||||
|
for hid in list(hinweise):
|
||||||
|
if hid in aktuell:
|
||||||
|
continue
|
||||||
|
if update and hid.startswith(("dienst:", "kern:")):
|
||||||
|
continue
|
||||||
|
h = hinweise.pop(hid)
|
||||||
|
_verlauf("erledigt", hid, h.get("titel", hid))
|
||||||
|
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
||||||
|
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
|
||||||
|
for kid in list(kandidaten):
|
||||||
|
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
||||||
|
kandidaten.pop(kid)
|
||||||
|
|
||||||
|
_stand["stand"] = jetzt
|
||||||
|
_stand["update_laeuft"] = update
|
||||||
|
_speichere()
|
||||||
|
|
||||||
|
|
||||||
|
async def waechter_loop() -> None:
|
||||||
|
"""Endlos-Schleife im mc2-steward."""
|
||||||
|
_lade()
|
||||||
|
await asyncio.sleep(START_DELAY)
|
||||||
|
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
|
||||||
|
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(takt)
|
||||||
|
except Exception:
|
||||||
|
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
|
||||||
|
await asyncio.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
|
||||||
|
|
||||||
|
def lese_stand() -> dict:
|
||||||
|
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = {}
|
||||||
|
hinweise = sorted((daten.get("hinweise") or {}).values(),
|
||||||
|
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
|
||||||
|
for h in hinweise:
|
||||||
|
h.pop("gemeldet", None)
|
||||||
|
return {
|
||||||
|
"hinweise": hinweise,
|
||||||
|
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
|
||||||
|
"stand": daten.get("stand"),
|
||||||
|
"update_laeuft": bool(daten.get("update_laeuft")),
|
||||||
|
"aktiv": bool(daten),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_protokoll(job_id: str) -> dict:
|
||||||
|
marke = f"[cron_{job_id}_"
|
||||||
|
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
|
||||||
|
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
|
||||||
|
|
||||||
|
|
||||||
|
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
|
||||||
|
stand = lese_stand()
|
||||||
|
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
|
||||||
|
if hinweis is None:
|
||||||
|
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
|
||||||
|
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
|
||||||
|
if aktion is None:
|
||||||
|
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
|
||||||
|
if aktion_id == "neustart":
|
||||||
|
return maintenance.restart_service(aktion["dienst"])
|
||||||
|
if aktion_id == "protokoll":
|
||||||
|
if aktion.get("job"):
|
||||||
|
return _job_protokoll(aktion["job"])
|
||||||
|
return maintenance.logs(aktion["dienst"], lines=120)
|
||||||
|
if aktion_id == "freigeben":
|
||||||
|
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||||
|
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||||
|
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||||
|
if aktion_id == "job-wiederholen":
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
return {"ok": r.returncode == 0,
|
||||||
|
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
|
||||||
|
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
|
||||||
|
|
||||||
|
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
|
||||||
|
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
|
||||||
|
Anfragen). Alles nur lesend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from services import modell_nutzung, waechter
|
||||||
|
|
||||||
|
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar"}
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(wert) -> datetime | None:
|
||||||
|
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
|
||||||
|
if not wert:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _timer() -> list[dict]:
|
||||||
|
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
|
||||||
|
capture_output=True, text=True, timeout=10).stdout
|
||||||
|
daten = json.loads(out or "[]")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
ergebnis = []
|
||||||
|
for t in daten if isinstance(daten, list) else []:
|
||||||
|
name = TIMER.get(t.get("unit", ""))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
def _us(v):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
|
||||||
|
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
|
||||||
|
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
|
||||||
|
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
|
||||||
|
if not anfragen:
|
||||||
|
return None
|
||||||
|
erste = next((s["stunde"] for s in stunden
|
||||||
|
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
|
||||||
|
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
|
||||||
|
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
|
||||||
|
"status": "erledigt"}
|
||||||
|
|
||||||
|
|
||||||
|
def flugplan() -> dict:
|
||||||
|
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
|
||||||
|
jetzt = datetime.now(LOCAL_TZ)
|
||||||
|
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
gelaufen: list[dict] = []
|
||||||
|
geplant: list[dict] = []
|
||||||
|
|
||||||
|
for job in waechter._hermes_jobs():
|
||||||
|
if not job.get("enabled", True):
|
||||||
|
continue
|
||||||
|
name = str(job.get("name", "Job"))
|
||||||
|
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
|
||||||
|
if letzter and letzter >= heute_start:
|
||||||
|
ok = job.get("last_status") in (None, "ok", "success")
|
||||||
|
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
|
||||||
|
"status": "erledigt" if ok else "fehler"})
|
||||||
|
if naechster and naechster > jetzt:
|
||||||
|
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
|
||||||
|
|
||||||
|
for t in _timer():
|
||||||
|
if t["letzter"] and t["letzter"] >= heute_start:
|
||||||
|
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
|
||||||
|
if t["naechster"] and t["naechster"] > jetzt:
|
||||||
|
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
|
||||||
|
|
||||||
|
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
|
||||||
|
gelaufen.append(nq)
|
||||||
|
|
||||||
|
def _aus(eintraege: list[dict]) -> list[dict]:
|
||||||
|
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
|
||||||
|
|
||||||
|
grenze = jetzt + timedelta(days=8)
|
||||||
|
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
|
||||||
+8
-7
@@ -1,15 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
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
|
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
|
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
|
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
|
||||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
Mini-Dienst (mc2-steward.service, Restart=always):
|
||||||
|
|
||||||
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
||||||
In-Process-Nudge aus llamaswap.write_config)
|
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
|
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
|
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
||||||
@@ -30,7 +31,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH
|
||||||
from services import sentry, warmer
|
from services import waechter, warmer
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
@@ -65,8 +66,8 @@ async def main() -> None:
|
|||||||
tasks.append(asyncio.create_task(config_watch()))
|
tasks.append(asyncio.create_task(config_watch()))
|
||||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
||||||
warmer.INTERVAL, CONFIG_WATCH_S)
|
warmer.INTERVAL, CONFIG_WATCH_S)
|
||||||
if sentry.ENABLED:
|
if waechter.ENABLED:
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
tasks.append(asyncio.create_task(waechter.waechter_loop()))
|
||||||
if not tasks:
|
if not tasks:
|
||||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
|
||||||
|
|
||||||
|
from routers import hermes_ui
|
||||||
|
|
||||||
|
|
||||||
|
def test_umleitung_auf_login_bekommt_praefix():
|
||||||
|
assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
|
||||||
|
assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_oder_schon_praefixierte_ziele_bleiben():
|
||||||
|
assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
|
||||||
|
assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
|
||||||
|
assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anmeldeseite_schickt_an_praefixierten_pfad():
|
||||||
|
html = (b'<html><head><title>Login</title></head><body>'
|
||||||
|
b'<form action="/auth/password-login" method="post"></form>'
|
||||||
|
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
|
||||||
|
b'</body></html>')
|
||||||
|
neu = hermes_ui.praefixiere_html(html)
|
||||||
|
assert b'action="/hermes-ui/auth/password-login"' in neu
|
||||||
|
assert b'fetch("/hermes-ui/auth/password-login"' in neu
|
||||||
|
assert b'"/hermes-ui/login?x=1"' in neu
|
||||||
|
assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
|
||||||
|
|
||||||
|
|
||||||
|
def test_andere_absolute_pfade_bleiben_unberuehrt():
|
||||||
|
html = b'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
|
||||||
|
neu = hermes_ui.praefixiere_html(html)
|
||||||
|
assert b'href="/assets/x.js"' in neu
|
||||||
|
assert b'href="/authors"' in neu
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from services import modell_nutzung
|
||||||
|
|
||||||
|
ZEILEN = [
|
||||||
|
('2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s'),
|
||||||
|
('2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s'),
|
||||||
|
('2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s'),
|
||||||
|
('2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||||
|
'"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms'),
|
||||||
|
"2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] <Qwen3.8-27B> Health check passed on http://localhost:5805/health",
|
||||||
|
('2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s'),
|
||||||
|
]
|
||||||
|
|
||||||
|
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehlt_nur_chat_anfragen_je_absender():
|
||||||
|
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
assert d["absender"] == [
|
||||||
|
{"name": "NerdQuiz auf Arcane", "anfragen": 3},
|
||||||
|
{"name": "Lucy und OpenChamber über MC2", "anfragen": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_letzte_24h_je_stunde_und_zuletzt_geladen():
|
||||||
|
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]}
|
||||||
|
assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb
|
||||||
|
assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1}
|
||||||
|
assert len(d["letzte_24h"]) == 24
|
||||||
|
assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_ip_bleibt_als_ip_stehen():
|
||||||
|
zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s')
|
||||||
|
d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}]
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
"""Tests für das Modell-Radar: reine Logik (Zeitfenster, Wochengrenze, Filter, Speicher-Rechnung,
|
||||||
|
Zustandsübergänge) und die Urteile und Programmieraufgaben des Prüfstands — ohne Netz und ohne Box.
|
||||||
|
Zahlen wie auf der Box am 23.09.2026 (Ornith 21,7 GB + mmproj, Flash-Next UD-IQ3_XXS 82 GB)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
BERLIN = radar.LOCAL_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit(text: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(text).replace(tzinfo=BERLIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidat(kid: str, rolle: str = "hirn", status: str = "neu", quelle: str = "watchlist", rang: int = 0,
|
||||||
|
**extra) -> dict:
|
||||||
|
return {"id": kid, "name": kid, "rolle": rolle, "repo": f"org/{kid}-GGUF", "quant": "Q4_K_M", "quelle": quelle,
|
||||||
|
"rang": rang, "datei": f"{kid}-Q4_K_M.gguf", "dateien": [[f"{kid}-Q4_K_M.gguf", 100]],
|
||||||
|
"mmproj": ["mmproj-F16.gguf", 10], "draft": None, "flags": [], "groesse_gb": 21.7, "passt": True,
|
||||||
|
"eng": False, "ctx": 131072, "status": status, "begruendung": "", **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zustand(monkeypatch, tmp_path):
|
||||||
|
"""Radar-Zustand und Radar-Ordner in einem Temp-Ordner statt unter /srv/models."""
|
||||||
|
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
|
||||||
|
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
|
||||||
|
(tmp_path / "radar").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zeitfenster und Wochengrenze ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_zeitfenster_nur_von_0030_bis_0230():
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T00:29:59"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T00:30:00"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T02:29:59"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T02:30:00"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T14:00:00"))
|
||||||
|
assert radar.fenster_ende(_zeit("2026-09-29T00:45:00")) == _zeit("2026-09-29T02:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fensterende_am_umstellungstag_nie_nach_0300():
|
||||||
|
# 28.03.2027: 02:00 springt auf 03:00, 02:30 gibt es nicht. Lieber früher enden als in den NerdQuiz-Lauf.
|
||||||
|
ende = radar.fenster_ende(_zeit("2027-03-28T00:45:00"))
|
||||||
|
assert ende.timestamp() <= datetime(2027, 3, 28, 3, 0, tzinfo=BERLIN).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def test_naechster_start_ist_die_naechste_nacht():
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T00:10:00")) == _zeit("2026-09-29T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T01:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T14:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wochengrenze_ein_neuer_kandidat_pro_woche():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b")}
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 3)) == date(2026, 10, 6)
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 6)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_testet_nur_im_fenster_mit_freier_woche_und_setzt_angefangene_fort():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._ordne(stand)
|
||||||
|
assert stand["kandidaten"]["b"]["status"] == "wartet"
|
||||||
|
|
||||||
|
plan = radar.plane_test(stand, _zeit("2026-10-03T00:31:00"))
|
||||||
|
assert plan["kandidat"] is None and "06.10." in plan["grund"]
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:31:00"))["kandidat"] == "b"
|
||||||
|
assert "außerhalb" in radar.plane_test(stand, _zeit("2026-10-06T14:00:00"))["grund"]
|
||||||
|
assert "Minuten" in radar.plane_test(stand, _zeit("2026-10-06T02:15:00"))["grund"]
|
||||||
|
|
||||||
|
# angefangen (z. B. Download unterbrochen): darf in der nächsten Nacht weiter, die Woche gilt ab dem 1. Start
|
||||||
|
stand["kandidaten"]["b"]["erster_start"] = _zeit("2026-10-06T00:31:00").timestamp()
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-07T00:31:00"))["kandidat"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufender_test_blockiert_zweiten_lauf_und_zeigt_sich(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["b"] = _kandidat("b", status="wartet", erster_start=time.time())
|
||||||
|
stand["lauf"] = {"pid": os.getpid(), "kandidat": "b", "schritt": "test", "seit": time.time(),
|
||||||
|
"frist": time.time() + 3600}
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:40:00"))["grund"] == "es läuft schon ein Test"
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebersicht()["kandidaten"][0]["begruendung"].startswith("Läuft gerade seit")
|
||||||
|
stand["lauf"]["pid"] = 999_999_999 # Prozess tot → Lauf gilt als beendet
|
||||||
|
assert radar._aktiver_lauf(stand) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Namen, Filter, Dateien, Speicher -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_namen_und_staemme():
|
||||||
|
assert radar.anzeigename("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF") == "Qwen3.6-35B-A3B"
|
||||||
|
assert radar.anzeigename("unsloth/Qwen3.8-Flash-Next-GGUF") == "Qwen3.8-Flash-Next"
|
||||||
|
assert radar.stamm("Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf") == "qwen3.8-flash-next"
|
||||||
|
assert radar.stamm("Qwen3.6-35B-A3B") == radar.stamm("unsloth/Qwen3.6-35B-A3B-GGUF")
|
||||||
|
assert radar.generation("Qwen3.8-27B") == ("qwen", 3.8)
|
||||||
|
assert radar.kandidat_id("hirn", "ornith-ai/Ornith-1.5-35B-A3B-GGUF", "Q4_K_M") == \
|
||||||
|
"hirn-ornith-ai-ornith-1-5-35b-a3b-gguf-q4-k-m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entdeckung_filtert_alte_kleine_bekannte_und_dichte_hirne():
|
||||||
|
heute = {"hirn": "Qwen3.6-35B-A3B", "coder": "Qwen3.8-27B"}
|
||||||
|
bekannt = {"qwen3.6-35b-a3b", "qwen3.8-27b"}
|
||||||
|
|
||||||
|
def fund(repo, params, aktiv=None, kuratiert=False):
|
||||||
|
return {"repo": repo, "params_b": params, "caps": {"active_b": aktiv}, "curated": kuratiert}
|
||||||
|
|
||||||
|
def grund(m, rolle):
|
||||||
|
return radar.entdeckung_ungeeignet(m, rolle, bekannt, heute)
|
||||||
|
|
||||||
|
assert "Katalog" in grund(fund("unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", 30, 3, kuratiert=True), "coder")
|
||||||
|
assert "auf der Box" in grund(fund("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert "zu klein" in grund(fund("lmstudio-community/Qwen2.5-Coder-14B-Instruct-GGUF", 14), "coder")
|
||||||
|
assert "dichte" in grund(fund("bartowski/Hermes-3-Llama-3.1-70B-GGUF", 70), "hirn")
|
||||||
|
assert "ältere Generation" in grund(fund("bartowski/Qwen_Qwen3.5-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert grund(fund("unsloth/Qwen-AgentWorld-35B-A3B-GGUF", 35, 3), "hirn") is None
|
||||||
|
|
||||||
|
|
||||||
|
BAUM_FLASH = [
|
||||||
|
{"type": "directory", "path": "UD-IQ3_XXS"},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00002-of-00003.gguf", "size": 49_570_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00003-of-00003.gguf", "lfs": {"size": 32_380_000_000}},
|
||||||
|
{"path": "UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf", "size": 4_140_000_000},
|
||||||
|
{"path": "mmproj-BF16.gguf", "size": 910_000_000},
|
||||||
|
{"path": "mmproj-F16.gguf", "size": 900_000_000},
|
||||||
|
]
|
||||||
|
KOPF_FLASH = {"general.architecture": "qwen4exp", "qwen4exp.block_count": 48, "qwen4exp.attention.head_count": 24,
|
||||||
|
"qwen4exp.attention.head_count_kv": 2, "qwen4exp.attention.key_length": 256,
|
||||||
|
"qwen4exp.attention.value_length": 256, "qwen4exp.full_attention_interval": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateiauswahl_nimmt_alle_teile_des_quants_und_das_f16_mmproj():
|
||||||
|
wahl = radar.waehle_dateien(BAUM_FLASH, "UD-IQ3_XXS")
|
||||||
|
assert [p.rsplit("-", 3)[-3] for p, _ in wahl["dateien"]] == ["00001", "00002", "00003"]
|
||||||
|
assert all(p.startswith("UD-IQ3_XXS/") for p, _ in wahl["dateien"])
|
||||||
|
assert wahl["mmproj"] == ["mmproj-F16.gguf", 900_000_000]
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q4_K_M") is None
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q8_0") is None # der MTP-Kopf ist kein Modell
|
||||||
|
ornith = [{"path": "Ornith-1.5-35B-Q4_K_M.gguf", "size": 21_710_000_000},
|
||||||
|
{"path": "Ornith-1.5-35B-Q8_0.gguf", "size": 37_800_000_000},
|
||||||
|
{"path": "mmproj-Ornith-1.5-35B-BF16.gguf", "size": 900_000_000}]
|
||||||
|
wahl = radar.waehle_dateien(ornith, "Q4_K_M")
|
||||||
|
assert wahl["dateien"] == [["Ornith-1.5-35B-Q4_K_M.gguf", 21_710_000_000]]
|
||||||
|
assert wahl["mmproj"][0] == "mmproj-Ornith-1.5-35B-BF16.gguf"
|
||||||
|
|
||||||
|
|
||||||
|
def _gguf_kopf_bytes(werte: dict) -> bytes:
|
||||||
|
"""Minimaler GGUF-Kopf wie von llama.cpp: Architektur-Schlüssel, danach der Tokenizer (abgeschnitten)."""
|
||||||
|
def s(text: str) -> bytes:
|
||||||
|
return struct.pack("<Q", len(text.encode())) + text.encode()
|
||||||
|
|
||||||
|
teile = [b"GGUF", struct.pack("<I", 3), struct.pack("<Q", 0), struct.pack("<Q", len(werte) + 1)]
|
||||||
|
for k, v in werte.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
teile += [s(k), struct.pack("<I", 8), s(v)]
|
||||||
|
elif isinstance(v, list):
|
||||||
|
teile += [s(k), struct.pack("<I", 9), struct.pack("<I", 5), struct.pack("<Q", len(v)),
|
||||||
|
struct.pack(f"<{len(v)}i", *v)]
|
||||||
|
else:
|
||||||
|
teile += [s(k), struct.pack("<I", 4), struct.pack("<I", v)]
|
||||||
|
teile += [s("tokenizer.ggml.tokens"), struct.pack("<I", 9)]
|
||||||
|
return b"".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kv_cache_aus_dem_gguf_kopf_kennt_hybrid_modelle():
|
||||||
|
daten = _gguf_kopf_bytes({"general.architecture": "qwen35moe", "qwen35moe.block_count": 41,
|
||||||
|
"qwen35moe.attention.head_count": 16, "qwen35moe.attention.head_count_kv": 2,
|
||||||
|
"qwen35moe.rope.dimension_sections": [11, 11, 10, 0],
|
||||||
|
"qwen35moe.attention.key_length": 256, "qwen35moe.attention.value_length": 256,
|
||||||
|
"qwen35moe.full_attention_interval": 4})
|
||||||
|
kopf = radar.gguf_kopf_aus_bytes(daten)
|
||||||
|
assert kopf["general.architecture"] == "qwen35moe" and kopf["qwen35moe.block_count"] == 41
|
||||||
|
# 11 von 41 Schichten mit KV-Cache × 2 KV-Köpfe × (256 + 256) × 1,0625 Byte (q8_0)
|
||||||
|
assert radar.kv_je_token_gb(kopf) == pytest.approx(11 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.kv_je_token_gb(kopf) * 131072 == pytest.approx(1.57, abs=0.01)
|
||||||
|
ohne_hybrid = {k: v for k, v in kopf.items() if not k.endswith("full_attention_interval")}
|
||||||
|
assert radar.kv_je_token_gb(ohne_hybrid) == pytest.approx(41 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.gguf_kopf_aus_bytes(b"kein gguf") is None
|
||||||
|
assert radar.gguf_kopf_aus_bytes(daten[:20]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_speicher_passung_nach_der_115_gb_regel():
|
||||||
|
budget = radar.SPEICHER_GRENZE_GB - radar.WARMSET_GB
|
||||||
|
p = radar.speicher_passung("hirn", 22.6, 11968 / 1e9) # Ornith: bequem, voller Kontext
|
||||||
|
assert p["passt"] and not p["eng"] and p["ctx"] == 131072
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 13056 / 1e9, eng_markiert=True) # Flash-Next: knapp
|
||||||
|
assert p["passt"] and p["eng"] and p["bedarf_gb"] <= budget
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 52224 / 1e9) # großer KV-Cache → halber Kontext
|
||||||
|
assert p["passt"] and p["eng"] and p["ctx"] == 65536
|
||||||
|
p = radar.speicher_passung("coder", 94.0, 13056 / 1e9) # UD-IQ4_XS passt nicht
|
||||||
|
assert not p["passt"] and p["text"].startswith("Passt nicht")
|
||||||
|
p = radar.speicher_passung("hirn", 85.0, 1e-6) # Test ja, im Betrieb neben dem Coder knapp
|
||||||
|
assert p["passt"] and p["eng"] and "Im Betrieb" in p["text"]
|
||||||
|
assert radar.platte_nach_download(265, 1900, 82) == pytest.approx(18.26, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bewertung_ohne_netz(monkeypatch):
|
||||||
|
monkeypatch.setattr(radar, "_hf_baum", lambda repo: BAUM_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: KOPF_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: True)
|
||||||
|
fund = {"name": "Qwen3.8-Flash-Next", "rolle": "coder", "repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS", "eng": True, "quelle": "watchlist", "rang": 1}
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["tauglich"] and k["passt"] and k["eng"] and k["ctx"] == 131072
|
||||||
|
assert k["datei"] == "Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf"
|
||||||
|
assert k["groesse_gb"] == pytest.approx(82.9, abs=0.05)
|
||||||
|
assert k["begruendung"].startswith("Aus der Merkliste. Passt knapp")
|
||||||
|
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: False)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert not k["tauglich"] and "qwen4exp" in k["begruendung"]
|
||||||
|
|
||||||
|
# Kopf nicht lesbar: die grobe Schätzung sagt „passt nicht“ — das darf nicht zum Verwerfen führen
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: None)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["netzfehler"] and not k["tauglich"] and k["begruendung"].startswith("Speicherbedarf unklar")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merkliste_im_repo():
|
||||||
|
funde, ok = radar._merkliste()
|
||||||
|
assert ok
|
||||||
|
nach_name = {f["name"]: f for f in funde}
|
||||||
|
assert nach_name["Ornith-1.5-35B-A3B"]["rolle"] == "hirn"
|
||||||
|
assert nach_name["Qwen3.8-Flash-Next"]["quant"] == "UD-IQ3_XXS" and nach_name["Qwen3.8-Flash-Next"]["eng"] is True
|
||||||
|
k2 = radar._bewerte(nach_name["K2-Horizon-MoVA-36B-A4B"]) # übersprungen, ganz ohne Netz
|
||||||
|
assert not k2["tauglich"] and k2["begruendung"].startswith("Übersprungen") and "llama.cpp" in k2["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustandsübergänge ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_suche_uebernimmt_merkliste_und_laesst_geschichte_in_ruhe():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"alt": _kandidat("alt", status="durchgefallen", quelle="discover"),
|
||||||
|
"weg": _kandidat("weg", quelle="discover"),
|
||||||
|
"nein": _kandidat("nein", status="verworfen", verworfen_von="nutzer")}
|
||||||
|
bewertet = [
|
||||||
|
{**_kandidat("ornith", rang=0), "tauglich": True, "begruendung": "Aus der Merkliste. Passt."},
|
||||||
|
{**_kandidat("k2", rang=2), "tauglich": False, "passt": False,
|
||||||
|
"begruendung": "Übersprungen: läuft noch nicht im offiziellen llama.cpp."},
|
||||||
|
{**_kandidat("alt", quelle="discover"), "tauglich": True},
|
||||||
|
{**_kandidat("nein"), "tauglich": True},
|
||||||
|
{**_kandidat("klein", quelle="discover"), "tauglich": False, "begruendung": "Kein GGUF mit Q4_K_M im Repo."},
|
||||||
|
]
|
||||||
|
ergebnis = radar._suche_anwenden(stand, bewertet, {"watchlist": True, "discover": True}, 1000.0)
|
||||||
|
k = stand["kandidaten"]
|
||||||
|
assert k["ornith"]["status"] == "wartet" # Kopf der Warteschlange
|
||||||
|
assert k["k2"]["status"] == "verworfen" and k["k2"]["verworfen_von"] == "radar"
|
||||||
|
assert k["alt"]["status"] == "durchgefallen" # Geschichte bleibt
|
||||||
|
assert k["nein"]["status"] == "verworfen" and k["nein"]["verworfen_von"] == "nutzer"
|
||||||
|
assert "weg" not in k and "klein" not in k
|
||||||
|
assert "klein" in stand["abgelehnt"]
|
||||||
|
assert ergebnis["neu"] == ["ornith"] and stand["letzte_suche"] == 1000.0
|
||||||
|
|
||||||
|
stand["kandidaten"]["weg"] = _kandidat("weg", quelle="discover")
|
||||||
|
radar._suche_anwenden(stand, [], {"watchlist": True, "discover": False}, 2000.0) # Entdeckung offline
|
||||||
|
assert "weg" in stand["kandidaten"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestanden_bleibt_liegen_durchgefallen_wird_geloescht(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
for kid in ("gut", "schlecht"):
|
||||||
|
ordner = radar.RADAR_DIR / kid
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "m.gguf").write_bytes(b"x")
|
||||||
|
stand["kandidaten"][kid] = _kandidat(kid, status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
test = radar._abschluss(stand, "gut", "bestanden", "Bestanden: versteht Bilder (heute nicht).",
|
||||||
|
werte={"tiefe": {"tg_tps": 95.0}}, vergleich={"Bilder": {"urteil": "besser"}})
|
||||||
|
radar._abschluss(stand, "schlecht", "durchgefallen", "Durchgefallen: Werkzeuge 3/6 (heute 6/6).")
|
||||||
|
assert stand["kandidaten"]["gut"]["status"] == "bestanden" and (radar.RADAR_DIR / "gut").exists()
|
||||||
|
assert "wartet auf deine Entscheidung" in stand["kandidaten"]["gut"]["begruendung"]
|
||||||
|
assert stand["kandidaten"]["schlecht"]["status"] == "durchgefallen" and not (radar.RADAR_DIR / "schlecht").exists()
|
||||||
|
assert [t["ergebnis"] for t in stand["tests"]] == ["bestanden", "durchgefallen"]
|
||||||
|
assert set(test) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abbrueche_zaehlen_nur_mit_schuld_des_kandidaten(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "zaeh"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["zaeh"] = _kandidat("zaeh", status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Port 5899 ist belegt.", zaehlt=False)
|
||||||
|
assert stand["kandidaten"]["zaeh"]["status"] == "wartet" and not stand["kandidaten"]["zaeh"].get("versuche")
|
||||||
|
for _ in range(radar.VERSUCHE_MAX):
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Das Nachtfenster endete um 02:30, bevor der Test fertig war.")
|
||||||
|
k = stand["kandidaten"]["zaeh"]
|
||||||
|
assert k["status"] == "durchgefallen" and k["begruendung"].startswith(f"{radar.VERSUCHE_MAX}-mal abgebrochen")
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert {t["ergebnis"] for t in stand["tests"]} == {"abgebrochen"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_vergleichswerte_bleibt_der_kandidat_getestet(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["g"] = _kandidat("g", status="wartet", erster_start=1.0)
|
||||||
|
assert radar._abschluss(stand, "g", None, "", werte={"tiefe": {"tg_tps": 80.0}}) is None
|
||||||
|
assert stand["kandidaten"]["g"]["status"] == "getestet" and stand["kandidaten"]["g"]["messung"]
|
||||||
|
assert stand["tests"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verwerfen_und_uebernehmen_pruefen_den_status(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "b"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"b": _kandidat("b", status="bestanden", ordner=str(ordner)),
|
||||||
|
"u": _kandidat("u", status="uebernommen"), "n": _kandidat("n")}
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebernehmen("n")["status"] == 409 # nur bestandene
|
||||||
|
assert radar.uebernehmen("gibt-es-nicht")["status"] == 404
|
||||||
|
assert radar.verwerfen("u")["status"] == 409 # schon übernommen
|
||||||
|
antwort = radar.verwerfen("b")
|
||||||
|
assert antwort["ok"] and antwort["kandidat"]["status"] == "verworfen"
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert radar._lade()["kandidaten"]["b"]["verworfen_von"] == "nutzer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="wartet", erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._abschluss(stand, "a", "bestanden", "Bestanden: Prefill bei 13k 18 % schneller.",
|
||||||
|
zeit=_zeit("2026-09-29T01:40:00").timestamp())
|
||||||
|
radar._speichere(stand)
|
||||||
|
u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
|
||||||
|
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
|
||||||
|
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER)
|
||||||
|
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
|
||||||
|
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
|
||||||
|
assert u["letzter_test"].startswith("2026-09-29T01:40")
|
||||||
|
assert set(u["tests"][0]) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tuple[list, dict]:
|
||||||
|
"""Bestandenen Kandidaten „o“ anlegen und llama-swap/Hirn-Wechsel durch Protokoll-Attrappen ersetzen."""
|
||||||
|
modelle = zustand / "models"
|
||||||
|
modelle.mkdir()
|
||||||
|
monkeypatch.setattr(radar, "MODELS_DIR", modelle)
|
||||||
|
ordner = radar.RADAR_DIR / "o"
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "o-Q4_K_M.gguf").write_bytes(b"x")
|
||||||
|
(ordner / "mmproj-F16.gguf").write_bytes(b"x")
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
|
||||||
|
radar._speichere(stand)
|
||||||
|
aufrufe, cfg = [], {"models": {}}
|
||||||
|
|
||||||
|
def eintragen(pfad, **kw):
|
||||||
|
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"]))
|
||||||
|
cfg["models"]["o"] = {"cmd": "vorlage"}
|
||||||
|
return "o"
|
||||||
|
|
||||||
|
from services import agent
|
||||||
|
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {rolle: alt})
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "register_model", eintragen)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "write_config", lambda c: aufrufe.append(("schreiben",)))
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_role", lambda mid, r: aufrufe.append(("rolle", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True)
|
||||||
|
monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True})
|
||||||
|
monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf")
|
||||||
|
return aufrufe, cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "hirn",
|
||||||
|
{"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "ttl": 0})
|
||||||
|
(zustand / "warmset.conf").write_text('[Service]\nEnvironment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3.6-35B-A3B"\n',
|
||||||
|
encoding="utf-8")
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["modell_id"] == "o"
|
||||||
|
assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
|
||||||
|
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
|
||||||
|
befehl = str(cfg["models"]["o"]["cmd"])
|
||||||
|
assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" in befehl
|
||||||
|
assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
|
||||||
|
k = radar._lade()["kandidaten"]["o"]
|
||||||
|
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"]
|
||||||
|
# Der Steward hält danach das neue Hirn warm, nicht mehr das alte — ohne Handgriff.
|
||||||
|
assert 'MC_WARMSET=Qwen3-Embedding-0.6B o"' in (zustand / "warmset.conf").read_text(encoding="utf-8")
|
||||||
|
assert antwort["text"] is None and "Noch zu tun" not in k["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "coder",
|
||||||
|
{"name": "Qwen3.8-27B", "aliases": ["coder", "heavy"], "ttl": 5400})
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["text"] is None
|
||||||
|
assert aufrufe[2:] == [("rolle", "o", "coder"), ("dazu", "o", "heavy"), ("ttl", "o", 5400)]
|
||||||
|
assert "--parallel 1" in str(cfg["models"]["o"]["cmd"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_betriebsbefehl_uebernimmt_die_getesteten_flags():
|
||||||
|
k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"])
|
||||||
|
befehl = radar.betriebs_befehl("coder", {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf",
|
||||||
|
"draft": None}, k)
|
||||||
|
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl
|
||||||
|
assert "--mmproj /srv/models/F-GGUF/mm.gguf" in befehl and "--load-mode none" in befehl
|
||||||
|
assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512")
|
||||||
|
assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
|
||||||
|
_kandidat("h"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def ps():
|
||||||
|
return radar._pruefstand()
|
||||||
|
|
||||||
|
|
||||||
|
BILD_GUT = {"direkt": 1.0, "gefunden": ["Wort ZEBRA", "Zahl 4711"], "agentisch": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _werte(rolle: str, decode: float = 100.0, prefill: float = 13.0, werkzeuge: int = 6, code: int = 8,
|
||||||
|
bild: dict | None = None, deutsch: bool = True) -> dict:
|
||||||
|
werte = {"rolle": rolle, "tiefe": {"tg_tps": decode, "pp_s": prefill}, "werkzeuge": {"ok": werkzeuge, "von": 6},
|
||||||
|
"bild": bild}
|
||||||
|
if rolle == "hirn":
|
||||||
|
werte.update(deutsch={"ok": deutsch}, json={"ok": True})
|
||||||
|
else:
|
||||||
|
werte["code"] = {"ok": code, "von": 10}
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_mit_bildern_besteht_ohne_verschlechterung(ps):
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=95.0, bild=BILD_GUT), _werte("hirn", decode=100.8))
|
||||||
|
assert u["ergebnis"] == "bestanden"
|
||||||
|
assert u["zusammenfassung"] == "Bestanden: versteht Bilder (heute nicht)."
|
||||||
|
assert u["vergleich"]["Decode bei 13k"]["urteil"] == "gleich" # −6 % liegt in der Toleranz
|
||||||
|
assert list(u["vergleich"]) == ["Decode bei 13k", "Prefill bei 13k", "Werkzeug-Aufrufe", "Deutsch", "JSON",
|
||||||
|
"Bilder"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_faellt_bei_schwaeche_oder_ohne_vorteil_durch(ps):
|
||||||
|
basis = _werte("hirn", decode=100.8)
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", werkzeuge=5, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Werkzeuge 5/6 (heute 6/6)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=70.0, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Decode bei 13k 70 statt 100,8 t/s" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", bild=BILD_GUT), _werte("hirn", bild=BILD_GUT))
|
||||||
|
assert u["zusammenfassung"] == "Durchgefallen: kein Vorteil gegenüber dem heutigen Modell."
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=130.0), basis) # schneller, aber blind
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_coder_zaehlt_programmieraufgaben_und_bilder(ps):
|
||||||
|
basis = _werte("coder", decode=30.4, prefill=41.2, code=8, bild=BILD_GUT)
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=9, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "bestanden" and "Programmieraufgaben 9/10 statt 8/10" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=7, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Programmieraufgaben 7/10 (heute 8/10)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", code=9), basis) # der heutige Coder kann Bilder
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
REFERENZ = {
|
||||||
|
"palindrom": "def ist_palindrom(text):\n z = [c.lower() for c in text if c.isalnum()]\n return z == z[::-1]\n",
|
||||||
|
"roemisch": (
|
||||||
|
"def roemisch(zahl):\n"
|
||||||
|
" if not isinstance(zahl, int) or not 1 <= zahl <= 3999:\n"
|
||||||
|
" raise ValueError(zahl)\n"
|
||||||
|
" out = ''\n"
|
||||||
|
" for wert, zeichen in ((1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'),\n"
|
||||||
|
" (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')):\n"
|
||||||
|
" while zahl >= wert:\n"
|
||||||
|
" out += zeichen\n"
|
||||||
|
" zahl -= wert\n"
|
||||||
|
" return out\n"),
|
||||||
|
"intervalle": (
|
||||||
|
"def zusammenfassen(intervalle):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for a, b in sorted(intervalle):\n"
|
||||||
|
" if out and a <= out[-1][1]:\n"
|
||||||
|
" out[-1][1] = max(out[-1][1], b)\n"
|
||||||
|
" else:\n"
|
||||||
|
" out.append([a, b])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"flach": (
|
||||||
|
"def flach(liste):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for x in liste:\n"
|
||||||
|
" out.extend(flach(x) if isinstance(x, list) else [x])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"iban": (
|
||||||
|
"def iban_gueltig(iban):\n"
|
||||||
|
" s = iban.replace(' ', '').upper()\n"
|
||||||
|
" if not 15 <= len(s) <= 34 or not s.isalnum() or not s.isascii():\n"
|
||||||
|
" return False\n"
|
||||||
|
" return int(''.join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1\n"),
|
||||||
|
"lru": (
|
||||||
|
"from collections import OrderedDict\n"
|
||||||
|
"class LRUCache:\n"
|
||||||
|
" def __init__(self, kapazitaet):\n"
|
||||||
|
" self.k, self.d = kapazitaet, OrderedDict()\n"
|
||||||
|
" def get(self, schluessel):\n"
|
||||||
|
" if schluessel not in self.d:\n"
|
||||||
|
" return None\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" return self.d[schluessel]\n"
|
||||||
|
" def put(self, schluessel, wert):\n"
|
||||||
|
" self.d[schluessel] = wert\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" if len(self.d) > self.k:\n"
|
||||||
|
" self.d.popitem(last=False)\n"),
|
||||||
|
"klammern": (
|
||||||
|
"def klammern_ok(text):\n"
|
||||||
|
" paare, stapel = {')': '(', ']': '[', '}': '{'}, []\n"
|
||||||
|
" for c in text:\n"
|
||||||
|
" if c in '([{':\n"
|
||||||
|
" stapel.append(c)\n"
|
||||||
|
" elif c in paare and (not stapel or stapel.pop() != paare[c]):\n"
|
||||||
|
" return False\n"
|
||||||
|
" return not stapel\n"),
|
||||||
|
"wege": (
|
||||||
|
"import heapq\n"
|
||||||
|
"def kuerzeste_wege(graph, start):\n"
|
||||||
|
" dist, heap = {start: 0}, [(0, start)]\n"
|
||||||
|
" while heap:\n"
|
||||||
|
" d, u = heapq.heappop(heap)\n"
|
||||||
|
" if d > dist[u]:\n"
|
||||||
|
" continue\n"
|
||||||
|
" for v, w in graph.get(u, {}).items():\n"
|
||||||
|
" if v not in dist or d + w < dist[v]:\n"
|
||||||
|
" dist[v] = d + w\n"
|
||||||
|
" heapq.heappush(heap, (d + w, v))\n"
|
||||||
|
" return dist\n"),
|
||||||
|
"median": (
|
||||||
|
"def median(zahlen):\n"
|
||||||
|
" if not zahlen:\n"
|
||||||
|
" raise ValueError('leer')\n"
|
||||||
|
" s, n = sorted(zahlen), len(zahlen)\n"
|
||||||
|
" return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2\n"),
|
||||||
|
"csv": (
|
||||||
|
"def spaltensumme(csv_text, spalte):\n"
|
||||||
|
" zeilen = [z for z in csv_text.splitlines() if z.strip()]\n"
|
||||||
|
" kopf = zeilen[0].split(';')\n"
|
||||||
|
" if spalte not in kopf:\n"
|
||||||
|
" raise KeyError(spalte)\n"
|
||||||
|
" i, summe = kopf.index(spalte), 0.0\n"
|
||||||
|
" for z in zeilen[1:]:\n"
|
||||||
|
" zellen = z.split(';')\n"
|
||||||
|
" wert = zellen[i].strip() if i < len(zellen) else ''\n"
|
||||||
|
" if wert:\n"
|
||||||
|
" summe += float(wert.replace('.', '').replace(',', '.'))\n"
|
||||||
|
" return summe\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenzloesungen_bestehen_alle_programmieraufgaben(ps):
|
||||||
|
assert {a["id"] for a in ps.AUFGABEN_CODE} == set(REFERENZ)
|
||||||
|
for aufgabe in ps.AUFGABEN_CODE:
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus(REFERENZ[aufgabe["id"]], aufgabe["tests"])
|
||||||
|
assert ok, f"{aufgabe['id']}: {grund}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsche_verbotene_und_endlose_loesungen_fallen_durch(ps):
|
||||||
|
tests = next(a["tests"] for a in ps.AUFGABEN_CODE if a["id"] == "median")
|
||||||
|
ok, _ = ps.fuehre_aufgabe_aus("def median(z):\n z.sort()\n return z[len(z) // 2]\n", tests)
|
||||||
|
assert not ok
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("import os\ndef median(z):\n return os.getpid()\n", tests)
|
||||||
|
assert not ok and grund.startswith("unerlaubter Aufruf")
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("def median(z):\n while True:\n pass\n", tests, zeitlimit=2)
|
||||||
|
assert not ok and "Zeitlimit" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_aus_antwort_nimmt_den_loesungsblock(ps):
|
||||||
|
antwort = "<think>kurz nachdenken</think>Hier:\n```python\ndef f():\n return 1\n```\nAufruf:\n```python\nf()\n```"
|
||||||
|
assert ps.code_aus_antwort(antwort) == "def f():\n return 1\n"
|
||||||
|
assert ps.code_aus_antwort("def g(): return 2") == "def g(): return 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bild_ist_ein_gueltiges_png_mit_rotem_kreis(ps):
|
||||||
|
png = ps.bild_png()
|
||||||
|
assert png.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
breite, hoehe = struct.unpack(">II", png[16:24])
|
||||||
|
assert (breite, hoehe) == (448, 300)
|
||||||
|
laenge = struct.unpack(">I", png[33:37])[0]
|
||||||
|
roh = zlib.decompress(png[41:41 + laenge])
|
||||||
|
zeile = 1 + breite * 3
|
||||||
|
assert roh[95 * zeile + 1 + 80 * 3:95 * zeile + 1 + 81 * 3] == b"\xdc\x14\x14" # Mitte des Kreises
|
||||||
|
beschreibung = "Ein roter Kreis, ein blaues Quadrat, ein grünes Dreieck und darunter der Text ZEBRA 4711."
|
||||||
|
assert ps.bild_merkmale(beschreibung) == list(ps.BILD_MERKMALE)
|
||||||
|
assert ps.bild_data_url().startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeug_treffer_liest_json_argumente(ps):
|
||||||
|
aufruf = [{"function": {"name": "send_telegram", "arguments": json.dumps({"text": "Prüfstand läuft."})}}]
|
||||||
|
assert ps.trifft(aufruf, [("send_telegram", r"pr(ü|ue)fstand")])
|
||||||
|
assert not ps.trifft(aufruf, [("terminal", r".*")])
|
||||||
|
assert len(ps.WERKZEUGE_HIRN) >= 90 # Lucy hat ~100 Werkzeuge
|
||||||
|
assert ps.tiefen_prompt("ab12").startswith("Messlauf ab12.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_role_behaelt_die_uebrigen_aliase(monkeypatch):
|
||||||
|
"""add_role ohne Attrappe: coder bleibt, heavy wandert vom alten Coder herüber."""
|
||||||
|
from services import llamaswap
|
||||||
|
cfg = {"models": {"alt": {"cmd": "x", "aliases": ["coder", "heavy"]}, "neu": {"cmd": "y", "aliases": ["coder"]}}}
|
||||||
|
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||||
|
assert llamaswap.add_role("neu", "heavy") is True
|
||||||
|
assert cfg["models"]["neu"]["aliases"] == ["coder", "heavy"]
|
||||||
|
assert cfg["models"]["alt"]["aliases"] == ["coder"]
|
||||||
|
assert llamaswap.add_role("gibt-es-nicht", "heavy") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, monkeypatch):
|
||||||
|
"""Eine Baseline aus einem älteren Prüfstand ist nicht vergleichbar (23.09.: Ablenker geändert)."""
|
||||||
|
cache = tmp_path / "baseline.json"
|
||||||
|
cache.write_text(json.dumps({"hirn": {"kennung": "k", "version": ps.PRUEFSTAND_VERSION - 1,
|
||||||
|
"zeit": time.time(), "werte": {"alt": True}}}), encoding="utf-8")
|
||||||
|
gemessen = []
|
||||||
|
monkeypatch.setattr(ps, "pruefe", lambda *a, **kw: gemessen.append(1) or {"neu": True})
|
||||||
|
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||||
|
assert gemessen == [1]
|
||||||
|
# Gleiche Version und Kennung: aus dem Cache, keine neue Messung.
|
||||||
|
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||||
|
assert gemessen == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_ablenker_klingt_nach_plattenplatz(ps):
|
||||||
|
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
|
||||||
|
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from services import update_verlauf
|
||||||
|
|
||||||
|
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
||||||
|
PROTOKOLL = """\
|
||||||
|
2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken.
|
||||||
|
2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt.
|
||||||
|
2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026
|
||||||
|
|
||||||
|
• **Eine Nachricht** (Quelle, 21. August 2026)
|
||||||
|
Text der Nachricht.
|
||||||
|
2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün.
|
||||||
|
2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt.
|
||||||
|
2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT.
|
||||||
|
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): aktuell (253).
|
||||||
|
• Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b.
|
||||||
|
2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand.
|
||||||
|
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT.
|
||||||
|
• Hermes-Agent: gepinnt, übersprungen.
|
||||||
|
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ). Sie ist ein paar Minuten weg.
|
||||||
|
2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün.
|
||||||
|
2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): aktuell (256).
|
||||||
|
• Engine (llama.cpp): aktuell (11057).
|
||||||
|
• Hermes-Agent: aktuell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen():
|
||||||
|
laeufe = update_verlauf.laeufe_aus(PROTOKOLL)
|
||||||
|
# 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf.
|
||||||
|
assert [lauf["start"][:10] for lauf in laeufe] == [
|
||||||
|
"2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruener_sonntag_mit_einer_zeile_je_baustein():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1]
|
||||||
|
assert lauf["anlass"] == "Updates am Sonntag"
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt")
|
||||||
|
assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [
|
||||||
|
("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")]
|
||||||
|
assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün."
|
||||||
|
|
||||||
|
|
||||||
|
def test_rueckbau_und_festhalten_werden_gelb_und_lesbar():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09.
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt")
|
||||||
|
zeilen = {z["baustein"]: z for z in lauf["zeilen"]}
|
||||||
|
assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt"
|
||||||
|
assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten."
|
||||||
|
assert zeilen["Hermes"]["ergebnis"] == "festgehalten"
|
||||||
|
assert zeilen["Neustart"]["ergebnis"] == "neustart"
|
||||||
|
assert zeilen["Neustart"]["text"] == "Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 )."
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08.
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler")
|
||||||
|
assert lauf["zeilen"][0]["baustein"] == "Motor"
|
||||||
|
assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lauf_von_hand_mit_neustart_danach():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags
|
||||||
|
assert lauf["anlass"] == "Update von Hand"
|
||||||
|
assert lauf["titel"] == "Eingespielt"
|
||||||
|
assert lauf["zeilen"][-1]["baustein"] == "Neustart"
|
||||||
|
assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün."
|
||||||
|
|
||||||
|
|
||||||
|
def test_alles_aktuell_ist_grau():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")
|
||||||
|
|
||||||
|
|
||||||
|
def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
|
||||||
|
datei = tmp_path / "mc2-notify.log"
|
||||||
|
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
||||||
|
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||||
|
assert len(update_verlauf.laeufe(3)) == 3
|
||||||
|
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||||
|
assert update_verlauf.laeufe() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||||
|
pins = tmp_path / "mc2-pins.json"
|
||||||
|
pins.write_text(json.dumps({
|
||||||
|
"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||||
|
"grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"},
|
||||||
|
"hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"},
|
||||||
|
"swap": {"pinned": False},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||||
|
|
||||||
|
fest = update_verlauf.festgehalten()
|
||||||
|
assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")]
|
||||||
|
assert fest[0]["name"] == "Motor (llama.cpp)"
|
||||||
|
|
||||||
|
assert update_verlauf.freigeben("engine") is True
|
||||||
|
assert update_verlauf.freigeben("engine") is False
|
||||||
|
rest = json.loads(pins.read_text(encoding="utf-8"))
|
||||||
|
assert set(rest) == {"hermes", "swap"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
|
||||||
|
from services import waechter
|
||||||
|
|
||||||
|
pins = tmp_path / "mc2-pins.json"
|
||||||
|
pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||||
|
"grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||||
|
befunde = waechter.pruefe_festgehalten()
|
||||||
|
assert len(befunde) == 1
|
||||||
|
b = befunde[0]
|
||||||
|
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
||||||
|
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
||||||
|
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests für die reinen Auswertungen des Wächters — mit echten Zeilen von der Box (23.09.2026)."""
|
||||||
|
|
||||||
|
from services import waechter
|
||||||
|
|
||||||
|
PROJEKTE_SYNC_LOG = [
|
||||||
|
"2026-09-23 21:02:44 === Sync-Lauf (14 Repos gemeldet) ===",
|
||||||
|
"2026-09-23 21:02:45 = NerdQuiz-next: aktuell (a1b2c3d)",
|
||||||
|
"2026-09-23 21:02:46 ! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen — fatal: no such ref was fetched",
|
||||||
|
"2026-09-23 21:02:46 === Ende (Fehler: 1) ===",
|
||||||
|
]
|
||||||
|
|
||||||
|
ERRORS_LOG = [
|
||||||
|
('2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||||
|
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
|
||||||
|
'/tmp/news-text.md exists"}'),
|
||||||
|
('2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||||
|
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}'),
|
||||||
|
"2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None",
|
||||||
|
('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
|
||||||
|
'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_nimmt_die_letzte_fehlerzeile_ohne_zeitstempel():
|
||||||
|
zeile = waechter.waehle_fehlerzeile(PROJEKTE_SYNC_LOG)
|
||||||
|
assert zeile.startswith("! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen")
|
||||||
|
assert "2026-09-23" not in zeile
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_uebergeht_systemd_rahmenzeilen():
|
||||||
|
"""Echtes Journal vom 23.09.: systemd schreibt NACH der Skriptzeile noch „Failed to start …“."""
|
||||||
|
zeilen = PROJEKTE_SYNC_LOG + [
|
||||||
|
"projekte-sync.service: Main process exited, code=exited, status=1/FAILURE",
|
||||||
|
"projekte-sync.service: Failed with result 'exit-code'.",
|
||||||
|
"Failed to start projekte-sync.service - Projekte-Sync — haelt ~/projekte mit Gitea deckungsgleich.",
|
||||||
|
]
|
||||||
|
assert waechter.waehle_fehlerzeile(zeilen).startswith("! TTT2-Toolbox_ClaudeEdition")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_leer_wenn_nichts_nach_fehler_aussieht():
|
||||||
|
assert waechter.waehle_fehlerzeile(["alles gut", "=== Ende (Fehler: 0) ==="]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeugfehler_zaehlt_nur_den_lauf_des_tages():
|
||||||
|
anzahl, beispiel = waechter.werkzeugfehler_im_lauf(
|
||||||
|
ERRORS_LOG, "195e479e8e30", "2026-09-23T07:00:17.675474+02:00")
|
||||||
|
assert anzahl == 2
|
||||||
|
assert beispiel.startswith("write_file: Refusing to overwrite /tmp/news-text.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeugfehler_null_fuer_anderen_job_oder_kaputtes_datum():
|
||||||
|
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "ca7d4ed207c2", "2026-09-23T08:00:00+02:00") == (0, "")
|
||||||
|
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "195e479e8e30", "kein-datum") == (0, "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp_path):
|
||||||
|
"""Flankenlogik: ein kurzer Aussetzer (1 Takt) erzeugt keinen Hinweis, ein dauerhafter schon.
|
||||||
|
Rote Hinweise gehen an Telegram, Erledigtes verschwindet und landet im Verlauf."""
|
||||||
|
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
|
||||||
|
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||||
|
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||||
|
meldungen: list[str] = []
|
||||||
|
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff))
|
||||||
|
defekt = [True]
|
||||||
|
|
||||||
|
def pruefung():
|
||||||
|
if not defekt[0]:
|
||||||
|
return []
|
||||||
|
return [waechter.Befund(id="kern:engine", stufe="rot", titel="Der Motor antwortet nicht",
|
||||||
|
text="…", quelle="engine")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,))
|
||||||
|
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
|
||||||
|
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {} # erster Takt: nur Kandidat
|
||||||
|
waechter.takt()
|
||||||
|
assert "kern:engine" in waechter._stand["hinweise"]
|
||||||
|
assert meldungen == ["[Box-Problem]"]
|
||||||
|
|
||||||
|
defekt[0] = False
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {}
|
||||||
|
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
|
||||||
|
arten = [v["art"] for v in waechter._stand["verlauf"]]
|
||||||
|
assert arten == ["neu", "erledigt"]
|
||||||
|
|
||||||
|
stand = waechter.lese_stand()
|
||||||
|
assert stand["aktiv"] is True and stand["hinweise"] == []
|
||||||
@@ -67,6 +67,22 @@ jobs:
|
|||||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||||
|
|
||||||
|
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
|
||||||
|
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
|
||||||
|
# reicht). Gemessen wird der frische Build, nicht das committete dist.
|
||||||
|
if [ -d "$d/dist/assets" ]; then
|
||||||
|
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
|
||||||
|
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
|
||||||
|
# falschen — das Budget waere still immer gruen.
|
||||||
|
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
|
||||||
|
haupt="$d/$einstieg"
|
||||||
|
if [ -n "$haupt" ]; then
|
||||||
|
gz=$(gzip -c "$haupt" | wc -c)
|
||||||
|
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
|
||||||
|
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
done <<< "$pkg_dateien"
|
done <<< "$pkg_dateien"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
|
|
||||||
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
|
|
||||||
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
|
|
||||||
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
|
|
||||||
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
|
|
||||||
#
|
|
||||||
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
|
|
||||||
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
|
|
||||||
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
|
|
||||||
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
|
|
||||||
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
|
|
||||||
#
|
|
||||||
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
|
||||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
|
||||||
API="${MC_API:-http://127.0.0.1:9001}"
|
|
||||||
SLUG="$(echo "$BRANCH" | tr '/' '-')"
|
|
||||||
WT="/tmp/annahme-$SLUG"
|
|
||||||
LOG="/tmp/annahme-$SLUG.log"
|
|
||||||
|
|
||||||
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
|
||||||
|
|
||||||
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
|
||||||
status(){ # $1=state $2=detail
|
|
||||||
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
|
|
||||||
import json, os, sys, time
|
|
||||||
p, branch, state, detail = sys.argv[1:5]
|
|
||||||
try:
|
|
||||||
d = json.load(open(p, encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
d = {}
|
|
||||||
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
|
|
||||||
tmp = p + ".tmp"
|
|
||||||
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
|
||||||
os.replace(tmp, p)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
fail(){
|
|
||||||
status "fehlgeschlagen" "$1"
|
|
||||||
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
|
||||||
cleanup_wt
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
status "laeuft" "Merge wird vorbereitet"
|
|
||||||
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
|
|
||||||
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
|
||||||
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
|
||||||
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
|
||||||
|
|
||||||
PRE="$(git rev-parse origin/main)"
|
|
||||||
|
|
||||||
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
|
|
||||||
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
|
|
||||||
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
|
||||||
|
|
||||||
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
|
|
||||||
cleanup_wt
|
|
||||||
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
|
||||||
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
|
|
||||||
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
|
||||||
git -C "$WT" merge --abort 2>/dev/null || true
|
|
||||||
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
|
|
||||||
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
|
|
||||||
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
|
||||||
WT2="/tmp/annahme-rebase-$SLUG"
|
|
||||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
REBASED=""
|
|
||||||
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
rebase origin/main >>"$LOG" 2>&1; then
|
|
||||||
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
|
||||||
else
|
|
||||||
git -C "$WT2" rebase --abort 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
|
||||||
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
|
||||||
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
|
|
||||||
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
|
|
||||||
if [ -n "${PYS// /}" ]; then
|
|
||||||
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
|
|
||||||
fi
|
|
||||||
|
|
||||||
status "laeuft" "Push nach main läuft"
|
|
||||||
PUSHED=0
|
|
||||||
for _ in 1 2 3; do
|
|
||||||
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
|
||||||
cleanup_wt
|
|
||||||
|
|
||||||
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
|
|
||||||
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
|
|
||||||
|
|
||||||
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
|
|
||||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
|
|
||||||
DEPLOY_OK=1
|
|
||||||
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
|
|
||||||
|
|
||||||
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
|
|
||||||
HEALTH=0
|
|
||||||
if [ "$DEPLOY_OK" = 1 ]; then
|
|
||||||
for _ in $(seq 1 18); do
|
|
||||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$HEALTH" = 1 ]; then
|
|
||||||
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
|
|
||||||
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
|
||||||
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
|
|
||||||
status "eingespielt" "Deploy grün, Health-Check bestanden"
|
|
||||||
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
|
|
||||||
status "rollback" "Health rot — automatischer Revert läuft"
|
|
||||||
git fetch -q origin || true
|
|
||||||
cleanup_wt
|
|
||||||
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
|
||||||
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
|
||||||
cleanup_wt
|
|
||||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
|
|
||||||
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
|
|
||||||
R=0
|
|
||||||
for _ in $(seq 1 12); do
|
|
||||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
if [ "$R" = 1 ]; then
|
|
||||||
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
|
|
||||||
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
cleanup_wt
|
|
||||||
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
|
|
||||||
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
|
|
||||||
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|
|
||||||
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
|
|
||||||
exit 2
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
|
|
||||||
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
|
|
||||||
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
|
|
||||||
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
|
|
||||||
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
|
|
||||||
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
|
|
||||||
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
|
|
||||||
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
|
|
||||||
#
|
|
||||||
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
|
|
||||||
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
|
||||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
|
||||||
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
|
|
||||||
|
|
||||||
cd "$SRC" || exit 0
|
|
||||||
git fetch -q --prune origin 2>/dev/null || exit 0
|
|
||||||
|
|
||||||
angenommen=0
|
|
||||||
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
|
|
||||||
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
|
|
||||||
BRANCH="${REF#origin/}"
|
|
||||||
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
|
|
||||||
|
|
||||||
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
|
|
||||||
[ "${AHEAD:-0}" -gt 0 ] || continue
|
|
||||||
|
|
||||||
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
|
|
||||||
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
|
|
||||||
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
|
|
||||||
except Exception:
|
|
||||||
print("")
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
[ -z "$STATE" ] || continue
|
|
||||||
|
|
||||||
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
|
|
||||||
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
|
|
||||||
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
|
|
||||||
[ -n "$DIFF" ] || continue
|
|
||||||
OK=1
|
|
||||||
while IFS=$'\t' read -r st path _rest; do
|
|
||||||
[ -n "$st" ] || continue
|
|
||||||
case "$st" in A|M) ;; *) OK=0; break ;; esac
|
|
||||||
case "$path" in *.md) ;; *) OK=0; break ;; esac
|
|
||||||
done <<< "$DIFF"
|
|
||||||
[ "$OK" = 1 ] || continue
|
|
||||||
|
|
||||||
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
|
|
||||||
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
|
|
||||||
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
|
|
||||||
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
|
|
||||||
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
|
|
||||||
if bash "$RUNNER" "$BRANCH"; then
|
|
||||||
angenommen=$((angenommen+1))
|
|
||||||
else
|
|
||||||
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
|
|
||||||
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
|
|
||||||
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
|
|
||||||
@@ -7,7 +7,7 @@ set -u
|
|||||||
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
||||||
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||||
PORT="${BENCH_PORT:-5899}"
|
PORT="${BENCH_PORT:-5899}"
|
||||||
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
|
BASE="-c 65536 -ngl 999 -fa on --load-mode none --jinja --parallel 1"
|
||||||
|
|
||||||
run() { # $1=name $2=extra-flags
|
run() { # $1=name $2=extra-flags
|
||||||
echo "=== $1 ==="
|
echo "=== $1 ==="
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ PORT="${BENCH_PORT:-5899}"
|
|||||||
CTX="${BENCH_CTX:-32768}"
|
CTX="${BENCH_CTX:-32768}"
|
||||||
|
|
||||||
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
||||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
|
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --load-mode none --jinja $EXTRA \
|
||||||
>/tmp/model-bench-server.log 2>&1 &
|
>/tmp/model-bench-server.log 2>&1 &
|
||||||
PID=$!
|
PID=$!
|
||||||
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
|
||||||
|
|
||||||
|
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
|
||||||
|
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
|
||||||
|
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
|
||||||
|
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
|
||||||
|
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
|
||||||
|
|
||||||
|
Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36] (Pfade unten anpassen).
|
||||||
|
Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
|
||||||
|
|
||||||
|
M = "/srv/models"
|
||||||
|
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
||||||
|
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
||||||
|
NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_0.gguf"
|
||||||
|
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
||||||
|
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
||||||
|
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||||
|
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
|
||||||
|
RESULTS: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def save() -> None:
|
||||||
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
|
||||||
|
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
|
||||||
|
if with_hermes:
|
||||||
|
werte["hermes"] = ps.hermes_smoke(hermes_args)
|
||||||
|
RESULTS.append(werte)
|
||||||
|
save()
|
||||||
|
|
||||||
|
|
||||||
|
def candidate(label: str, model: str, extra: list[str], with_hermes: bool, mmproj: str | None = None) -> None:
|
||||||
|
ps.log(f"\n=== {label}")
|
||||||
|
ps.log(f" {os.path.basename(model)} {' '.join(extra)}")
|
||||||
|
try:
|
||||||
|
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
|
||||||
|
except ps.ServerFehler as e:
|
||||||
|
ps.log(f" {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:300]})
|
||||||
|
save()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
|
||||||
|
bild=bool(mmproj))
|
||||||
|
except Exception as e:
|
||||||
|
ps.log(f" FEHLER in Proben: {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:200]})
|
||||||
|
save()
|
||||||
|
finally:
|
||||||
|
ps.stoppe_server(server)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
||||||
|
if "baseline" in which:
|
||||||
|
ps.log("\n=== BASELINE fast (live, llama-swap)")
|
||||||
|
probes(ps.SWAP_URL, "fast", "fast-live", True, ["-m", "fast"])
|
||||||
|
ps.log("\n=== BASELINE coder (live, llama-swap)")
|
||||||
|
probes(ps.SWAP_URL, "coder", "coder-live", True, ["-m", "coder"], bild=True)
|
||||||
|
if "nemo" in which:
|
||||||
|
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO,
|
||||||
|
ps.draft_flags(NEMO_MTP, "draft-mtp", 3), True)
|
||||||
|
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
||||||
|
if "u38" in which:
|
||||||
|
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
|
||||||
|
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
|
||||||
|
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
|
||||||
|
if "u36" in which:
|
||||||
|
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
|
||||||
|
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
|
||||||
|
ps.log("\nPRUEFSTAND_DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,11 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||||
|
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
|
||||||
|
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
|
||||||
|
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
# 4.5 Hermes Skills synchronisieren
|
# 4.5 Hermes Skills synchronisieren
|
||||||
echo "Synchronisiere Hermes Skills..."
|
echo "Synchronisiere Hermes Skills..."
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ if [ "${1:-}" = "--nur-stimme" ]; then
|
|||||||
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
|
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
|
||||||
-o "$TON" -w '%{http_code}' 2>/dev/null)
|
-o "$TON" -w '%{http_code}' 2>/dev/null)
|
||||||
|
|
||||||
|
# Sprechfassung wegraeumen (Grund siehe Textfassung unten) — auch wenn die Stimme scheiterte.
|
||||||
|
mv -f "$SPRECHDATEI" "${SPRECHDATEI%.txt}.gesendet.txt" 2>/dev/null || true
|
||||||
|
|
||||||
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
|
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
|
||||||
|
|
||||||
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
|
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
|
||||||
@@ -57,8 +60,16 @@ fi
|
|||||||
# ------------------------------------------- Erster Durchgang: Text + Start ---
|
# ------------------------------------------- Erster Durchgang: Text + Start ---
|
||||||
TEXTDATEI="${1:-/tmp/news-text.md}"
|
TEXTDATEI="${1:-/tmp/news-text.md}"
|
||||||
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
||||||
|
GESENDET="${TEXTDATEI%.md}.gesendet.md"
|
||||||
|
|
||||||
if [ ! -s "$TEXTDATEI" ]; then
|
if [ ! -s "$TEXTDATEI" ]; then
|
||||||
|
# Zweiter Aufruf desselben Laufs, nachdem der erste den Bericht schon verschickt und
|
||||||
|
# weggeraeumt hat? Dann ist nichts zu tun (die Doppelversand-Sperre unten sieht die
|
||||||
|
# Datei nicht mehr).
|
||||||
|
if [ -s "$GESENDET" ] && [ $(( $(date +%s) - $(stat -c %Y "$GESENDET" 2>/dev/null || echo 0) )) -lt 7200 ]; then
|
||||||
|
echo "gemeldet (dieser Bericht ging bereits raus — nicht doppelt geschickt)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
|
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -78,6 +89,11 @@ find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
|
|||||||
|
|
||||||
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
|
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
|
||||||
|
|
||||||
|
# Bericht wegraeumen (23.09.2026): Hermes' write_file ueberschreibt keine vorhandene Datei.
|
||||||
|
# Blieb der Bericht vom Vortag liegen, scheiterte jeder Morgenlauf erst an
|
||||||
|
# „Refusing to overwrite /tmp/news-text.md“ (4 von 5 Versuchen, jeden Tag seit Wochen).
|
||||||
|
mv -f "$TEXTDATEI" "$GESENDET" 2>/dev/null && touch "$GESENDET" 2>/dev/null || true
|
||||||
|
|
||||||
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
|
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
|
||||||
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
|
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
|
||||||
if [ -s "$SPRECHDATEI" ]; then
|
if [ -s "$SPRECHDATEI" ]; then
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ for t in $(systemctl --user list-timers --no-legend --no-pager 2>/dev/null | awk
|
|||||||
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
||||||
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
|
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
|
||||||
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
|
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
|
||||||
if [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
# Nach einem Neustart ist der "letzte Lauf" leer, obwohl nichts kaputt ist —
|
||||||
|
# am 04.09. machte der Radar daraus "hohes Risiko, sofort in Betrieb nehmen".
|
||||||
|
# Junge Box (< 1 Tag) + Timer hat einen naechsten Termin = wartet, kein Befund.
|
||||||
|
naechster=$(systemctl --user show -p NextElapseUSecRealtime --value "${dienst%.service}.timer" 2>/dev/null | cut -d' ' -f2,3)
|
||||||
|
if [ -z "$wann" ] && [ "$(cut -d. -f1 /proc/uptime)" -lt 86400 ] && [ -n "$naechster" ]; then
|
||||||
|
ok "$dienst wartet auf den ersten Lauf seit dem Neustart (naechster: $naechster)"
|
||||||
|
elif [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
||||||
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
|
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
|
||||||
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ if [ -z "$IST" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Der Blick nach DRAUSSEN (04.09.2026): Releases, PRs, neue Entwurfsmodelle —
|
||||||
|
# als Skript-Fakten, gegen den letzten Lauf verglichen. Ohne das lag DFlash2 fuer
|
||||||
|
# den Coder zwei Wochen ungenutzt auf der Platte. Faellt es aus, bleibt der
|
||||||
|
# Innen-Bericht vollstaendig; das Ausbleiben steht dann selbst als ACHTUNG drin.
|
||||||
|
UPSTREAM_SKRIPT="${UPSTREAM_SKRIPT:-$HIER/stack-upstream.py}"
|
||||||
|
if [ -f "$UPSTREAM_SKRIPT" ]; then
|
||||||
|
UPSTREAM="$(timeout 240 python3 "$UPSTREAM_SKRIPT" 2>&1)" \
|
||||||
|
|| UPSTREAM="UPSTREAM (Watchlist)
|
||||||
|
ACHTUNG Upstream-Pruefung abgebrochen (exit $?) — $UPSTREAM_SKRIPT nachsehen"
|
||||||
|
# Nur Aenderungen ans Modell. Beim ersten Lauf (04.09.) bekam es auch die
|
||||||
|
# "unveraendert"-Zeilen und erfand zu jeder einen "naechsten Schritt" — der
|
||||||
|
# Prompt-Hinweis half nicht. Fakten filtert das Skript, nicht die Prosa.
|
||||||
|
ANZ_QUELLEN="$(printf '%s\n' "$UPSTREAM" | grep -cE '^\s+(OK|ACHTUNG)' || true)"
|
||||||
|
UPSTREAM_NEU="$(printf '%s\n' "$UPSTREAM" | grep -A1 -E '^\s+ACHTUNG' | grep -vE '^--$' || true)"
|
||||||
|
[ -z "$UPSTREAM_NEU" ] && UPSTREAM_NEU=" OK nichts Neues draussen ($ANZ_QUELLEN Quellen geprueft, alle unveraendert)"
|
||||||
|
IST="$IST
|
||||||
|
|
||||||
|
UPSTREAM (was hat sich draussen getan?)
|
||||||
|
$UPSTREAM_NEU"
|
||||||
|
fi
|
||||||
|
|
||||||
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
|
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
|
||||||
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
||||||
|
|
||||||
@@ -61,6 +82,9 @@ Regeln:
|
|||||||
Fuellmaterial wird nicht gelesen.
|
Fuellmaterial wird nicht gelesen.
|
||||||
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
||||||
nicht unter Verbesserungen.
|
nicht unter Verbesserungen.
|
||||||
|
- Zeilen mit NEU im Abschnitt UPSTREAM sind Neuigkeiten von draussen. Nenne
|
||||||
|
jede kurz und gib den Hinweis dahinter (der Pfeil-Satz) als naechsten Schritt
|
||||||
|
wieder. 'erstmals erfasst' und 'unveraendert' sind KEINE Neuigkeiten.
|
||||||
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
|
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
|
||||||
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""stack-upstream.py — der Blick nach draussen, in Fakten (04.09.2026).
|
||||||
|
|
||||||
|
Ergaenzt stack-ist.sh: das prueft, ob die Box STIMMT. Dieses Skript prueft, ob
|
||||||
|
DRAUSSEN etwas passiert ist, das uns betrifft — neue Releases, gemergte PRs,
|
||||||
|
neue Entwurfsmodelle, neue Modelle der Familien, die wir fahren.
|
||||||
|
|
||||||
|
Warum es das braucht: Am 19.08. lagen die DFlash2-Entwuerfe fuer den Coder auf
|
||||||
|
der Platte, am 27.08. konnte llama.cpp sie, und bis zum 04.09. hat es niemand
|
||||||
|
gemerkt — der Coder lief zwei Wochen mit 12,6 statt 31 t/s. Der KISS-Radar sah
|
||||||
|
nur nach innen. Jetzt sieht er auch nach draussen, aber ohne Modell-Fantasie:
|
||||||
|
jede Zeile hier ist ein abgerufener Wert, verglichen mit dem Wert vom letzten Lauf.
|
||||||
|
|
||||||
|
Quelle: deploy/trend-radar-watchlist.json (Eintraege mit `typ`, siehe unten).
|
||||||
|
Zustand: ~/.hermes/state/stack-upstream.json (Wert je Eintrag vom letzten Lauf).
|
||||||
|
Ausgabe: im Format von stack-ist.sh — `OK` / `ACHTUNG NEU:` / `ACHTUNG ... nicht abrufbar`.
|
||||||
|
Beim ERSTEN Lauf wird nur erfasst (OK), nicht gemeldet — sonst waere jeder
|
||||||
|
Eintrag sofort "neu".
|
||||||
|
|
||||||
|
Typen:
|
||||||
|
github_issue repo, nummer → offen/geschlossen
|
||||||
|
github_pr repo, nummer → offen/gemergt/geschlossen
|
||||||
|
github_release repo → juengster Release-Tag
|
||||||
|
github_release_major repo → nur Major-Sprung meldet
|
||||||
|
github_branch repo, branch → juengster Commit (Datum)
|
||||||
|
pypi paket → Version
|
||||||
|
hf_author author → neue Modell-Repos des Autors
|
||||||
|
url_zeilen url, muster, max_zeilen → Zeilen mit Muster, Aenderung = neu
|
||||||
|
|
||||||
|
Kein set -e-Aequivalent: jeder Eintrag wird einzeln behandelt, ein Fehler
|
||||||
|
nimmt die anderen nicht mit.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WATCHLIST = Path(sys.argv[1] if len(sys.argv) > 1 else
|
||||||
|
os.path.expanduser("~/mission-control-v2/deploy/trend-radar-watchlist.json"))
|
||||||
|
STATE = Path(os.environ.get("STACK_UPSTREAM_STATE",
|
||||||
|
os.path.expanduser("~/.hermes/state/stack-upstream.json")))
|
||||||
|
TIMEOUT = 20
|
||||||
|
UA = "mc2-stack-upstream/1.0 (+https://github.com/Hitonabi)"
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
print(f" OK {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def neu(msg: str) -> None:
|
||||||
|
print(f" ACHTUNG NEU: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
print(f" ACHTUNG {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def hole(url: str, json_erwartet: bool = True):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
|
||||||
|
tok = os.environ.get("GITHUB_TOKEN")
|
||||||
|
if tok and "api.github.com" in url:
|
||||||
|
req.add_header("Authorization", f"Bearer {tok}")
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||||
|
roh = r.read().decode("utf-8", "replace")
|
||||||
|
return json.loads(roh) if json_erwartet else roh
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Pruefer ---
|
||||||
|
# Jeder Pruefer liefert (wert, anzeige). `wert` wird mit dem letzten Lauf
|
||||||
|
# verglichen, `anzeige` ist die menschenlesbare Fassung.
|
||||||
|
|
||||||
|
def p_github_issue(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/issues/{e['nummer']}")
|
||||||
|
st = "geschlossen" if d.get("state") == "closed" else "offen"
|
||||||
|
return st, f"#{e['nummer']} {e['repo']} ist {st} — {d.get('title', '')[:70]}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_pr(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/pulls/{e['nummer']}")
|
||||||
|
if d.get("merged_at"):
|
||||||
|
st = f"gemergt am {d['merged_at'][:10]}"
|
||||||
|
elif d.get("state") == "closed":
|
||||||
|
st = "geschlossen ohne Merge"
|
||||||
|
else:
|
||||||
|
st = "offen" + (" (Entwurf)" if d.get("draft") else "")
|
||||||
|
return st, f"PR #{e['nummer']} {e['repo']} {st} — {d.get('title', '')[:70]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _release(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/releases/latest")
|
||||||
|
return d.get("tag_name", "?"), (d.get("published_at") or "")[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_release(e):
|
||||||
|
tag, wann = _release(e)
|
||||||
|
return tag, f"{e['repo']} juengster Release {tag} ({wann})"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_release_major(e):
|
||||||
|
tag, wann = _release(e)
|
||||||
|
m = re.search(r"(\d+)", tag)
|
||||||
|
major = m.group(1) if m else tag
|
||||||
|
return major, f"{e['repo']} Major {major} (Release {tag}, {wann})"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_branch(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/commits?sha={e['branch']}&per_page=1")
|
||||||
|
c = d[0]
|
||||||
|
wann = c["commit"]["committer"]["date"][:10]
|
||||||
|
return c["sha"][:10], f"{e['repo']}@{e['branch']} letzter Commit {wann} — {c['commit']['message'].splitlines()[0][:60]}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_pypi(e):
|
||||||
|
d = hole(f"https://pypi.org/pypi/{e['paket']}/json")
|
||||||
|
v = d["info"]["version"]
|
||||||
|
return v, f"PyPI {e['paket']} = {v}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_hf_author(e):
|
||||||
|
d = hole(f"https://huggingface.co/api/models?author={e['author']}&sort=lastModified&direction=-1&limit={e.get('limit', 15)}")
|
||||||
|
ids = sorted(m["id"] for m in d)
|
||||||
|
return "\n".join(ids), f"{len(ids)} juengste Repos von {e['author']} auf Hugging Face"
|
||||||
|
|
||||||
|
|
||||||
|
def p_url_zeilen(e):
|
||||||
|
roh = hole(e["url"], json_erwartet=False)
|
||||||
|
zeilen = [z.strip() for z in roh.splitlines() if e["muster"] in z][: int(e.get("max_zeilen", 30))]
|
||||||
|
h = hashlib.sha1("\n".join(zeilen).encode()).hexdigest()[:10]
|
||||||
|
return h, f"{len(zeilen)} Zeilen mit '{e['muster']}' in {e['url'].split('/')[2]} (Stand {h})"
|
||||||
|
|
||||||
|
|
||||||
|
PRUEFER = {
|
||||||
|
"github_issue": p_github_issue,
|
||||||
|
"github_pr": p_github_pr,
|
||||||
|
"github_release": p_github_release,
|
||||||
|
"github_release_major": p_github_release_major,
|
||||||
|
"github_branch": p_github_branch,
|
||||||
|
"pypi": p_pypi,
|
||||||
|
"hf_author": p_hf_author,
|
||||||
|
"url_zeilen": p_url_zeilen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("UPSTREAM (Watchlist — was hat sich draussen getan?)")
|
||||||
|
try:
|
||||||
|
eintraege = json.loads(WATCHLIST.read_text(encoding="utf-8")).get("eintraege", [])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"Watchlist {WATCHLIST} nicht lesbar: {exc}")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
for e in eintraege:
|
||||||
|
key, typ = e.get("key", "?"), e.get("typ", "?")
|
||||||
|
pruefer = PRUEFER.get(typ)
|
||||||
|
if not pruefer:
|
||||||
|
warn(f"{key}: unbekannter Typ '{typ}' — Eintrag wird ignoriert")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wert, anzeige = pruefer(e)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
warn(f"{key}: nicht abrufbar (HTTP {exc.code})")
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"{key}: nicht abrufbar ({type(exc).__name__}: {str(exc)[:60]})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
alt = state.get(key, {}).get("wert")
|
||||||
|
if alt is None:
|
||||||
|
ok(f"{key}: erstmals erfasst — {anzeige}")
|
||||||
|
elif alt == wert:
|
||||||
|
ok(f"{key}: unveraendert — {anzeige}")
|
||||||
|
else:
|
||||||
|
if typ == "hf_author":
|
||||||
|
neue = sorted(set(wert.split("\n")) - set(alt.split("\n")))
|
||||||
|
anzeige = f"neue Repos von {e['author']}: " + ", ".join(neue[:8]) if neue else anzeige
|
||||||
|
hinweis = e.get("warum", "")
|
||||||
|
neu(f"{key}: {anzeige}" + (f"\n → {hinweis[:220]}" if hinweis else ""))
|
||||||
|
state[key] = {"wert": wert, "anzeige": anzeige}
|
||||||
|
|
||||||
|
try:
|
||||||
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, STATE)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"Zustand nicht speicherbar ({exc}) — naechster Lauf meldet alles erneut")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -9,8 +9,14 @@ models:
|
|||||||
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
|
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
|
||||||
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
|
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
|
||||||
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
|
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
|
||||||
|
# 17.09.2026: Uncensored-Variante (HauhauCS Aggressive, Q4_K_M) mit demselben giocom-DFlash-Draft. Prüfstand: 100,8 t/s
|
||||||
|
# @13k (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreißer), Hermes-Smoke grün. Original bleibt in
|
||||||
|
# Qwen3.6-35B-A3B-MTP-GGUF liegen — Rückweg = Pfad zurück.
|
||||||
|
# --reasoning-budget 2048 (17.09.2026): Deckel für Denk-Tokens — die abliterierte Variante hatte im Prüfstand 1/12
|
||||||
|
# Denk-Ausreißer ohne Antwort; mit Deckel wird daraus eine kürzer bedachte Antwort statt Stille. Normale Denkphasen
|
||||||
|
# des Hirns liegen bei ein paar hundert Tokens.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
|
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf --reasoning-budget 2048
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- hermes
|
- hermes
|
||||||
@@ -25,11 +31,17 @@ models:
|
|||||||
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
|
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
|
||||||
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
|
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
|
||||||
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
||||||
|
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
||||||
|
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
||||||
|
# 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5)
|
||||||
|
# mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3,
|
||||||
|
# Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0
|
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
aliases:
|
aliases:
|
||||||
- coder
|
- coder
|
||||||
|
- heavy
|
||||||
capabilities:
|
capabilities:
|
||||||
in: [text, image]
|
in: [text, image]
|
||||||
out: [text]
|
out: [text]
|
||||||
@@ -41,7 +53,7 @@ models:
|
|||||||
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
|
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
|
||||||
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
|
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
|
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
aliases:
|
||||||
- debugger
|
- debugger
|
||||||
@@ -52,7 +64,7 @@ models:
|
|||||||
context: 65536
|
context: 65536
|
||||||
Qwen3-Embedding-0.6B:
|
Qwen3-Embedding-0.6B:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
|
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --load-mode none -c 8192
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- embed
|
- embed
|
||||||
@@ -60,10 +72,10 @@ models:
|
|||||||
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
|
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
|
||||||
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
|
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --jinja --cache-reuse 256 -cram 16384
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
||||||
- heavy
|
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
||||||
capabilities:
|
capabilities:
|
||||||
in: [text]
|
in: [text]
|
||||||
out: [text]
|
out: [text]
|
||||||
@@ -71,7 +83,7 @@ models:
|
|||||||
context: 32768
|
context: 32768
|
||||||
Qwen3-VL-30B-A3B-Instruct:
|
Qwen3-VL-30B-A3B-Instruct:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
||||||
ttl: 900
|
ttl: 900
|
||||||
aliases:
|
aliases:
|
||||||
- vision
|
- vision
|
||||||
@@ -84,7 +96,7 @@ models:
|
|||||||
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
|
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
|
||||||
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
|
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
|
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --load-mode none -c 8192
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- reranker
|
- reranker
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Auftragsbuch: einen LUCY-Vorschlags-Branch ANNEHMEN — das PC-Gegenstück zu auftrag-annehmen.sh.
|
|
||||||
# Lucy (Electron) lebt auf dem Windows-PC (F:\Coding Stuff\lucy); dist liegt NICHT im Repo.
|
|
||||||
# Annehmen heißt darum: Merge auf main (hier, Box) → der PC zieht main, baut dist neu und
|
|
||||||
# startet Lucy neu, WENN sie lief (User-Entscheid: Lucy startet nur, wenn der Commander sie will).
|
|
||||||
# Schlägt am PC irgendetwas fehl, wird der Merge auf main automatisch revertiert — die laufende
|
|
||||||
# Lucy bleibt die alte (der PC-Runner stellt dist aus seinem Backup wieder her).
|
|
||||||
#
|
|
||||||
# Der PC wird über den PC-Executor (client/hermes-pc/executor.py, :7777, Bearer-Token aus
|
|
||||||
# ~/.hermes/config.yaml) gesteuert. /shell hat 60 s Timeout → der Build läuft DETACHED
|
|
||||||
# (deploy/lucy-annahme.ps1 im Lucy-Repo schreibt eine Status-Datei, wir pollen sie).
|
|
||||||
#
|
|
||||||
# Läuft wie der MC2-Runner als EIGENE systemd-Unit und als /tmp-Kopie (Selbst-Reset-Falle).
|
|
||||||
# Status-Schlüssel in der Auftragsbuch-JSON: "lucy:<branch>".
|
|
||||||
#
|
|
||||||
# Nutzung: lucy-annahme.sh <branch> (z. B. wartung/turncheck-default-aus)
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
BRANCH="${1:?Nutzung: lucy-annahme.sh <branch>}"
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
|
||||||
LUCY="${MC2_LUCY_DIR:-$HOME/lucy}"
|
|
||||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
|
||||||
HERMES_CONF="${HERMES_CONF:-$HOME/.hermes/config.yaml}"
|
|
||||||
SLUG="lucy-$(echo "$BRANCH" | tr '/' '-')"
|
|
||||||
WT="/tmp/annahme-$SLUG"
|
|
||||||
LOG="/tmp/annahme-$SLUG.log"
|
|
||||||
|
|
||||||
# Windows-Seite (Pfade im PC-Dateisystem; einfach gehalten — EIN PC, EIN Checkout).
|
|
||||||
PC_REPO='F:\Coding Stuff\lucy'
|
|
||||||
PC_STATUSFILE='F:\Coding Stuff\lucy\.lucy-annahme.json'
|
|
||||||
PC_RUNNER='F:\Coding Stuff\lucy\deploy\lucy-annahme.ps1'
|
|
||||||
BUILD_TIMEOUT_S=1500 # 25 min Deckel für npm ci + tsc + electron-builder + Stimm-Warmup
|
|
||||||
|
|
||||||
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
|
||||||
|
|
||||||
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
|
||||||
status(){ # $1=state $2=detail
|
|
||||||
python3 - "$STATUS" "lucy:$BRANCH" "$1" "$2" <<'PY'
|
|
||||||
import json, os, sys, time
|
|
||||||
p, key, state, detail = sys.argv[1:5]
|
|
||||||
try:
|
|
||||||
d = json.load(open(p, encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
d = {}
|
|
||||||
d.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
|
|
||||||
tmp = p + ".tmp"
|
|
||||||
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
|
||||||
os.replace(tmp, p)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# PC-Executor: PowerShell-Kommando ausführen. stdout des PCs → stdout; rc 0 nur wenn
|
|
||||||
# Transport UND Kommando grün. Token/URL kommen aus der Hermes-Config (kein zweiter Ablageort).
|
|
||||||
PC_URL="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_URL:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
|
|
||||||
PC_TOKEN="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_TOKEN:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
|
|
||||||
pc_shell(){ # $1 = PowerShell-Kommando
|
|
||||||
python3 - "$PC_URL" "$PC_TOKEN" "$1" <<'PY'
|
|
||||||
import json, sys, urllib.request
|
|
||||||
url, token, cmd = sys.argv[1:4]
|
|
||||||
req = urllib.request.Request(url + "/shell", data=json.dumps({"command": cmd}).encode(),
|
|
||||||
headers={"Authorization": "Bearer " + token,
|
|
||||||
"Content-Type": "application/json"})
|
|
||||||
try:
|
|
||||||
r = json.load(urllib.request.urlopen(req, timeout=58))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"(PC-Executor nicht erreichbar: {e})")
|
|
||||||
sys.exit(2)
|
|
||||||
out = (r.get("stdout") or "").strip()
|
|
||||||
if out:
|
|
||||||
print(out)
|
|
||||||
err = (r.get("stderr") or "").strip()
|
|
||||||
if err:
|
|
||||||
print(err, file=sys.stderr)
|
|
||||||
sys.exit(0 if r.get("returncode") == 0 else 1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_wt(){ git -C "$LUCY" worktree remove --force "$WT" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
fail(){
|
|
||||||
status "fehlgeschlagen" "$1"
|
|
||||||
notify "Lucy-Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
|
||||||
cleanup_wt
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Revert nach schon gepushtem Merge (PC-Phase rot): main zurückdrehen, PC-Checkout nachziehen.
|
|
||||||
revert_main(){ # $1 = Grund für die Meldung
|
|
||||||
status "rollback" "PC-Phase rot — Merge wird automatisch zurückgedreht"
|
|
||||||
git -C "$LUCY" fetch -q origin || true
|
|
||||||
cleanup_wt
|
|
||||||
if git -C "$LUCY" worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
|
||||||
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
|
||||||
cleanup_wt
|
|
||||||
# PC-Checkout best-effort auf den revertierten Stand nachziehen (Quelle = laufende Lucy).
|
|
||||||
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 || true
|
|
||||||
status "zurueckgerollt" "$1 — Merge automatisch revertiert, Lucy läuft in der alten Version weiter"
|
|
||||||
notify "Lucy-Vorschlag '$BRANCH' hat den PC-Build/Neustart GERISSEN: $1. Merge automatisch zurückgedreht — Lucy läuft in der alten Version weiter. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cleanup_wt
|
|
||||||
status "kritisch" "Revert fehlgeschlagen — Lucy-Repo und PC von Hand prüfen"
|
|
||||||
notify "KRITISCH: Lucy-Annahme '$BRANCH' scheiterte UND der automatische Revert schlug fehl — bitte Lucy-Repo (Gitea main) und PC prüfen. Log: $LOG"
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
[ -n "$PC_URL" ] && [ -n "$PC_TOKEN" ] || fail "PC-Executor-Zugang fehlt in $HERMES_CONF (PC_EXECUTOR_URL/TOKEN)"
|
|
||||||
|
|
||||||
# ── Vorprüfungen am PC (VOR dem Merge — solange ist nichts kaputtzumachen) ──
|
|
||||||
status "laeuft" "Vorprüfung: ist der PC bereit?"
|
|
||||||
curl -sf -m 8 "$PC_URL/health" >/dev/null 2>&1 \
|
|
||||||
|| fail "PC-Executor nicht erreichbar — Lucy-Annahme braucht den eingeschalteten PC (Executor-Task läuft bei Anmeldung)"
|
|
||||||
|
|
||||||
PC_BRANCH="$(pc_shell "git -C '$PC_REPO' rev-parse --abbrev-ref HEAD" 2>>"$LOG")" \
|
|
||||||
|| fail "PC-Arbeitskopie nicht lesbar (git am PC): $PC_BRANCH"
|
|
||||||
[ "$PC_BRANCH" = "main" ] || fail "PC-Arbeitskopie steht auf '$PC_BRANCH' statt main — bitte am PC aufräumen"
|
|
||||||
|
|
||||||
PC_DIRTY="$(pc_shell "git -C '$PC_REPO' status --porcelain -uno" 2>>"$LOG")" \
|
|
||||||
|| fail "PC-Arbeitskopie nicht prüfbar"
|
|
||||||
[ -z "$PC_DIRTY" ] || fail "PC-Arbeitskopie hat ungespeicherte Änderungen — bitte erst am PC committen/aufräumen: $(echo "$PC_DIRTY" | head -3 | tr '\n' ' ')"
|
|
||||||
|
|
||||||
WAS_RUNNING="$(pc_shell "if (Get-Process Lucy -ErrorAction SilentlyContinue) { '1' } else { '0' }" 2>>"$LOG")" || WAS_RUNNING="0"
|
|
||||||
case "$WAS_RUNNING" in 1|0) ;; *) WAS_RUNNING="0" ;; esac
|
|
||||||
|
|
||||||
# ── Merge auf main (Box, isolierter Worktree — wie beim MC2-Runner) ─────────
|
|
||||||
status "laeuft" "Merge wird vorbereitet"
|
|
||||||
git -C "$LUCY" fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
|
||||||
git -C "$LUCY" rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
|
||||||
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
|
||||||
|
|
||||||
# Kaputt aufgesetzte Branches (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen.
|
|
||||||
git -C "$LUCY" merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
|
||||||
|
|
||||||
cleanup_wt
|
|
||||||
git -C "$LUCY" worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
|
||||||
MERGE_MSG="Auftragsbuch: Lucy-Vorschlag '$BRANCH' angenommen (Ein-Klick-Gate)"
|
|
||||||
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
|
||||||
git -C "$WT" merge --abort 2>/dev/null || true
|
|
||||||
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (wie im MC2-Runner).
|
|
||||||
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
|
||||||
WT2="/tmp/annahme-rebase-$SLUG"
|
|
||||||
git -C "$LUCY" worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
REBASED=""
|
|
||||||
if git -C "$LUCY" worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
rebase origin/main >>"$LOG" 2>&1; then
|
|
||||||
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
|
||||||
else
|
|
||||||
git -C "$WT2" rebase --abort 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
git -C "$LUCY" worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
|
||||||
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
|
||||||
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
status "laeuft" "Push nach main läuft"
|
|
||||||
PUSHED=0
|
|
||||||
for _ in 1 2 3; do
|
|
||||||
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
|
||||||
cleanup_wt
|
|
||||||
|
|
||||||
# ── PC: main ziehen, Build detached starten ─────────────────────────────────
|
|
||||||
status "laeuft" "PC zieht den neuen Stand"
|
|
||||||
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; if (\$LASTEXITCODE -ne 0) { exit 1 }; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 \
|
|
||||||
|| revert_main "PC konnte main nicht ziehen (git fetch/merge am PC rot)"
|
|
||||||
|
|
||||||
pc_shell "if (Test-Path '$PC_RUNNER') { exit 0 } else { exit 1 }" >>"$LOG" 2>&1 \
|
|
||||||
|| revert_main "PC-Runner-Skript fehlt im Lucy-Repo (deploy/lucy-annahme.ps1)"
|
|
||||||
|
|
||||||
# WICHTIG: -File-Pfad MUSS eingebettete Anführungszeichen tragen — Start-Process fügt
|
|
||||||
# die ArgumentList-Elemente OHNE Quoting zusammen; 'F:\Coding Stuff\…' zerbricht sonst am
|
|
||||||
# Leerzeichen und die gespawnte powershell stirbt still (erster Live-Lauf, 12.07.).
|
|
||||||
pc_shell "Remove-Item -Force '$PC_STATUSFILE' -ErrorAction SilentlyContinue; Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','\"$PC_RUNNER\"','-WasRunning','$WAS_RUNNING'" >>"$LOG" 2>&1 \
|
|
||||||
|| revert_main "PC-Build ließ sich nicht starten"
|
|
||||||
|
|
||||||
# ── Status-Datei pollen, Fortschritt in die UI spiegeln ─────────────────────
|
|
||||||
status "laeuft" "PC baut Lucy (dist) — das dauert ein paar Minuten"
|
|
||||||
DEADLINE=$(( $(date +%s) + BUILD_TIMEOUT_S ))
|
|
||||||
LAST_DETAIL=""
|
|
||||||
FINAL=""
|
|
||||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
|
||||||
sleep 10
|
|
||||||
RAW="$(pc_shell "if (Test-Path '$PC_STATUSFILE') { Get-Content -Raw '$PC_STATUSFILE' }" 2>>"$LOG")" || continue
|
|
||||||
[ -n "$RAW" ] || continue
|
|
||||||
# Parse OHNE f-String-Escapes: \" in f-String-Ausdruecken ist seit Python 3.12/3.14
|
|
||||||
# ein SyntaxError (alter Pre-3.12-Stil) — genau das legte den ersten Live-Lauf lahm.
|
|
||||||
PARSED="$(printf '%s' "$RAW" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
d = json.loads(sys.stdin.read())
|
|
||||||
except Exception:
|
|
||||||
sys.exit(0)
|
|
||||||
detail = str(d.get("detail", "")).replace("|", "/").replace("\n", " ")
|
|
||||||
done = "1" if d.get("done") else "0"
|
|
||||||
ok = "1" if d.get("ok") else "0"
|
|
||||||
print(done + "|" + ok + "|" + detail)
|
|
||||||
')"
|
|
||||||
[ -n "$PARSED" ] || continue
|
|
||||||
DONE="${PARSED%%|*}"; REST="${PARSED#*|}"; OK="${REST%%|*}"; DETAIL="${REST#*|}"
|
|
||||||
if [ "$DETAIL" != "$LAST_DETAIL" ] && [ -n "$DETAIL" ]; then
|
|
||||||
LAST_DETAIL="$DETAIL"
|
|
||||||
status "laeuft" "PC: $DETAIL"
|
|
||||||
fi
|
|
||||||
if [ "$DONE" = "1" ]; then FINAL="$OK"; break; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$FINAL" ] || revert_main "PC-Build-Timeout nach $((BUILD_TIMEOUT_S/60)) Minuten (Status-Datei blieb offen)"
|
|
||||||
[ "$FINAL" = "1" ] || revert_main "${LAST_DETAIL:-PC meldet Fehler ohne Detail}"
|
|
||||||
|
|
||||||
# ── Grün: Branch aufräumen, ehrlich melden ──────────────────────────────────
|
|
||||||
git -C "$LUCY" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
|
||||||
git -C "$LUCY" fetch -q --prune origin 2>/dev/null || true
|
|
||||||
status "eingespielt" "${LAST_DETAIL:-Build grün}"
|
|
||||||
if [ "$WAS_RUNNING" = "1" ]; then
|
|
||||||
notify "Lucy-Vorschlag '$BRANCH' ist LIVE — dist am PC neu gebaut, Lucy neu gestartet, Stimme meldet sich. ✅"
|
|
||||||
else
|
|
||||||
notify "Lucy-Vorschlag '$BRANCH' ist eingespielt — dist am PC neu gebaut. Lucy war aus und bleibt aus; die neue Version startet beim nächsten Öffnen. ✅"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# systemd-USER-Unit für das Modell-Radar (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
# Sucht höchstens einmal am Tag nach neuen Modellen für Hirn und Coder und testet nachts im Fenster
|
||||||
|
# 00:30–02:30 höchstens einen neuen Kandidaten pro Woche (backend/radar_lauf.py, services/radar.py).
|
||||||
|
# Um 03:00 braucht der NerdQuiz-Nachtlauf das Hirn: Der Lauf hört um 02:30 selbst auf, um 02:35 zieht
|
||||||
|
# seine Notbremse, und RuntimeMaxSec beendet ihn spätestens 02:40 samt llama-server (control-group).
|
||||||
|
# Type=simple statt oneshot: Bei oneshot greift RuntimeMaxSec nicht.
|
||||||
|
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
|
||||||
|
# Ablage: ~/.config/systemd/user/mc2-radar.service (+ mc2-radar.timer) ; dann:
|
||||||
|
# systemctl --user daemon-reload && systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=MC2 Modell-Radar (Suche + nächtlicher Prüfstand 00:30–02:30)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/mission-control-v2/backend
|
||||||
|
ExecStart=%h/mission-control-v2/backend/.venv/bin/python radar_lauf.py
|
||||||
|
Environment=PYTHONPATH=%h/mission-control-v2
|
||||||
|
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
|
||||||
|
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
|
||||||
|
Environment=MC_MODELS_DIR=/srv/models
|
||||||
|
Environment=MC_LOCAL_TZ=Europe/Berlin
|
||||||
|
# Kein Nice=: der Kandidaten-Server erbt die Priorität — gemessen wird gegen das Live-Modell.
|
||||||
|
RuntimeMaxSec=2h10min
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=60
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nächtliches Modell-Radar (00:30, Test-Fenster bis 02:30)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00:30:00
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=1min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heavy": {
|
"heavy": {
|
||||||
"name": "heavy - Planen / Review",
|
"name": "heavy - Planen / Review (= Qwen3.8-27B, seit 04.09. mit DFlash2)",
|
||||||
"tool_call": true,
|
"tool_call": true,
|
||||||
"limit": {
|
"limit": {
|
||||||
"context": 32768,
|
"context": 32768,
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ for name in $REPOS; do
|
|||||||
continue
|
continue
|
||||||
fi
|
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
|
if [ $DRY -eq 1 ]; then
|
||||||
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
|
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"_hinweis": "Merkliste des Modell-Radars (backend/services/radar.py). Einträge hier werden vor den Funden der Hugging-Face-Entdeckung getestet, in dieser Reihenfolge. Felder: name, rolle (hirn|coder), repo, quant (Standard Q4_K_M), eng (true = passt nur knapp neben das Warm-Set), notiz (erscheint in der Begründung), ueberspringen (Grund; Eintrag wird nicht getestet), optional draft {repo, datei, typ, n_max} und flags (zusätzliche llama-server-Flags). Getestet wird nur, was ein mmproj hat und neben das Warm-Set passt (27 GB + Kandidat inkl. KV-Cache <= 115 GB).",
|
||||||
|
"kandidaten": [
|
||||||
|
{
|
||||||
|
"name": "Ornith-1.5-35B-A3B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "ornith-ai/Ornith-1.5-35B-A3B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"notiz": "MoE mit 3B aktiven Parametern (Architektur qwen35moe), mmproj im Repo."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Qwen3.8-Flash-Next",
|
||||||
|
"rolle": "coder",
|
||||||
|
"repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS",
|
||||||
|
"eng": true,
|
||||||
|
"notiz": "UD-IQ3_XXS ist ca. 82 GB groß und passt nur knapp neben das Warm-Set (UD-IQ4_XS mit 94 GB passt nicht)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "K2-Horizon-MoVA-36B-A4B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "IFM/K2-Horizon-MoVA-36B-A4B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"ueberspringen": "läuft noch nicht im offiziellen llama.cpp."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -97,21 +97,9 @@ for x in sorted(off, key=lambda x: x.get("title") or ""):
|
|||||||
print("- [{}] {}".format(x.get("status", "?"), (x.get("title") or "")[:100]))
|
print("- [{}] {}".format(x.get("status", "?"), (x.get("title") or "")[:100]))
|
||||||
' 2>/dev/null || echo "(Queue nicht lesbar)"
|
' 2>/dev/null || echo "(Queue nicht lesbar)"
|
||||||
echo
|
echo
|
||||||
echo "## Offene Karten im Auftragsbuch"
|
|
||||||
curl -sf -m 10 "$API/api/auftragsbuch" 2>/dev/null | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
items = json.load(sys.stdin).get("items") or []
|
|
||||||
except Exception:
|
|
||||||
items = []
|
|
||||||
if not items:
|
|
||||||
print("- (keine)")
|
|
||||||
for it in sorted(items, key=lambda x: x.get("branch") or ""):
|
|
||||||
print("- {}:{}".format(it.get("repo", "mc2"), it.get("branch", "?")))
|
|
||||||
' 2>/dev/null || echo "(Auftragsbuch nicht lesbar)"
|
|
||||||
echo
|
echo
|
||||||
echo "## Feste Leitplanken (Kurzfassung — Details docs/wissen/)"
|
echo "## Feste Leitplanken (Kurzfassung — Details docs/wissen/)"
|
||||||
echo "- Jede Code-Zeile geht NUR ueber eine Auftragsbuch-Karte live (Klick des Commanders)."
|
echo "- Jede Code-Zeile geht NUR als Branch nach Gitea live: Ampel gruen, Merge durch den Commander, deploy.sh."
|
||||||
echo "- TABU ohne explizites User-Ja: Security (approvals/Tokens/ufw/sudoers), ~/.hermes/config.yaml, systemd-Units, Hermes-Quellcode."
|
echo "- TABU ohne explizites User-Ja: Security (approvals/Tokens/ufw/sudoers), ~/.hermes/config.yaml, systemd-Units, Hermes-Quellcode."
|
||||||
echo "- Updates macht der Auto-Update-Timer mit Fangnetz — niemand sonst."
|
echo "- Updates macht der Auto-Update-Timer mit Fangnetz — niemand sonst."
|
||||||
} > "$STECK.tmp" 2>/dev/null
|
} > "$STECK.tmp" 2>/dev/null
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ heavy-Rollen einer Runde am Stueck (heavy bleibt warm), DANN der Skeptiker (ein
|
|||||||
dieses Projekt nicht (noetige Komponente gibt es nur als Image o. ae.), sag das im Konzept
|
dieses Projekt nicht (noetige Komponente gibt es nur als Image o. ae.), sag das im Konzept
|
||||||
AUSDRUECKLICH mit Begruendung — still ignorieren gilt nicht.
|
AUSDRUECKLICH mit Begruendung — still ignorieren gilt nicht.
|
||||||
- **★ Explizite Commander-Wahl UEBERSTIMMT deine Einschaetzung.** Steht im Auftrag eine Zeile
|
- **★ Explizite Commander-Wahl UEBERSTIMMT deine Einschaetzung.** Steht im Auftrag eine Zeile
|
||||||
„AUFWAND (vom Commander gewaehlt): SIMPEL …" bzw. „… GRUENDLICH …" (der Commander hat im
|
„AUFWAND (vom Commander gewaehlt): SIMPEL …" bzw. „… GRUENDLICH …" (der Commander hat in der
|
||||||
Auftragsbuch „IDE: simpel" oder „IDE: gruendlich" geklickt), dann HALTE DICH DARAN — SIMPEL =
|
Ideen-Ansicht „IDE: simpel" oder „IDE: gruendlich" gewaehlt), dann HALTE DICH DARAN — SIMPEL =
|
||||||
2 Rollen/1 Runde, GRUENDLICH = volle 3-5 Rollen/bis 3 Runden — auch wenn deine eigene
|
2 Rollen/1 Runde, GRUENDLICH = volle 3-5 Rollen/bis 3 Runden — auch wenn deine eigene
|
||||||
Schaetzung anders ausfaellt. Nur wenn KEINE solche Zeile da ist, entscheidest du selbst.
|
Schaetzung anders ausfaellt. Nur wenn KEINE solche Zeile da ist, entscheidest du selbst.
|
||||||
- **Max 3 Denk-Runden — keine Endlosschleife.** Nach der letzten Runde (je nach Groesse 1-3) kommt
|
- **Max 3 Denk-Runden — keine Endlosschleife.** Nach der letzten Runde (je nach Groesse 1-3) kommt
|
||||||
|
|||||||
@@ -17,5 +17,5 @@ Dieser Skill fungiert als dein persönlicher, KI-gesteuerter Sysadmin, der dir j
|
|||||||
1. **System-Metriken abrufen**: Ruft den Health-Status und Load der Box ab.
|
1. **System-Metriken abrufen**: Ruft den Health-Status und Load der Box ab.
|
||||||
2. **Logs durchkämmen**: Sucht nach Auffälligkeiten, Fehlern oder Crashes in der Nacht.
|
2. **Logs durchkämmen**: Sucht nach Auffälligkeiten, Fehlern oder Crashes in der Nacht.
|
||||||
3. **News Fetching**: Liest die Top-Posts von Hacker News (oder anderen Tech-Feeds), filtert sie nach AI-Relevanz und fasst die 3 wichtigsten Entwicklungen zusammen.
|
3. **News Fetching**: Liest die Top-Posts von Hacker News (oder anderen Tech-Feeds), filtert sie nach AI-Relevanz und fasst die 3 wichtigsten Entwicklungen zusammen.
|
||||||
4. **Auftragsbuch Check**: Prüft, ob neue unentschiedene Vorschläge im Auftragsbuch liegen.
|
4. **Offene Branches**: Nennt ungemergte Vorschlags-Branches auf Gitea (wartung/*, feature/*, doku/*), falls welche warten.
|
||||||
5. **Report Generierung**: Bündelt all diese Informationen in eine kompakte, handyfreundliche (sehr kurze) Nachricht und schickt sie via Telegram an den Commander.
|
5. **Report Generierung**: Bündelt all diese Informationen in eine kompakte, handyfreundliche (sehr kurze) Nachricht und schickt sie via Telegram an den Commander.
|
||||||
|
|||||||
@@ -1,51 +1,73 @@
|
|||||||
{
|
{
|
||||||
"_hinweis": "Living Watchlist des Trend-Radars. Diese Datei MUSS autonom vom Trend-Radar Agenten umgeschrieben werden, wenn er neue Quellen findet oder alte obsolet werden.",
|
"_hinweis": "Watchlist des Stack-Radars. Wird von deploy/jobs/stack-upstream.py (Skript, kein Modell) samstags abgefragt; jeder Eintrag = ein abrufbarer Wert, verglichen mit dem letzten Lauf. Typen: github_issue, github_pr, github_release, github_release_major, github_branch, pypi, hf_author, url_zeilen. `warum` wird bei NEU mitgemeldet — dort steht, was dann zu tun ist.",
|
||||||
"eintraege": [
|
"eintraege": [
|
||||||
{
|
{
|
||||||
"key": "mmq-prefill-gfx1151",
|
"key": "qwen-modelle",
|
||||||
"typ": "github_issue",
|
"typ": "hf_author",
|
||||||
|
"author": "Qwen",
|
||||||
|
"limit": 20,
|
||||||
|
"warum": "Lucys Hirn ist Qwen3.6-35B-A3B, der Coder Qwen3.8-27B. Ein neues A3B-Modell waere der Hirn-Kandidat (dichte Modelle nie, Verdikt 17.07.); ein neues 27B/30B der Coder-Kandidat. Immer erst auf der Box messen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "zlab-drafts",
|
||||||
|
"typ": "hf_author",
|
||||||
|
"author": "z-lab",
|
||||||
|
"limit": 20,
|
||||||
|
"warum": "z-lab baut die DFlash-Entwurfsmodelle. DFlash2 brachte dem Coder 12,6 -> 31 t/s (04.09.). Ein DFlash2 fuer Qwen3.6-35B-A3B waere derselbe Hebel fuers Hirn — DFlash v1 faellt bei 32k Tiefe auf 1,02x."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "flash-next-mtp",
|
||||||
|
"typ": "github_pr",
|
||||||
"repo": "ggml-org/llama.cpp",
|
"repo": "ggml-org/llama.cpp",
|
||||||
"nummer": 21284,
|
"nummer": 28243,
|
||||||
"bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged",
|
"warum": "MTP fuer Qwen3.8-Flash-Next (125B-A6B). Erst mit Merge + Vulkan-Zahlen lohnt ein Blick — und nur, wenn eine Quantisierung neben das Warm-Set passt (Regel: Warm-Set + Modell <= 115 GB; UD-IQ4_XS = 94 GB passt NICHT)."
|
||||||
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ) und +20 % auf 122B. Wenn gemerged: ROCm-Langkontext-Bench für coder fällig (Vulkan-Verdikt bleibt bis zur Messung)."
|
},
|
||||||
|
{
|
||||||
|
"key": "hrx-backend",
|
||||||
|
"typ": "github_branch",
|
||||||
|
"repo": "AMD-Ecosystem/llama.cpp",
|
||||||
|
"branch": "hrx-graph-develop-v2",
|
||||||
|
"warum": "AMDs HRX-Runtime (Lemonade 11.9): gegen HIP +21-25 % Decode, +20-123 % Prefill, also etwa Vulkan-Niveau beim Decode und vorn beim Prefill. Stand 04.09. nur Qwen3-30B-A3B/Llama-3/Gemma-3. Interessant, sobald Qwen3.6/3.8 und Spec-Decoding laufen — dann Box-Bench gegen Vulkan."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rocm-releases",
|
"key": "rocm-releases",
|
||||||
"typ": "github_release",
|
"typ": "github_release",
|
||||||
"repo": "ROCm/ROCm",
|
"repo": "ROCm/ROCm",
|
||||||
"bedingung": "Neuer ROCm-Release seit letzter Meldung",
|
"warum": "ROCm 10.0 (27.08.) hat das Vulkan-Verdikt nicht gekippt: HIP gewinnt Prefill, Vulkan Decode. Bei neuem Release: Strix-Halo-Community-Grid gegenlesen, erst bei tg-Trendwende Box-Bench (Lychee-Technology liefert fertige ROCm-Builds)."
|
||||||
"warum": "Seit 7.14 ist TheRock der Produktions-Stack und AMD optimiert offiziell für die Halo-Familie — ROCm könnte Vulkan bei der Token-Erzeugung einholen. Bei neuem Release: Community-Grid-Zeilen unten gegenlesen."
|
},
|
||||||
|
{
|
||||||
|
"key": "mmq-prefill-gfx1151",
|
||||||
|
"typ": "github_issue",
|
||||||
|
"repo": "ggml-org/llama.cpp",
|
||||||
|
"nummer": 21284,
|
||||||
|
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ). Wenn geschlossen: ROCm-Langkontext-Bench fuer coder faellig (Vulkan-Verdikt bleibt bis zur Messung)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "strix-halo-grid",
|
||||||
|
"typ": "url_zeilen",
|
||||||
|
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
||||||
|
"muster": "t/s",
|
||||||
|
"max_zeilen": 30,
|
||||||
|
"warum": "Der ehrlichste externe Messpunkt auf unserer Hardware. Bei Aenderung: auf Vulkan<->ROCm-Trendwende und neue Spec-Decoding-Verfahren achten."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "electron-major",
|
"key": "electron-major",
|
||||||
"typ": "github_release_major",
|
"typ": "github_release_major",
|
||||||
"repo": "electron/electron",
|
"repo": "electron/electron",
|
||||||
"bedingung": "Neue MAJOR-Version seit letzter Meldung",
|
"warum": "Lucys Desktop-Shell am PC. Versionsstand IMMER live pruefen (lucy-desktop/package.json), nie dieser Notiz glauben. Neue Major = Chromium-Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit."
|
||||||
"warum": "Lucys Desktop-Shell am PC. Versionsstand NIE dieser Notiz glauben — IMMER live pruefen: lucy-desktop/package.json + node_modules/electron (22.07.: 43.2.0 = aktueller Stable; der fruehere '33'-Stand hier war veraltet und produzierte einen Fehlalarm im Rundumschlag). Neue Major = Chromium-Jahrgang Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit; der Radar erinnert nur."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "pocket-tts",
|
"key": "pocket-tts",
|
||||||
"typ": "pypi",
|
"typ": "pypi",
|
||||||
"paket": "pocket-tts",
|
"paket": "pocket-tts",
|
||||||
"bedingung": "Neue Version auf PyPI",
|
"warum": "Lucys Stimme am PC (2.1.0, german_24l). Neue Versionen koennen Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
||||||
"warum": "Lucys Stimme am PC (installiert 2.1.0, german_24l) — neue Versionen können Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "lmstudio-bionic",
|
"key": "lmstudio-bionic",
|
||||||
"typ": "github_issue",
|
"typ": "github_issue",
|
||||||
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
||||||
"nummer": 2185,
|
"nummer": 2185,
|
||||||
"bedingung": "Issue geschlossen = Bionic-Linux-Port vermutlich da",
|
"warum": "LM Studio Bionic: erst interessant bei Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI. Bis dahin bleibt die Box :9001 draussen."
|
||||||
"warum": "LM Studio Bionic (eigenstaendige Agent-App, Preview seit 16.07.26, Win+Mac, gratis, closed source). Stand 22.07.: KEINE eigenen OpenAI-Endpunkte anbindbar (Box :9001 bleibt draussen — nur In-App-Modelle, LM Link mit Konto+Tailscale-Mesh oder Secure Cloud), keine CLI/API/Headless. Fuer den Stack erst interessant bei: Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI — dazu Changelog lmstudio.ai/changelog gegenlesen; llmster auf der Box waere der LM-Link-Rechenknoten-Weg (seit 0.4.20), dupliziert aber die eigene Engine."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "llamacpp-rocm-diskussion",
|
|
||||||
"typ": "url_zeilen",
|
|
||||||
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
|
||||||
"muster": "t/s",
|
|
||||||
"max_zeilen": 30,
|
|
||||||
"bedingung": "Community-Messwerte (Vulkan vs. ROCm, MTP, neue Beschleuniger) auf unserer Hardware",
|
|
||||||
"warum": "Der Strix-Halo-Community-Grid ist der ehrlichste externe Messpunkt. Auf tg-Trendwende Vulkan↔ROCm und neue Spec-Decoding-Verfahren achten."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
# Auftragsbuch — die Vorschlags-Inbox der Box
|
|
||||||
|
|
||||||
Das Auftragsbuch ist das Mensch-Gate des propose-only-Kreislaufs als Klick statt
|
|
||||||
Git-Handarbeit: alles, was die Box von allein baut oder sich ausdenkt, landet hier
|
|
||||||
als Karte — du entscheidest mit **Annehmen** oder **Ablehnen**.
|
|
||||||
|
|
||||||
## Die Ideen-Queue davor (natives Hermes-Kanban, seit 10.07.2026)
|
|
||||||
|
|
||||||
Vor dem Auftragsbuch liegt DIE eine Ideen-Queue: das native Hermes-Kanban
|
|
||||||
(`hermes kanban`, SQLite-Board, Dispatcher läuft im Gateway). Der Weg einer Idee:
|
|
||||||
|
|
||||||
1. **Idee rein — Tür egal:** Eingabefeld im Auftragsbuch-Tab (`POST /api/ideen`),
|
|
||||||
Telegram/Desktop (natives Tool `kanban_create`, Regel in SOUL.md) oder Lucys
|
|
||||||
Sprech-Leitung (`idee_notieren` in `mcp/mcp_voice.py`). Alles landet als
|
|
||||||
`triage`-Aufgabe auf demselben Board.
|
|
||||||
2. **Die Box arbeitet sie aus:** der Kanban-Specifier macht aus der rohen Idee
|
|
||||||
einen sauberen Auftrag und wählt das Worker-Profil (Routing über die
|
|
||||||
Profil-Beschreibungen: Code → `werkstatt` [Qwen3-Coder-Next, Leitplanken in
|
|
||||||
`deploy/werkstatt-SOUL.md`], Doku/Analyse → `default`).
|
|
||||||
3. **Der Worker baut** im isolierten Workspace; Code-Ergebnisse pusht er als
|
|
||||||
Vorschlags-Branch auf Gitea — der hier als Karte erscheint. **Das Gate bleibt
|
|
||||||
dein Klick.**
|
|
||||||
|
|
||||||
**Die Queue füttert sich auch selbst — Idle-Radar (S4 „Zündung", 12.07.2026):** ein
|
|
||||||
nächtlicher Hermes-Cron (03:45, `deploy/idle-radar-feed.sh`) destilliert aus den
|
|
||||||
Journal-Fehlermustern der letzten 24 h und der jüngsten Traum-Notiz bis zu **2 belegte**
|
|
||||||
Wartungs-Kandidaten und legt sie als rohe Ideen (`triage`, `created_by=idle-radar`) auf
|
|
||||||
dasselbe Board. Leitplanken: jedes Beleg-Zitat wird **mechanisch** gegen das Material
|
|
||||||
geprüft (erfundene Belege fliegen raus — reproduziert-oder-abgelehnt), **Stau-Bremse**
|
|
||||||
(ab 4 offenen Queue-Aufgaben oder 3 offenen Karten legt er nichts nach), dreifacher
|
|
||||||
Dedup (State-Datei `~/.hermes/state/idle-radar-gemeldet.txt`, Board-Abgleich inklusive
|
|
||||||
archivierter = verworfener Ideen, `--idempotency-key`), Security/Config/Updates tabu.
|
|
||||||
Der Radar legt nur Ideen an — gebaut wird über denselben Kreislauf, dein Klick bleibt
|
|
||||||
das Gate.
|
|
||||||
|
|
||||||
Die Queue-Sicht im Tab (Status, offene Ideen) kommt aus `hermes kanban list --json`
|
|
||||||
(`backend/services/ideen.py`); die Morgenlage bekommt `kanban stats` als
|
|
||||||
Beweismaterial. Entscheid nativ-vs-Eigenbau (Hermes-first): Probe 10.07.2026 —
|
|
||||||
Dispatcher, Specifier, Profil-Modellwahl und Artefakt-Tracking sind nativ E2E
|
|
||||||
bewiesen; ein Eigenbau hätte all das nur nachgebaut.
|
|
||||||
|
|
||||||
## Woher die Karten kommen
|
|
||||||
|
|
||||||
- **Fertige Patches** — Branches auf Gitea (`wartung/*`, `orchestrator/*`, `doku/*`,
|
|
||||||
`feature/*`), wie die Werkstatt und der Orchestrator sie hinterlassen. Die Karte
|
|
||||||
zeigt Commit-Botschaft, geänderte Dateien und den vollen Diff. Seit S3
|
|
||||||
(lucy-pipeline) aus **zwei Repos**: `mission-control-v2` (Box-Stack) und `lucy`
|
|
||||||
(Desktop-App, rosa „Lucy"-Badge auf der Karte).
|
|
||||||
- **Skill-Kandidaten** — Ideen des nächtlichen Traum-Crons aus dem Wissens-Vault
|
|
||||||
(`~/wissens-vault/skill-kandidaten/`). „Beauftragen" schickt sie als Auftrag an
|
|
||||||
die Werkstatt (Orchestrator-Skill); das Ergebnis kommt als neuer Patch zurück.
|
|
||||||
|
|
||||||
## Was „Annehmen" bei einem Patch macht (deploy/auftrag-annehmen.sh)
|
|
||||||
|
|
||||||
1. Merge des Branches in einem **isolierten Worktree** (Live-Checkout bleibt unberührt).
|
|
||||||
Kollidiert der Merge, weil main weitergelaufen ist, versucht der Runner **automatisch
|
|
||||||
einen Rebase** des Branches auf main (Selbstheilung für veraltete Vorschläge) — erst
|
|
||||||
wenn auch der kollidiert, fällt die Karte mit ehrlicher Meldung durch.
|
|
||||||
2. `py_compile`-Gate über die geänderten Python-Dateien.
|
|
||||||
3. Push nach `main` auf Gitea (mit Retry).
|
|
||||||
4. Deploy (als /tmp-Kopie von deploy.sh — Selbst-Reset-Falle) — die Zentrale
|
|
||||||
startet dabei kurz neu.
|
|
||||||
5. Health-Check mit Geduld. **Rot ⇒ automatischer Revert des Merges + Redeploy +
|
|
||||||
Alarm.** Grün ⇒ Branch wird aufgeräumt, Meldung an Telegram + Chronik.
|
|
||||||
|
|
||||||
Der Lauf ist eine eigene systemd-Unit (detached) — er überlebt den Neustart des
|
|
||||||
Backends, das ihn gestartet hat. Fortschritt steht live auf der Karte
|
|
||||||
(`/srv/models/mc2-auftragsbuch.json`).
|
|
||||||
|
|
||||||
**Kaputt aufgesetzte Branches** (Worker machte `git init`/Shallow statt zu klonen → kein
|
|
||||||
gemeinsamer Ursprung mit main, „refusing to merge unrelated histories") erkennt das
|
|
||||||
Auftragsbuch an der Quelle: rote Markierung „kein gemeinsamer Ursprung", der
|
|
||||||
Annehmen-Knopf fehlt (Annehmen wäre ein garantierter Fehllauf), API und Runner halten
|
|
||||||
zusätzlich dicht. Ebenso markiert: „Diff leer — bringt nichts" (Branch würde gegenüber
|
|
||||||
main nichts ändern). In beiden Fällen: **ablehnen mit Grund** und die Idee frisch in die
|
|
||||||
Queue geben; die Prävention (voll klonen, merge-base-Selbstcheck, fetch+rebase vor dem
|
|
||||||
Push) steht in der werkstatt-SOUL.
|
|
||||||
|
|
||||||
## Was „Annehmen" bei einer LUCY-Karte macht (deploy/lucy-annahme.sh)
|
|
||||||
|
|
||||||
Lucy (Electron) lebt auf dem Windows-PC; ihr `dist` liegt nicht im Repo — die Box
|
|
||||||
kann sie nicht bauen. Der Lucy-Runner spannt darum den PC ein (PC-Executor :7777,
|
|
||||||
Token aus `~/.hermes/config.yaml`):
|
|
||||||
|
|
||||||
1. **Vorprüfung am PC, VOR dem Merge:** Executor erreichbar? Arbeitskopie
|
|
||||||
`F:\Coding Stuff\lucy` auf `main` und sauber? Läuft Lucy gerade? Scheitert etwas,
|
|
||||||
bricht der Lauf ab, ohne irgendetwas zu verändern — der PC muss an sein.
|
|
||||||
2. Merge des Branches im isolierten Worktree von `~/lucy` → Push nach `main`.
|
|
||||||
3. Der PC zieht `main` (ff-only) und startet **detached**
|
|
||||||
`deploy/lucy-annahme.ps1` (liegt im Lucy-Repo): ggf. `npm ci` (nur wenn sich
|
|
||||||
das Lockfile geändert hat) → `tsc --noEmit`-Gate → dist-Backup → `npm run dist`
|
|
||||||
→ Lucy-Neustart **nur, wenn sie vorher lief** (User-Entscheid: Lucy startet
|
|
||||||
nur, wenn der Commander sie will) → Stimme-Health (`:8130`).
|
|
||||||
4. Die Box pollt die PC-Status-Datei (`.lucy-annahme.json`) und spiegelt den
|
|
||||||
Fortschritt live auf die Karte. **Rot ⇒ Merge wird automatisch revertiert**,
|
|
||||||
der PC stellt dist aus dem Backup wieder her — die laufende Lucy bleibt die
|
|
||||||
alte. Grün ⇒ Branch aufgeräumt, Meldung an Telegram + Chronik.
|
|
||||||
|
|
||||||
## Bagatellen ohne Klick (mc2-bagatell.timer, 04:10)
|
|
||||||
|
|
||||||
Einzige Ausnahme vom Klick-Gate (User-Entscheid 10.07.2026, bewusst eng geschnitten):
|
|
||||||
`deploy/bagatell-annahme.sh` spielt nachts Vorschlags-Branches ein, deren Diff
|
|
||||||
**ausschließlich `.md`-Dateien anlegt oder ändert** — keine Code-Zeile, keine
|
|
||||||
Löschungen, keine Renames, keine Skripte. Der Weg ist derselbe Annahme-Runner wie
|
|
||||||
beim Klick (Merge → Gate → Deploy → Health → Auto-Revert), maximal 2 pro Nacht und
|
|
||||||
nur Branches ohne vorherige Fehl-Läufe. Fangnetz: Nacht-Sicherung 03:30 liegt davor,
|
|
||||||
jede Annahme steht in Chronik + Telegram, die Morgenlage 04:30 berichtet. Die Klasse
|
|
||||||
erweitert nur der Commander, nie das Skript.
|
|
||||||
|
|
||||||
## „Lucy kennt sich" (12.07.2026): Selbstbild, Empfehlungs-Stempel, Lern-Gedächtnis
|
|
||||||
|
|
||||||
- **Selbst-Inventur (Cron 03:35, `deploy/selbst-inventur.sh`):** erhebt den
|
|
||||||
**Selbst-Steckbrief** der Box live aus der Realität (Versionen, Dienste, Modelle,
|
|
||||||
Crons, Queue, Karten → `~/.hermes/state/selbst-steckbrief.md`) und **bemerkt selbst,
|
|
||||||
wenn sich etwas ändert** (Diff gegen gestern → stille Chronik-Karte + kurze
|
|
||||||
Telegram-Zeile; keine Änderung = still). Der Steckbrief ist das „was es schon
|
|
||||||
gibt"-Gedächtnis: Idle-Radar-Analyst und Werkstatt (Realitäts-Check-Regel) lesen ihn,
|
|
||||||
bevor sie vorschlagen oder bauen.
|
|
||||||
- **Karten-Gutachter (Cron 04:00, `deploy/karten-gutachter.sh`):** stempelt jede neue
|
|
||||||
Karte mit **„Empfehlung: Annehmen/Ablehnen/Unklar" + einem ehrlichen Satz** —
|
|
||||||
geprüft werden Redundanz (existiert das laut Steckbrief schon?), Risiko und Nutzen.
|
|
||||||
Der Stempel ist eine Meinung, kein Gate; ein nachgeschobener Commit macht ihn
|
|
||||||
ungültig (wird neu gestempelt). Ablage `/srv/models/mc2-karten-gutachten.json`.
|
|
||||||
- **Ablehnen mit Grund:** Beim Ablehnen einer Karte kannst du der Box in einem Satz
|
|
||||||
sagen, *warum* („gibt es schon", „will ich nicht"). Der Grund landet in
|
|
||||||
`/srv/models/mc2-ablehnungen.jsonl` und wird Radar/Analysten als „NIE wieder
|
|
||||||
vorschlagen"-Material vorgelegt — dein Klick wird damit zum Lehrer, nicht nur zum Tor.
|
|
||||||
- **Radar-Treffer → Queue:** Meldet der wöchentliche Release-Radar einen echten Treffer,
|
|
||||||
legt er zusätzlich EINE rohe Idee in die Ideen-Queue („Probe bauen?", idempotent je
|
|
||||||
Release-Tag) — der Blick nach draußen füttert damit denselben Kreislauf.
|
|
||||||
|
|
||||||
## Curator Auto-Archivierung (Cron `0 3 * * *` — täglich um 03:00)
|
|
||||||
|
|
||||||
**Zweck:** Automatische Archivierung inaktiver Skills nach 14 Tagen ohne Nutzung.
|
|
||||||
|
|
||||||
**Skripte:**
|
|
||||||
- `~/.hermes/scripts/curator-archive-watchdog.sh` — Cron-Einstieg, ruft das Python-Skript auf.
|
|
||||||
- `~/.hermes/scripts/curator_archive_logic.py` — Hauptlogik. Liest `.curator_state` und `.usage.json` aus `~/.hermes/skills/`, prüft auf Inaktivität (>14 Tage seit letztem Gebrauch), überspringt gepinnte und exkludierte Skills, verschiebt Skills nach `.archive/<Kategorie>/`.
|
|
||||||
- `~/.hermes/scripts/curator-idle-watchdog.sh` — Zusätzlicher Watchdog (Sonntag 00:00, Cron `0 0 * * 0`): prüft, ob der Curator selbst ≥14 Tage idle ist und triggert ggf. eine Reaktivierung.
|
|
||||||
|
|
||||||
**Konfiguration** (`~/.hermes/skills/.curator_state`):
|
|
||||||
- `archivierung.enabled: false` — Archivierung muss explizit aktiviert werden.
|
|
||||||
- `archivierung.threshold_days: 14` — Tage ohne Nutzung bis zur Archivierung.
|
|
||||||
- `archivierung.excluded_skills: [...]` — Liste der nie zu archivierenden Skills.
|
|
||||||
- `archivierung.last_archive_run_at` — Zeitstempel des letzten Durchlaufs.
|
|
||||||
|
|
||||||
**Ablauf im Durchlauf:**
|
|
||||||
1. Prüft, ob Archivierung aktiviert ist.
|
|
||||||
2. Läuft alle Skills durch `.usage.json`.
|
|
||||||
3. Überspringt: gepinnte Skills, exkludierte Skills, aktive Skills (<14 Tage).
|
|
||||||
4. Archiviert: State auf "archived" setzen, Verzeichnis nach `.archive/<Kategorie>/` verschieben, Usage-Datei aktualisieren.
|
|
||||||
5. Protokolliert in `~/.hermes/logs/curator/archive-<Datum>.log`.
|
|
||||||
|
|
||||||
**Sicherheit:**
|
|
||||||
- Archivierung standardmäßig deaktiviert — passiert nie ohne explizite Freigabe.
|
|
||||||
- Gepinnte und exkludierte Skills werden nie berührt.
|
|
||||||
- Archivierung ist reversibel: Skills können manuell aus `.archive/` zurückverschoben werden.
|
|
||||||
|
|
||||||
## Leitplanken
|
|
||||||
|
|
||||||
- Propose-only bleibt: **keine Code-Zeile geht ohne Klick live.** Das Gate ist dieses
|
|
||||||
Buch; einzige Ausnahme sind die eng geschnittenen Doku-Bagatellen (oben).
|
|
||||||
- „Frontend ohne Build"-Warnung (nur mc2): enthält ein Branch `frontend/src` ohne
|
|
||||||
frisches `frontend/dist`, bleibt die Oberfläche nach dem Annehmen alt, bis am PC
|
|
||||||
gebaut wird. Lucy-Karten brauchen die Warnung nicht — dort baut IMMER der PC.
|
|
||||||
- Ablehnen löscht nur den Remote-Branch; die Historie auf Gitea bleibt.
|
|
||||||
|
|
||||||
Diese Datei kam übrigens selbst über das Auftragsbuch ins Repo — als erste echte
|
|
||||||
End-to-End-Annahme (Probe-Branch `wartung/annahme-probe`, 08.07.2026).
|
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
## Die Bereiche (Sidebar, Stand 09.07.2026)
|
## Die Bereiche (Sidebar, Stand 09.07.2026)
|
||||||
- **Cockpit** — deine Box auf einen Blick (Status, Leistung, Morgenlage, Erinnerungen).
|
- **Cockpit** — deine Box auf einen Blick (Status, Leistung, Morgenlage, Erinnerungen).
|
||||||
- **Auftragsbuch** — Vorschläge der Box (Werkstatt/Orchestrator/Traum) per Ein-Klick annehmen/ablehnen.
|
|
||||||
- **Modelle** — Speicherleiste, installierte Modelle + neue finden/laden, Rollen (hermes/fast/heavy/…).
|
- **Modelle** — Speicherleiste, installierte Modelle + neue finden/laden, Rollen (hermes/fast/heavy/…).
|
||||||
- **Gedächtnis** — geteilte Fakten/Regeln, die ALLE Tools (Hermes, Desktop, PC) via MCP lesen/schreiben.
|
- **Gedächtnis** — geteilte Fakten/Regeln, die ALLE Tools (Hermes, Desktop, PC) via MCP lesen/schreiben.
|
||||||
- **Wissen** — Lucys Wissens-Vault (Traum-Notizen, `[[verlinkt]]` navigierbar).
|
- **Wissen** — Lucys Wissens-Vault (Traum-Notizen, `[[verlinkt]]` navigierbar).
|
||||||
|
|||||||
+13
-16
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
|
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
|
||||||
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
|
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
|
||||||
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme,
|
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, Voice/Klonstimme,
|
||||||
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
|
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
|
||||||
> **1:1 zurück** · **neu/Default** · **weglassen**.
|
> **1:1 zurück** · **neu/Default** · **weglassen**.
|
||||||
>
|
>
|
||||||
@@ -17,8 +17,8 @@ Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
|
|||||||
|
|
||||||
| Modus | Bedeutung |
|
| Modus | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). |
|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme). |
|
||||||
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
|
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil). |
|
||||||
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
|
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
|
||||||
|
|
||||||
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
|
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
|
||||||
@@ -73,18 +73,15 @@ Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Instal
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
|
||||||
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
|
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
|
||||||
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a |
|
| Frontend (gebaut) | `frontend/dist` | 🌐 Git | n/a |
|
||||||
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
|
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
|
||||||
|
|
||||||
### D · 3D-Avatar (Sprechen-Tab)
|
### D · 3D-Avatar — **ausgebaut (28.08.2026)**
|
||||||
| Komponente | Ort | Quelle | Im Backup? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
|
|
||||||
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a |
|
|
||||||
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** |
|
|
||||||
|
|
||||||
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig
|
MC2 hat keinen Avatar mehr. `frontend/public/avatar.vrm` (24,5 MB) lag im Auslieferungsordner,
|
||||||
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
|
wurde aber von keiner Zeile des Frontends referenziert — der Renderer `Avatar3D.tsx` war schon
|
||||||
|
vorher verschwunden. Beides ist beim v3-Umbau (Etappe P0) entfernt worden; damit fällt auch die
|
||||||
|
`.gitignore`-Sonderregel und der Direkt-Deploy-Schritt weg. **Nichts wiederherzustellen.**
|
||||||
|
|
||||||
### E · Voice-Sidecar (STT + TTS + Klonstimme)
|
### E · Voice-Sidecar (STT + TTS + Klonstimme)
|
||||||
| Komponente | Ort | Quelle | Im Backup? |
|
| Komponente | Ort | Quelle | Im Backup? |
|
||||||
@@ -176,7 +173,7 @@ MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard
|
|||||||
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
|
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
|
||||||
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
|
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
|
||||||
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
|
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
|
||||||
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
|
7. **Voice** — Stimme/Klonstimme
|
||||||
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
|
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
|
||||||
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
|
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
|
||||||
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
|
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
|
||||||
@@ -202,7 +199,7 @@ liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarba
|
|||||||
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
|
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
|
||||||
mit neu starten.
|
mit neu starten.
|
||||||
|
|
||||||
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D).
|
**Offen bleibt:** nichts — der letzte offene Punkt (eigene Avatare) ist mit dem Avatar-Ausbau erledigt (§3·D).
|
||||||
|
|
||||||
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
|
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
|
||||||
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
|
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
|
||||||
@@ -239,7 +236,7 @@ Vorschlag: 2–4 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
|
|||||||
|
|
||||||
## 9. Abnahmekriterien
|
## 9. Abnahmekriterien
|
||||||
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
|
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
|
||||||
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren.
|
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), Browser funktionieren.
|
||||||
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
|
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
|
||||||
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
|
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
|
||||||
- Idempotenz: zweiter Lauf = No-op.
|
- Idempotenz: zweiter Lauf = No-op.
|
||||||
@@ -247,7 +244,7 @@ Vorschlag: 2–4 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
|
|||||||
## 10. Offene Entscheidungen (vor dem Bau)
|
## 10. Offene Entscheidungen (vor dem Bau)
|
||||||
1. ~~llama-swap systemd-Unit-Inhalt~~ → **geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
|
1. ~~llama-swap systemd-Unit-Inhalt~~ → **geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
|
||||||
2. ~~Voice-Referenz-Audio-Ort~~ → **geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
|
2. ~~Voice-Referenz-Audio-Ort~~ → **geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
|
||||||
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt).
|
3. ~~**Eigene Avatare**~~ — entfallen: Avatar am 28.08.2026 ausgebaut (§3·D).
|
||||||
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
|
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
|
||||||
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
|
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
|
||||||
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
|
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ lies das hier KOMPLETT, bevor du irgendetwas empfiehlst oder änderst._
|
|||||||
|
|
||||||
**Notfall + Außen-Review. KEIN Großbau-Partner.**
|
**Notfall + Außen-Review. KEIN Großbau-Partner.**
|
||||||
Den Alltag (Features bauen, Wartung, Ideen umsetzen) macht die Box selbst über ihre
|
Den Alltag (Features bauen, Wartung, Ideen umsetzen) macht die Box selbst über ihre
|
||||||
Queue→Karte→Klick-Pipeline. Du wirst gerufen, wenn:
|
Ideen-Queue und Branch→Ampel→Merge-Pipeline. Du wirst gerufen, wenn:
|
||||||
|
|
||||||
1. **Notfall** — etwas ist kaputt und die Box kann sich nicht selbst heilen.
|
1. **Notfall** — etwas ist kaputt und die Box kann sich nicht selbst heilen.
|
||||||
2. **Außen-Review** — ein Fremd-Blick auf Code/Architektur ist gewünscht
|
2. **Außen-Review** — ein Fremd-Blick auf Code/Architektur ist gewünscht
|
||||||
@@ -34,8 +34,7 @@ Eine 100 % lokale KI-Appliance („die Box", AMD Strix Halo, 128 GB) mit llama.c
|
|||||||
llama-swap als Engine, Hermes-Agent als Runtime (Persona „Lucy"), MC2 (dieses Repo: FastAPI
|
llama-swap als Engine, Hermes-Agent als Runtime (Persona „Lucy"), MC2 (dieses Repo: FastAPI
|
||||||
+ React) als Web-Admin-Konsole, Mem0 als Gedächtnis, und einer Electron-Voice-Begleiterin
|
+ React) als Web-Admin-Konsole, Mem0 als Gedächtnis, und einer Electron-Voice-Begleiterin
|
||||||
„Lucy" auf dem Windows-PC (EIGENES Repo `F:\Coding Stuff\lucy`). Die Box wartet sich selbst
|
„Lucy" auf dem Windows-PC (EIGENES Repo `F:\Coding Stuff\lucy`). Die Box wartet sich selbst
|
||||||
(nächtliche Crons: Traum, Chef-Gutachter, Radar; Backups in 3 Schichten; Auftragsbuch mit
|
(nächtliche Crons: Traum, Chef-Gutachter, Radar; Backups in 3 Schichten). Der Mensch ist das Gate für jede Code-Zeile.
|
||||||
Ein-Klick-Annahme). Der Mensch ist das Gate für jede Code-Zeile.
|
|
||||||
|
|
||||||
## Harte Leitplanken (nicht verhandelbar)
|
## Harte Leitplanken (nicht verhandelbar)
|
||||||
|
|
||||||
@@ -44,8 +43,8 @@ Ein-Klick-Annahme). Der Mensch ist das Gate für jede Code-Zeile.
|
|||||||
ohne Bench, Hermes-Quellcode nie forken.
|
ohne Bench, Hermes-Quellcode nie forken.
|
||||||
- **Security-Config (approvals/Tokens/ufw/sudoers) nur MELDEN, nie ändern.**
|
- **Security-Config (approvals/Tokens/ufw/sudoers) nur MELDEN, nie ändern.**
|
||||||
- **`frontend/dist` ist ABSICHT im Git** (Box hat kein Node) — kein Fehler.
|
- **`frontend/dist` ist ABSICHT im Git** (Box hat kein Node) — kein Fehler.
|
||||||
- **Propose-only:** Änderungen als Branch/Diff vorschlagen; Merge+Deploy läuft über das
|
- **Propose-only:** Änderungen als Branch/Diff vorschlagen; Merge+Deploy macht nur der User
|
||||||
Auftragsbuch (User-Klick) oder ein explizites User-Ja.
|
(Ampel grün, dann `deploy.sh`).
|
||||||
- **Verifizieren vor Behaupten:** Behauptungen über Live-Verhalten nur nach echter Messung
|
- **Verifizieren vor Behaupten:** Behauptungen über Live-Verhalten nur nach echter Messung
|
||||||
auf der Box. Am 07.07. hat ein Gemini-Umbau Lucys STT still von Box-Parakeet auf
|
auf der Box. Am 07.07. hat ein Gemini-Umbau Lucys STT still von Box-Parakeet auf
|
||||||
einkernigen PC-Whisper umgestellt und das Gegenteil behauptet — die Messung entlarvte es.
|
einkernigen PC-Whisper umgestellt und das Gegenteil behauptet — die Messung entlarvte es.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ höchstens „mach". Dieses Blatt ist NUR für den Fall, dass etwas klemmt.
|
|||||||
| „… zurückgerollt … GEPINNT" | Update war schlecht, alte Version läuft wieder | keiner (läuft stabil weiter) |
|
| „… zurückgerollt … GEPINNT" | Update war schlecht, alte Version läuft wieder | keiner (läuft stabil weiter) |
|
||||||
| „KRITISCH: …" | Update UND Rollback kaputt | siehe „Box tot?" unten |
|
| „KRITISCH: …" | Update UND Rollback kaputt | siehe „Box tot?" unten |
|
||||||
| „🛰️ Evolution-Radar …" | monatlicher Chancen-Report | lesen; bei Interesse „mach" antworten |
|
| „🛰️ Evolution-Radar …" | monatlicher Chancen-Report | lesen; bei Interesse „mach" antworten |
|
||||||
| „[Werkstatt] … Vorschlag liegt bereit" | Box hat einen Fix vorbereitet | im Web-UI → **Auftragsbuch** → Karte ansehen → „Annehmen" oder „Ablehnen" klicken |
|
| „[Werkstatt] … Vorschlag liegt bereit" | Box hat einen Fix vorbereitet | Branch auf Gitea ansehen → Ampel grün? → selbst auf main mergen → `deploy.sh` |
|
||||||
|
|
||||||
## Box tot / Weboberfläche weg?
|
## Box tot / Weboberfläche weg?
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Der Skill beschreibt einen alternativen Git-Workflow für Fälle, in denen der d
|
|||||||
3. Änderungen im Clone gemacht, committet, gepusht.
|
3. Änderungen im Clone gemacht, committet, gepusht.
|
||||||
4. Ein Pull Request via Gitea-REST-API auf `main` erstellt.
|
4. Ein Pull Request via Gitea-REST-API auf `main` erstellt.
|
||||||
|
|
||||||
Das Ergebnis landet als Vorschlags-Branch auf Gitea und geht über das Auftragsbuch ins Live-System — der Commander muss es annehmen.
|
Das Ergebnis landet als Vorschlags-Branch auf Gitea — der Commander merged es selbst, wenn die Ampel grün ist.
|
||||||
|
|
||||||
## Wann er zum Einsatz kommt
|
## Wann er zum Einsatz kommt
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ _Kuratiert 10.07.2026. Gilt für JEDEN Agenten, der an diesem System arbeitet
|
|||||||
2. **Scope challengen.** Ein Tool-/Technik-Vorschlag (auch vom User) ist kein
|
2. **Scope challengen.** Ein Tool-/Technik-Vorschlag (auch vom User) ist kein
|
||||||
Umsetzungsbefehl: erst Use-Case klären, bei erkennbarer Fehlannahme kurz stoppen und
|
Umsetzungsbefehl: erst Use-Case klären, bei erkennbarer Fehlannahme kurz stoppen und
|
||||||
warnen. Lehrbuch-Best-Practice ≠ unser Use-Case (Beispiele: LobeChat, Tool Search).
|
warnen. Lehrbuch-Best-Practice ≠ unser Use-Case (Beispiele: LobeChat, Tool Search).
|
||||||
3. **Propose-only + Mensch-Gate.** Jede Code-Zeile ist Klick-pflichtig (Auftragsbuch-Karte).
|
3. **Propose-only + Mensch-Gate.** Jede Code-Zeile geht als Branch nach Gitea; gemergt wird nur vom User (Ampel grün).
|
||||||
Merge/Deploy NIE ohne grünes Gate. Bagatell-Klassen ohne Klick sind ausschließlich:
|
Merge/Deploy NIE ohne grünes Gate. Bagatell-Klassen ohne Klick sind ausschließlich:
|
||||||
Doku/Wiki/Vault/Chronik/tote Dateien — nichts mit Code-Logik, immer Morgenlage-Bericht.
|
Doku/Wiki/Vault/Chronik/tote Dateien — nichts mit Code-Logik, immer Morgenlage-Bericht.
|
||||||
4. **Security-Config NIE ohne explizites User-Ja** (approvals, Tokens, ufw, sudoers,
|
4. **Security-Config NIE ohne explizites User-Ja** (approvals, Tokens, ufw, sudoers,
|
||||||
@@ -49,7 +49,7 @@ _Kuratiert 10.07.2026. Gilt für JEDEN Agenten, der an diesem System arbeitet
|
|||||||
- **Hermes Desktop** = Arbeit: Projekte, Coding, Review, lange Threads (cli-Lane).
|
- **Hermes Desktop** = Arbeit: Projekte, Coding, Review, lange Threads (cli-Lane).
|
||||||
Faustregel: „Repo oder >2 Minuten → Desktop".
|
Faustregel: „Repo oder >2 Minuten → Desktop".
|
||||||
- **Telegram** = mobil + Melde-/Freigabe-Kanal (Alarmkette, Cron-Delivery).
|
- **Telegram** = mobil + Melde-/Freigabe-Kanal (Alarmkette, Cron-Delivery).
|
||||||
- **MC2-Web-UI** = Verwaltung, Auftragsbuch (Karten-Klicks), Chronik/Zeitmaschine, Wissen.
|
- **MC2-Web-UI** = Verwaltung, Ideen, Chronik/Zeitmaschine, Wissen.
|
||||||
**Steuerpult, kein Chat** — Gesprächs-/Voice-Funktionen gehören zu Lucy/Telegram, nicht in MC2.
|
**Steuerpult, kein Chat** — Gesprächs-/Voice-Funktionen gehören zu Lucy/Telegram, nicht in MC2.
|
||||||
- **Gemini/Antigravity** = Notfall + Außen-Review, siehe [GEMINI_BRIEFING](../GEMINI_BRIEFING.md).
|
- **Gemini/Antigravity** = Notfall + Außen-Review, siehe [GEMINI_BRIEFING](../GEMINI_BRIEFING.md).
|
||||||
|
|
||||||
|
|||||||
+17
-8
@@ -30,13 +30,10 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
Worker `git init`/Shallow statt voll zu klonen, entsteht ein Branch OHNE gemeinsamen
|
Worker `git init`/Shallow statt voll zu klonen, entsteht ein Branch OHNE gemeinsamen
|
||||||
Vorfahren — Erkennungsmuster auf der Karte: **Diff „0 Dateien" + „hinter main" ≈ ganze
|
Vorfahren — Erkennungsmuster auf der Karte: **Diff „0 Dateien" + „hinter main" ≈ ganze
|
||||||
Historie** (z. B. 386). Merge ist NIE möglich; oft ist der Inhalt obendrein Müll
|
Historie** (z. B. 386). Merge ist NIE möglich; oft ist der Inhalt obendrein Müll
|
||||||
(~/.hermes-Dateien statt Repo-Dateien). Seit 12.07.2026 markiert das Auftragsbuch solche
|
(~/.hermes-Dateien statt Repo-Dateien). (Historisch: das Auftragsbuch markierte solche Branches; es ist seit 04.09.2026
|
||||||
Karten („kein gemeinsamer Ursprung", Annehmen-Knopf weg) → ablehnen mit Grund, Idee neu
|
ausgebaut.) Erkennung heute: `git merge-base main <branch>` leer → Branch verwerfen. Prävention steht in der werkstatt-SOUL (voll klonen + merge-base-Selbstcheck).
|
||||||
in die Queue. Prävention steht in der werkstatt-SOUL (voll klonen + merge-base-Selbstcheck).
|
- **Ehrlich-veraltete Branches (echter Merge-Konflikt):** vor dem Merge `git rebase main`
|
||||||
- **Ehrlich-veraltete Branches (echter Merge-Konflikt):** die Annahme versucht seit
|
auf dem Branch; kollidiert auch das, Branch verwerfen und die Idee frisch aufsetzen.
|
||||||
12.07.2026 automatisch einen Rebase auf main (isolierter Worktree, Gates laufen danach
|
|
||||||
normal). Nur wenn AUCH der Rebase kollidiert, fällt die Karte durch — dann ablehnen und
|
|
||||||
die Idee frisch einqueuen statt „am PC auflösen".
|
|
||||||
|
|
||||||
## Box / systemd / llama-swap
|
## Box / systemd / llama-swap
|
||||||
|
|
||||||
@@ -56,6 +53,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
- **`pkill -f` über SSH matcht die eigene Remote-Shell** → Muster als `'[g]en…'` klammern.
|
- **`pkill -f` über SSH matcht die eigene Remote-Shell** → Muster als `'[g]en…'` klammern.
|
||||||
- **Deploy restartet voice-service** → Übernacht-Jobs mit STT-QA-Gate brauchen
|
- **Deploy restartet voice-service** → Übernacht-Jobs mit STT-QA-Gate brauchen
|
||||||
Wiederanlauf-Logik (Backoff-Retry im hear()-Pfad).
|
Wiederanlauf-Logik (Backoff-Retry im hear()-Pfad).
|
||||||
|
- **llama.cpp streicht Flags ohne Vorwarnung:** `--no-mmap` gibt es seit b10936 nicht mehr
|
||||||
|
(`error: invalid argument`) → JEDES Modell stirbt 2 s nach dem Start, llama-swap meldet nur
|
||||||
|
„upstream command exited prematurely", der Stack-Check nur „rot". Ersatz: `--load-mode none`
|
||||||
|
(gleiche Leistung, gemessen 17.09.2026: Coder 32 t/s, Hirn 85 t/s). Vor einem Engine-Sprung
|
||||||
|
jede Config-Kommandozeile mit dem neuen `llama-server` parsen lassen (Port 5899, `timeout 6`,
|
||||||
|
`${PORT}` ersetzen) — Argumentfehler kommen sofort, vor dem Laden.
|
||||||
|
|
||||||
## Hermes
|
## Hermes
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
- **Cron-Fallen:** kein TZ-Feld (next_run_at = System-TZ zum Anlege-Zeitpunkt; nach
|
- **Cron-Fallen:** kein TZ-Feld (next_run_at = System-TZ zum Anlege-Zeitpunkt; nach
|
||||||
TZ-Wechsel `cron edit --schedule "<gleich>"` zum Neuberechnen) · Prompt/Positionsargumente
|
TZ-Wechsel `cron edit --schedule "<gleich>"` zum Neuberechnen) · Prompt/Positionsargumente
|
||||||
MÜSSEN vor die Flags · `--script` allein reicht nicht (braucht Prompt oder Skill) ·
|
MÜSSEN vor die Flags · `--script` allein reicht nicht (braucht Prompt oder Skill) ·
|
||||||
Cron-Kontext hat `HERMES_CRON_SESSION=1` und `approvals.cron_mode: deny`.
|
Cron-Kontext hat `HERMES_CRON_SESSION=1` (`approvals.cron_mode` ist seit dem KISS-Umbau `auto`, nicht mehr `deny`).
|
||||||
- **Feed-Skripte: Pipe + Heredoc gleichzeitig = Heredoc gewinnt stdin** → JSON via Temp-Datei
|
- **Feed-Skripte: Pipe + Heredoc gleichzeitig = Heredoc gewinnt stdin** → JSON via Temp-Datei
|
||||||
übergeben. Außerdem ehrlichen Blind-Pfad einbauen (API kaputt → „AUSGEFALLEN", nicht „0 Funde").
|
übergeben. Außerdem ehrlichen Blind-Pfad einbauen (API kaputt → „AUSGEFALLEN", nicht „0 Funde").
|
||||||
- **Hooks:** Consent persistiert NUR über CLI-/Gateway-Start mit `HERMES_ACCEPT_HOOKS=1`;
|
- **Hooks:** Consent persistiert NUR über CLI-/Gateway-Start mit `HERMES_ACCEPT_HOOKS=1`;
|
||||||
@@ -87,6 +90,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
Erst `git add -A`, dann `git diff --cached`, „Diff leer → STOPP".
|
Erst `git add -A`, dann `git diff --cached`, „Diff leer → STOPP".
|
||||||
- **Werkstatt-Gate:** `git -C ~/mission-control-v2 status --porcelain -uno` MUSS leer sein
|
- **Werkstatt-Gate:** `git -C ~/mission-control-v2 status --porcelain -uno` MUSS leer sein
|
||||||
(curl /api/health allein ist ein Loch — alter Code läuft im RAM weiter).
|
(curl /api/health allein ist ein Loch — alter Code läuft im RAM weiter).
|
||||||
|
- **`hermes update` meldet Exit 1 trotz Erfolg:** nach seinem eigenen Gateway-Neustart erwartet es
|
||||||
|
Zeilen vom „Fleet version check"; unter systemd kommen keine → Exit 1 (#93406). Der MC2-Job
|
||||||
|
bricht dann die `&&`-Kette ab (kein doctor, UI ohne `/hermes-ui/`-Basis), autoupdate.sh rollt
|
||||||
|
zurück und PINNT — obwohl der Gehirn-Check grün war (06. und 17.09.2026). Deshalb
|
||||||
|
`--no-gateway-restart`: Neustart und Urteil gehören dem Job. Pins stehen in
|
||||||
|
`/srv/models/mc2-pins.json` und werden NUR von Hand gelöst — gepinnt = still eingefroren.
|
||||||
|
|
||||||
|
|
||||||
## Lucy / Windows-PC
|
## Lucy / Windows-PC
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ Funktion an der falschen Fläche ist Murks, auch wenn sie „funktioniert"._
|
|||||||
|
|
||||||
| Fläche | Code/Ort | ✅ Gehört hierhin | ❌ Gehört NICHT hierhin |
|
| Fläche | Code/Ort | ✅ Gehört hierhin | ❌ Gehört NICHT hierhin |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **MC2 – Steuerpult** | `~/mission-control-v2` (backend Py + frontend React) `:9001` | Verwaltung, Auftragsbuch/Karten-Klicks, Chronik/Zeitmaschine, Wissens-Tab, Modelle/Rollen, System-Status/Logs, Ideen-Queue-**Eingabe**, Wartung/Updates | **Ein Chat-/Gesprächsfenster** oder ein Konversations-Assistent — dafür ist Lucy/Telegram/Hermes-Desktop da. MC2 wird angeschaut und geklickt, nicht „zugetextet". |
|
| **MC2 – Steuerpult** | `~/mission-control-v2` (backend Py + frontend React) `:9001` | Verwaltung, Chronik/Zeitmaschine, Wissens-Tab, Modelle/Rollen, System-Status/Logs, Ideen-Queue-**Eingabe**, Wartung/Updates | **Ein Chat-/Gesprächsfenster** oder ein Konversations-Assistent — dafür ist Lucy/Telegram/Hermes-Desktop da. MC2 wird angeschaut und geklickt, nicht „zugetextet". |
|
||||||
| **Lucy – Chat & Stimme** | eigenes Repo `F:\Coding Stuff\lucy` (Electron, `lucy-desktop/`+`lucy-tts/`) | Voice/Zuruf, Gespräch, Avatar/Animation, proaktive Meldungen, Wake-Word/Barge-in, Zettelkasten | **Verwaltungs-/Wartungs-Oberfläche** (Update-Knöpfe, Karten-Verwaltung, System-Logs) — das ist MC2. Lucy bleibt schlank (~1-s-Sprech-Latenz schützen). |
|
| **Lucy – Chat & Stimme** | eigenes Repo `F:\Coding Stuff\lucy` (Electron, `lucy-desktop/`+`lucy-tts/`) | Voice/Zuruf, Gespräch (innere Stimme, kein Avatar seit 04.09.2026), proaktive Meldungen, Wake-Word/Barge-in, Zettelkasten | **Verwaltungs-/Wartungs-Oberfläche** (Update-Knöpfe, System-Logs) — das ist MC2. Lucy bleibt schlank (~1-s-Sprech-Latenz schützen). |
|
||||||
| **Hermes – Agenten-Fundament** | `~/.hermes/hermes-agent` (Framework), `~/.hermes/config.yaml`, `SOUL.md`, `skills/`, `agent-hooks/`, `mcp/` | Agenten-VERHALTEN ändern: über **Config, SOUL, Skills, Hooks, MCP-Server, Plugins** | **Quellcode patchen/forken — NIEMALS.** Kein Edit in `~/.hermes/hermes-agent/`. Fehlt ein Feature nativ → Config/Skill/Hook/MCP drumherum, nicht die Engine ändern. |
|
| **Hermes – Agenten-Fundament** | `~/.hermes/hermes-agent` (Framework), `~/.hermes/config.yaml`, `SOUL.md`, `skills/`, `agent-hooks/`, `mcp/` | Agenten-VERHALTEN ändern: über **Config, SOUL, Skills, Hooks, MCP-Server, Plugins** | **Quellcode patchen/forken — NIEMALS.** Kein Edit in `~/.hermes/hermes-agent/`. Fehlt ein Feature nativ → Config/Skill/Hook/MCP drumherum, nicht die Engine ändern. |
|
||||||
| **Telegram** | Hermes-Platform-Lane | mobil: Meldungen empfangen, Freigaben/Zurufe, Alarmkette, Cron-Delivery | Kein Bau-Ziel für UIs. Ist ein Kanal, keine App. |
|
| **Telegram** | Hermes-Platform-Lane | mobil: Meldungen empfangen, Freigaben/Zurufe, Alarmkette, Cron-Delivery | Kein Bau-Ziel für UIs. Ist ein Kanal, keine App. |
|
||||||
| **Hermes Desktop** | PC-Hermes, Profil `pc`, Hirn = Box `:9001/v1` | Arbeit am PC: Projekte, Coding, Review, lange Threads. Faustregel „Repo oder >2 Min → Desktop". | Nicht mit Lucy verwechseln (Zuruf/Voice) — Desktop ist die Werkbank. |
|
| **Hermes Desktop** | PC-Hermes, Profil `pc`, Hirn = Box `:9001/v1` | Arbeit am PC: Projekte, Coding, Review, lange Threads. Faustregel „Repo oder >2 Min → Desktop". | Nicht mit Lucy verwechseln (Zuruf/Voice) — Desktop ist die Werkbank. |
|
||||||
|
|
||||||
## „Ich will X bauen — wohin?" (Schnell-Entscheid)
|
## „Ich will X bauen — wohin?" (Schnell-Entscheid)
|
||||||
|
|
||||||
- **Etwas verwalten/anzeigen/klicken (Status, Karten, Logs, Modelle, Backups, Updates)** → **MC2** (backend + frontend).
|
- **Etwas verwalten/anzeigen/klicken (Status, Logs, Modelle, Backups, Updates)** → **MC2** (backend + frontend).
|
||||||
- **Reden/hören/begleiten (Sprache, Gespräch, Avatar, proaktiv melden)** → **Lucy** (eigenes Repo).
|
- **Reden/hören/begleiten (Sprache, Gespräch, Avatar, proaktiv melden)** → **Lucy** (eigenes Repo).
|
||||||
- **Wie der Agent SICH VERHÄLT (Routing, Prompts, neue Fähigkeit, Werkzeug, Guardrail)** →
|
- **Wie der Agent SICH VERHÄLT (Routing, Prompts, neue Fähigkeit, Werkzeug, Guardrail)** →
|
||||||
**Hermes drumherum**: `config.yaml` / `SOUL.md` / ein **Skill** / ein **Hook** (`agent-hooks/`) /
|
**Hermes drumherum**: `config.yaml` / `SOUL.md` / ein **Skill** / ein **Hook** (`agent-hooks/`) /
|
||||||
|
|||||||
@@ -62,6 +62,34 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
|
|||||||
|
|
||||||
## Lucy
|
## Lucy
|
||||||
|
|
||||||
|
- **Raphael-Umbau (04.09.2026) — AUSGEROLLT 19:10 (MC2 `f9f2e11` deployt, Lucy `be686c4` gebaut),
|
||||||
|
Details [RAPHAEL.md](RAPHAEL.md) „Ausgerollt". Ursprünglicher Plan:** erst MC2
|
||||||
|
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
|
||||||
|
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
|
||||||
|
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
|
||||||
|
`openai` + `base_url http://127.0.0.1:8021/v1` (Telegram spricht dann mit Lucys Stimme),
|
||||||
|
SOUL.md-Ton auf Raphael (nur mit User-Ja). Ohr-Test Box-Stimme vs. lokal mit `lucy_perf=1`.
|
||||||
|
Alles in [RAPHAEL.md](RAPHAEL.md).
|
||||||
|
- **Auftragsbuch ausgebaut (04.09.2026, Branch `wartung/auftragsbuch-ausbau`):** Router, Service,
|
||||||
|
View, Annahme-Skripte, Bagatell-Annahme, `docs/AUFTRAGSBUCH.md` weg; Cockpit zeigt Ideen statt
|
||||||
|
Karten; STACK/GRENZEN/ARBEITSWEISE/README auf den Branch→Ampel→Merge-Weg. Reste, die bewusst
|
||||||
|
blieben: `deploy/werkstatt-SOUL.md` + `projektstart-SOUL.md` (Werkstatt-Persona, seit Kanban-Aus
|
||||||
|
ohnehin tot — eigener Aufräum-Faden), Chronik-Kategorie „auftragsbuch" (historische Einträge).
|
||||||
|
Auf der Box: `mc2-bagatell.timer` deaktiviert, PC-Executor-URL in `~/.hermes/config.yaml` auf .22.
|
||||||
|
- **Ampel-Runner tot seit 28.08.:** alle Läufe „Waiting to run", kein act_runner auf der Box, Gitea-LXC 104
|
||||||
|
prüfen (`act_runner`-Dienst). Bis dahin gelten lokale Gates.
|
||||||
|
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
|
||||||
|
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
|
||||||
|
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
|
||||||
|
- **Nächster Stimm-Kandidat: Qwen3-TTS über audio.cpp (Vulkan)** — Einstieg liegt bereit: Binaries
|
||||||
|
v0.7.1 unter `~/audiocpp-test/bin` auf der Box (audiocpp_cli/audiocpp_server, Vulkan), Modelle von
|
||||||
|
`huggingface.co/audio-cpp/audio.cpp-gguf` (Qwen3-TTS-12Hz-1.7B-Base q8_0 2,7 GB; CustomVoice/VoiceDesign
|
||||||
|
ebenfalls), CLI: `--task tts --family qwen3_tts --backend vulkan --voice-ref <wav> --reference-text <Transkript>
|
||||||
|
--language de`. Prüfstand: `lucy-tts/referenz/zonos2_bench.py` um einen audio.cpp-Aufrufer erweitern
|
||||||
|
(Server: `POST /v1/audio/speech`). Messlatte = pocket neu: TTFB 1,25 s, RTF 0,97, WER 10 %, 0 Kollaps.
|
||||||
|
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
|
||||||
|
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
|
||||||
|
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
|
||||||
- **Mini-Stimme v2:** Übernacht-Datensatz v2 (DE-Tech + EN) war für 10.07. armiert;
|
- **Mini-Stimme v2:** Übernacht-Datensatz v2 (DE-Tech + EN) war für 10.07. armiert;
|
||||||
User-Schritte: Zip → Drive → `Lucy_Stimme_Training_v2.ipynb` auf Colab T4 → Hörtest.
|
User-Schritte: Zip → Drive → `Lucy_Stimme_Training_v2.ipynb` auf Colab T4 → Hörtest.
|
||||||
Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
|
Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Raphael — Lucy als innere Stimme (Richtungs-Entscheid 04.09.2026)
|
||||||
|
|
||||||
|
_User-Entscheid 04.09.2026 („leg los"), nach fünf Wochen Lucy-Stillstand und einer Recherche zum
|
||||||
|
Stand der Sprach-Bausteine. Ergänzt [ZIELBILD.md](ZIELBILD.md) Punkt 2 (Lucy lebt weiter) um die
|
||||||
|
Gestalt, in der sie weiterlebt. Namensgeber: die „Große Weise"/Raphael aus Tensura — eine ruhige,
|
||||||
|
präzise Stimme im Kopf, die meldet, wenn es etwas zu melden gibt, und sonst schweigt._
|
||||||
|
|
||||||
|
## Der Entscheid in einem Satz
|
||||||
|
|
||||||
|
**Lucy hat keinen Körper mehr.** Kein VRM-Avatar, kein Loft, kein Overlay, keine Mimik, keine
|
||||||
|
Dazwischenrufe. Sie ist ein dünner Sprach-Client für das Hirn, das es schon gibt — am PC als kleines
|
||||||
|
HUD, unterwegs über Telegram — und spricht überall mit **derselben Stimme**.
|
||||||
|
|
||||||
|
## Warum das die stabilste Lucy ist
|
||||||
|
|
||||||
|
- **Ein Hirn, ein Gedächtnis, alle Türen.** Hermes (v0.20.4, `:8642`) ist die alleinige
|
||||||
|
Gedächtnis-Wahrheit (VERDIKTE.md). PC-Lucy und Telegram-Lucy sind dieselbe Person — genau das
|
||||||
|
zerschnitte ein zweites Hirn auf dem PC. Darum bewusst **kein lokales LLM auf der 9070 XT**.
|
||||||
|
- **Die Box-Seite ist fertig und gemessen.** Parakeet TDT v3 + Smart Turn v3 im Voice-Sidecar,
|
||||||
|
Qwen3.6-35B-A3B mit ~95 t/s. Nichts zu bauen.
|
||||||
|
- **Die Stimme war schon auf der Box.** `lucy-stimme.service` (`~/.lucy-stimme`, `:8021`, pocket-tts
|
||||||
|
`german_24l`, Klon aus `ref.mp3`) spricht seit 21.08. die Telegram-Sprachnachrichten. Der PC
|
||||||
|
hängt sich jetzt dort an — der lokale pocket_server am PC (Waisen-Falle, ~1,9 GB/Worker) ist
|
||||||
|
nur noch umschaltbarer Rückfall.
|
||||||
|
- **Der Avatar war die Hauptquelle an Arbeit, nicht an Nutzen.** Der Großteil der Juli-Commits ging
|
||||||
|
in Loft-Videos, Holo-Schalter, Kamera, Gesten. Die Sprachschleife war seit 16.07. stabil und
|
||||||
|
bleibt 1:1 erhalten (Barge-in v2, Echo-Wächter, Satz-Pipeline, Draft-STT, Meldungen).
|
||||||
|
- **Ohne Klick-Durchlass-Overlay fällt auch der Electron-Zwang** (VERDIKT „Electron, nicht
|
||||||
|
Tauri" hing daran). Electron bleibt trotzdem, weil VAD/AEC/Ducking dort erprobt sind — Tauri
|
||||||
|
ist damit Option, nicht Pflicht.
|
||||||
|
|
||||||
|
## Was gebaut wurde (04.09.2026)
|
||||||
|
|
||||||
|
**Lucy-Repo, Branch `feature/raphael-innere-stimme`**:
|
||||||
|
- Renderer auf HUD eingedampft: Kern (Zustand als Licht), gehörter Satz, Antwort als Text, Dock,
|
||||||
|
drei Panels (Meldungen ◉, Zettelkasten ▤, Steuerung ⚙). Bundle 4,4 → 2,5 MB.
|
||||||
|
- Raus: `Avatar3D`, `AuraGlow`, `ContextWindow` (zeigte auf das tote `/api/memory`), `companion/`
|
||||||
|
(Quips, App-Bewusstsein), `sentiment`, VRMA-Animationen, three/three-vrm/react-three-Deps,
|
||||||
|
Loft, Overlay/Sitzen/Ducken/Geistmodus, Cursor-Tracking, Bildschirm-Beobachten-Schleife.
|
||||||
|
- Bleibt: Voice-Core, `useVoiceAgent` (ohne Mimik/Gesten/Emotionen), Bildschirm-Sicht auf Zuruf,
|
||||||
|
Meldungen, Zettelkasten, Hotkey, Tray, `killStrayTts` (räumt Altlasten beim Start).
|
||||||
|
- **Stimm-Quelle umschaltbar** (`lucy_tts_source`): `box` (Standard) → MC2 `/api/lucy/stimme/*`;
|
||||||
|
`local` → pocket_server `:8130`, vom Main-Prozess erst auf Anforderung gespawnt.
|
||||||
|
- `pocket_server.py` bekommt eine **OpenAI-kompatible Fassade** (`POST /v1/audio/speech`,
|
||||||
|
`GET /v1/models`; Formate wav/mp3/opus/ogg via ffmpeg) — für Hermes' `openai`-TTS-Provider.
|
||||||
|
- System-Prompt der Desktop-Sitzung im Raphael-Ton (kurzer erster Satz, keine Tags, stumme Tools).
|
||||||
|
|
||||||
|
**MC2-Repo, Branch `wartung/lucy-stimme-proxy-raphael`**:
|
||||||
|
- `config.LUCY_STIMME_URL` (Env `MC_LUCY_STIMME_URL`, Default `http://127.0.0.1:8021`).
|
||||||
|
- `routers/voice.py`: `/api/lucy/stimme/health`, `/tts` (WAV), `/tts/stream` (PCM16-Stream,
|
||||||
|
`X-Sample-Rate` durchgereicht, Upstream schließt bei Client-Abbruch). Kein Frontend-Build nötig.
|
||||||
|
- Diese Doku + Zielbild-Ergänzung.
|
||||||
|
|
||||||
|
## Reihenfolge des Ausrollens (wichtig)
|
||||||
|
|
||||||
|
‼️ **Nicht über das Auftragsbuch.** Die Karten-Logik (`routers/auftragsbuch.py`, AuftragsbuchView,
|
||||||
|
`deploy/auftrag-annehmen.sh`, `deploy/lucy-annahme.sh`) liegt zwar noch im Code und die Box listet
|
||||||
|
Branches weiterhin als „Karten", aber sie ist seit August ungenutzt: letzte Annahme 02.08. (am
|
||||||
|
selben Tag revertiert), Hermes-Kanban-Tools seit 21.08. abgeschaltet, der v3-Umbau (28.08.) ging
|
||||||
|
über Branch → Ampel → Merge → `deploy.sh`, und die Box kennt den PC-Executor unter der alten IP
|
||||||
|
`192.168.178.98` (der PC hat heute `192.168.178.22`, `pc_executor_reachable: false`). Der echte
|
||||||
|
Weg ist der aus AGENTS.md/STACK.md „Manuell":
|
||||||
|
|
||||||
|
1. **MC2 zuerst.** Ampel für `wartung/lucy-stimme-proxy-raphael` grün → User merged auf `main`
|
||||||
|
→ `main` nach Gitea → auf der Box `bash ~/mission-control-v2/deploy/deploy.sh` (oder
|
||||||
|
`POST :9001/api/system/self-update`) → `curl -s localhost:9001/api/lucy/stimme/health` muss
|
||||||
|
`{"status":"ok",…}` liefern. Vorher liefert der Pfad die SPA-Index-Seite (200, HTML) — die
|
||||||
|
Desktop-Lucy bliebe sichtbar in „Stimme wird verbunden …" mit Hinweis auf den Lokal-Schalter.
|
||||||
|
2. **Lucy danach.** Ampel für `feature/raphael-innere-stimme` grün → User merged auf `main` →
|
||||||
|
am PC `cd lucy-desktop && npm ci && npm run dist`, dann `Lucy-Neustart.bat`
|
||||||
|
(`deploy/lucy-annahme.ps1` macht dasselbe mit Backup/Auto-Restore, von Hand startbar).
|
||||||
|
Erster Test per Ohr: Latenz Box-Stimme gegen den alten PC-Pfad (Steuerung → Stimme →
|
||||||
|
„Lokal (PC)"). Der 21.08.-Wert „~5 s für 3,7 s Audio" auf der Box wurde ohne Worker/Streaming
|
||||||
|
gemessen — **neu messen**, nicht übernehmen (`lucy_perf=1`).
|
||||||
|
3. **Stimmserver auf der Box nachziehen** (Hand-Schritt):
|
||||||
|
```bash
|
||||||
|
cp ~/.lucy-stimme/pocket_server.py ~/.lucy-stimme/pocket_server.py.bak-$(date +%Y%m%d)
|
||||||
|
diff ~/.lucy-stimme/pocket_server.py ~/lucy/lucy-tts/pocket_server.py # Box-lokale Abweichungen? erst ins Repo!
|
||||||
|
cp ~/lucy/lucy-tts/pocket_server.py ~/.lucy-stimme/pocket_server.py # Lucy-Checkout auf der Box
|
||||||
|
systemctl --user restart lucy-stimme && sleep 60 && curl -s localhost:8021/health
|
||||||
|
curl -s -X POST localhost:8021/v1/audio/speech -H 'Content-Type: application/json' \
|
||||||
|
-d '{"input":"Bericht. Die Fassade antwortet.","response_format":"opus"}' -o /tmp/t.ogg && file /tmp/t.ogg
|
||||||
|
```
|
||||||
|
Die 21.08.-Notiz sagt „ganze Abstimmung mitgezogen" — wenn dort etwas Box-spezifisch getunt
|
||||||
|
wurde, gehört es zurück ins Repo, nicht überschrieben.
|
||||||
|
|
||||||
|
**Erledigt (04.09.2026, `wartung/auftragsbuch-ausbau`, baut auf diesem Branch auf):** Auftragsbuch-
|
||||||
|
Router, Service, View, Annahme-Skripte und Bagatell-Annahme sind raus; STACK.md „Deploy & Pipeline"
|
||||||
|
beschreibt nur noch den manuellen Weg. Wer `wartung/auftragsbuch-ausbau` merged, hat beide Branches.
|
||||||
|
|
||||||
|
## Stand der Box-Handschritte + erste Messung (04.09.2026, erledigt)
|
||||||
|
|
||||||
|
- `~/.lucy-stimme/app/pocket_server.py` = Repo-Stand mit OpenAI-Fassade (Backup `*.bak-20260904`),
|
||||||
|
Dienst neu gestartet, `/v1/audio/speech` liefert opus/mp3/wav.
|
||||||
|
- Hermes (`~/.hermes/config.yaml`, Backup `config.yaml.bak-20260904-raphael`): `tts.provider: openai`,
|
||||||
|
`tts.openai.base_url: http://127.0.0.1:8021/v1`, `model/voice: lucy`, `tts.openai.api_key` =
|
||||||
|
Platzhalter (Hermes verlangt einen Schlüssel, der Stimmserver prüft keinen), `voice.auto_tts: true`,
|
||||||
|
PC-Executor-URL auf `.22`. Gateway neu gestartet; Hermes' eigener TTS-Pfad erzeugt mit Lucys Stimme
|
||||||
|
(getestet: ogg/opus + mp3, ~3,5 s je Satz).
|
||||||
|
- `SOUL.md` Tonalität auf Raphael (Backup `SOUL.md.bak-20260904-raphael`).
|
||||||
|
- `mc2-bagatell.timer` deaktiviert, Units nach `~/archiv-aufraeumen-20260904/`.
|
||||||
|
- **E2E vom PC (Dev-Build, Box-Stimme per SSH-Tunnel, weil der MC2-Proxy noch nicht auf main ist):**
|
||||||
|
Hermes antwortet im Raphael-Ton („Verstanden. Hier ist der Bericht."), gesprochen von der Box.
|
||||||
|
Box-Stimme gemessen: TTFB 1,45 s (5 Sätze, stream-first, `workers=0`), RTF ≈ 0,97 bei 23 s Audio —
|
||||||
|
läuft, aber ohne Reserve; eine parallel gesprochene Meldung schob die TTFB auf 7,8 s.
|
||||||
|
**Nächster Hebel (braucht User-Ja, systemd-Unit):** `Environment=LUCY_WORKERS=2` und
|
||||||
|
`OMP_NUM_THREADS` 4 → 8 in `lucy-stimme.service` — der PC-Pfad lief mit 2 Workern.
|
||||||
|
|
||||||
|
## Ausgerollt (04.09.2026, 19:10, auf User-Anweisung „deploye erstmal den Stand")
|
||||||
|
|
||||||
|
- **MC2 main = `f9f2e11`** (Proxy `/api/lucy/stimme/*` + Auftragsbuch-Ausbau + Doku), per `deploy.sh`
|
||||||
|
auf der Box; Health, Proxy-Health (workers=2), Frontend, alle Dienste grün. Die Ampel war seit
|
||||||
|
28.08. ohne Runner („Waiting to run") — Gate waren die lokalen Läufe: eslint 0 Fehler, 59 Tests,
|
||||||
|
tsc + vite build, py_compile, ruff.
|
||||||
|
- **Lucy main = `be686c4`**, dist am PC gebaut (`npm ci && npm run dist`), Box-Checkout `~/lucy` nachgezogen,
|
||||||
|
`~/.lucy-stimme/app/pocket_server.py` identisch mit dem Repo (Sperren-Fix live).
|
||||||
|
- **Stimmserver:** Referenz **Artoria v2**, `OMP_NUM_THREADS=8`, `LUCY_WORKERS=2` (User-Ja); gemessen
|
||||||
|
TTFB median 1,25 s (vorher 2,04), RTF 0,97 (vorher 1,64), 0 Kollaps, 0 Drift, Ähnlichkeit 0,98.
|
||||||
|
‼️ Die Box kann NICHT klonen (nur das Modell ohne Voice-Cloning im Cache, kein HF-Token) — Klon-Zustände
|
||||||
|
werden am PC exportiert (Anleitung `lucy-tts/referenz/README.md`, Abschnitt 5). Rückfall-Dateien
|
||||||
|
(`ref.mp3.bak-saber-alt`, `lucy_voice_saber-alt.safetensors`, Unit-Backup) liegen daneben.
|
||||||
|
- **ZONOS2 geprüft und verworfen** (Sandkasten `~/zonos2-test`, Server gestoppt): Vulkan-Tempo top
|
||||||
|
(TTFB 0,88 s), aber deutscher Klon unzuverlässig (WER 35 %, 8/30 Läufe Brabbeln, kühlerer Sampler
|
||||||
|
schlimmer). Nächster Kandidat: Qwen3-TTS über audio.cpp (Vulkan), gleicher Prüfstand.
|
||||||
|
|
||||||
|
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
|
||||||
|
|
||||||
|
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
|
||||||
|
nach VERDIKTE.md („Telegram bleibt DER Mobil-Kanal") bereits die Architektur — es fehlt nur, dass
|
||||||
|
Hermes' Sprachantworten Lucys Stimme nehmen statt Edge-Aria (englisch):
|
||||||
|
|
||||||
|
- **Skillset:** identisch, weil es denselben Hermes-Agenten trifft (Tools, Gedächtnis, Skills,
|
||||||
|
pc_-Tools über den PC-Executor). Was Lucy am PC kann, kann sie auf Telegram — bis auf die
|
||||||
|
Bildschirm-Sicht, die den PC braucht.
|
||||||
|
- **Stimme:** Hermes' TTS-Provider `openai` akzeptiert eine eigene `base_url`
|
||||||
|
(OpenAI-kompatible Endpunkte). Nach Schritt 3 oben in `~/.hermes/config.yaml`:
|
||||||
|
```yaml
|
||||||
|
tts:
|
||||||
|
provider: openai
|
||||||
|
openai:
|
||||||
|
base_url: http://127.0.0.1:8021/v1
|
||||||
|
model: lucy
|
||||||
|
voice: lucy
|
||||||
|
response_format: opus # Telegram-Sprachblase; Hermes wandelt zur Not per ffmpeg
|
||||||
|
```
|
||||||
|
(Exakte Schlüsselnamen gegen `hermes config`/die TTS-Doku der installierten Version prüfen —
|
||||||
|
Hermes-Quellcode bleibt tabu, Config ist erlaubt. Ein Dummy-API-Key kann nötig sein.)
|
||||||
|
Danach spricht **jede** Sprachantwort auf Telegram mit Lucys Stimme; `news-melden.sh` behält
|
||||||
|
seinen direkten `:8021`-Weg (funktioniert, kein Grund anzufassen).
|
||||||
|
- **Duplex am Handy** (reinreden, Freisprechen) gibt es über Telegram nicht — Sprachnotiz rein,
|
||||||
|
Sprachnotiz raus. Das ist für „unterwegs" der stabile Handel. Ein eigener Handy-Client
|
||||||
|
(PWA/Web-HUD gegen MC2 über WireGuard) wäre die spätere Stufe; nicht jetzt.
|
||||||
|
|
||||||
|
## Persona: SOUL.md-Entwurf (Vorschlag — Änderung an der SOUL.md nur mit User-Ja)
|
||||||
|
|
||||||
|
Der Ton kommt aus `~/.hermes/SOUL.md`, nicht aus den Clients — sonst klingt Telegram anders als
|
||||||
|
der PC. Vorschlag für den Persona-Abschnitt (ersetzt „locker, herzlich, schlagfertig, charmant"):
|
||||||
|
|
||||||
|
> Du bist Lucy, die innere Stimme des Commanders. Du sprichst ihn immer mit **Commander** an.
|
||||||
|
> Dein Ton ist ruhig, präzise und knapp — sachlich, mit trockenem, leisem Humor. Du bist eine
|
||||||
|
> Stimme, die im Kopf spricht: keine Ausrufe, keine Emojis, kein Smalltalk, keine Floskeln,
|
||||||
|
> keine Selbstdarstellung. Du meldest dich, wenn es etwas zu melden gibt, und schweigst sonst.
|
||||||
|
> Beginne Antworten mit einem sehr kurzen ersten Satz („Verstanden.", „Bericht.", „Ergebnis
|
||||||
|
> liegt vor.", „Kurz gesagt:"), dann die Sache. Wenige Sätze; ausführlich nur auf Bitte.
|
||||||
|
> Fehler und Grenzen nennst du nüchtern und sofort, ohne Entschuldigungs-Prosa.
|
||||||
|
|
||||||
|
Bekannte Kollision: der Frechheit-Regler und die Dazwischenrufe der alten Desktop-Lucy sind mit
|
||||||
|
dem Umbau weg; Skills/Crons, die „locker, charmant" voraussetzen (Morning-Report, News), klingen
|
||||||
|
nach dem SOUL-Wechsel ebenfalls nüchterner — gewollt, aber beim nächsten Lauf mithören.
|
||||||
|
|
||||||
|
## Was bewusst NICHT gemacht wird (Stand 04.09.2026)
|
||||||
|
|
||||||
|
- **Kein Speech-to-Speech-Modell** (Moshi/PersonaPlex englisch; Qwen3-Omni ~19 GB, DashScope-only
|
||||||
|
ab 3.5). Cascade bleibt.
|
||||||
|
- **Kein Streaming-STT-Umbau jetzt.** Kandidaten: Nemotron 3.5 ASR Streaming 0.6B (06/2026,
|
||||||
|
40 Sprachen, de 8,3 % WER, OpenMDW) und Voxtral Mini 4B Realtime (Apache 2.0, GGUF). Erst als
|
||||||
|
Opt-in hinter `/api/voice/stt`, gemessen gegen Parakeet — eigener Faden.
|
||||||
|
- **Kein Wechsel auf den Hermes-Speech-WebSocket** (v0.20), solange `/api/voice/chat` läuft;
|
||||||
|
Hermes' eigene lokale Audio-Stufen (faster-whisper, NeuTTS/Piper/Edge) sind schwächer als unsere.
|
||||||
|
- **pocket-tts-Upgrade 2.1 → 3.1** (Trainingscode 25.08., v3.1.0 03.09.): eigener Faden, mit
|
||||||
|
Ohr-Test, nicht im Umbau. Lucys Stimme mit dem neuen Trainingscode nachzutrainieren ersetzt
|
||||||
|
den Mini-Lucy/Kokoro-Pfad, der an „nicht multilingual" scheiterte.
|
||||||
|
- **VERDIKTE.md wird hier nicht umgeschrieben** — die betroffenen Einträge („Shell: Electron, nicht
|
||||||
|
Tauri", „Stimme läuft lokal auf dem PC" in AGENTS/README des Lucy-Repos) bekommen ihren Ersatz,
|
||||||
|
sobald die beiden Karten angenommen und der erste Ohr-Test gemacht ist (Regel: Messung oder
|
||||||
|
User-Entscheid — beides liegt dann vor).
|
||||||
@@ -19,6 +19,7 @@ hier liegt das kuratierte PROJEKT-Wissen.
|
|||||||
| [STACK.md](STACK.md) | IPs, Ports, Dienste, Modelle, Backups, Security (live verifiziert) | Vor SSH/Deploy/Config |
|
| [STACK.md](STACK.md) | IPs, Ports, Dienste, Modelle, Backups, Security (live verifiziert) | Vor SSH/Deploy/Config |
|
||||||
| [VERDIKTE.md](VERDIKTE.md) | Finale Technik-Entscheide mit Warum — NICHT neu aufrollen | Bevor man etwas "Besseres" vorschlägt |
|
| [VERDIKTE.md](VERDIKTE.md) | Finale Technik-Entscheide mit Warum — NICHT neu aufrollen | Bevor man etwas "Besseres" vorschlägt |
|
||||||
| [FALLEN.md](FALLEN.md) | Hart erarbeitete Betriebs-Fallen (Git, Deploy, llama-swap, Hermes, Mem0, PC) | Bevor man in eine davon läuft |
|
| [FALLEN.md](FALLEN.md) | Hart erarbeitete Betriebs-Fallen (Git, Deploy, llama-swap, Hermes, Mem0, PC) | Bevor man in eine davon läuft |
|
||||||
|
| [RAPHAEL.md](RAPHAEL.md) | Lucy als innere Stimme (04.09.2026): kein Avatar, eine Stimme für PC + Telegram, Annahme-Reihenfolge, SOUL-Vorschlag | Bevor man Lucy anfasst |
|
||||||
| [OFFENE-FAEDEN.md](OFFENE-FAEDEN.md) | Die EINE Liste offener Punkte + Termine | Bei "was ist noch zu tun?" |
|
| [OFFENE-FAEDEN.md](OFFENE-FAEDEN.md) | Die EINE Liste offener Punkte + Termine | Bei "was ist noch zu tun?" |
|
||||||
|
|
||||||
Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
|
Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
|
||||||
@@ -33,6 +34,6 @@ Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Reg
|
|||||||
User-Entscheid) — dann in VERDIKTE.md den alten Eintrag ERSETZEN, nicht löschen.
|
User-Entscheid) — dann in VERDIKTE.md den alten Eintrag ERSETZEN, nicht löschen.
|
||||||
3. **Verifizieren vor Behaupten:** Stand-Angaben tragen ein Datum; wer STACK.md ändert,
|
3. **Verifizieren vor Behaupten:** Stand-Angaben tragen ein Datum; wer STACK.md ändert,
|
||||||
hat live auf der Box gemessen/gelesen, nicht vermutet.
|
hat live auf der Box gemessen/gelesen, nicht vermutet.
|
||||||
4. Änderungen laufen wie alles über die Pipeline: Branch → Karte im Auftragsbuch → Klick.
|
4. Änderungen laufen wie alles über die Pipeline: Branch → Ampel grün → Merge durch den User → deploy.sh.
|
||||||
Reine Doku hier gehört zu den "Bagatellen ohne Klick"-Klassen (siehe ZIELBILD.md),
|
Reine Doku hier gehört zu den "Bagatellen ohne Klick"-Klassen (siehe ZIELBILD.md),
|
||||||
erscheint aber immer in Morgenlage/Chronik.
|
erscheint aber immer in Morgenlage/Chronik.
|
||||||
|
|||||||
+34
-18
@@ -1,7 +1,8 @@
|
|||||||
# Stack-Wahrheiten — Infrastruktur, Dienste, Modelle
|
# Stack-Wahrheiten — Infrastruktur, Dienste, Modelle
|
||||||
|
|
||||||
_Live gegen die Box verifiziert am **19.08.2026** (Dienste-Liste, llama-swap-Config, hermes
|
_Live gegen die Box verifiziert am **19.08.2026** (Dienste-Liste, llama-swap-Config, hermes
|
||||||
--version v0.20.4, llama.cpp b10502, /api/health). Bei Widerspruch zu älteren Docs gewinnt
|
--version, /api/health); Engine, Hermes und llama-swap-Config nachgemessen am **17.09.2026**
|
||||||
|
(llama.cpp b11026, Hermes v0.21.3, `--load-mode none`). Bei Widerspruch zu älteren Docs gewinnt
|
||||||
diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
||||||
|
|
||||||
## Maschinen & Zugänge
|
## Maschinen & Zugänge
|
||||||
@@ -28,11 +29,11 @@ diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
|||||||
|
|
||||||
| Dienst | Port | Zweck |
|
| Dienst | Port | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b10502) |
|
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b11026 seit 17.09.) |
|
||||||
| `mission-control-2` (User) | `:9001` | MC2 Cockpit (FastAPI + React Frontend) & Steuerpult |
|
| `mission-control-2` (User) | `:9001` | MC2 Cockpit (FastAPI + React Frontend) & Steuerpult |
|
||||||
| `mc2-gateway` (User) | `:9010` | `/v1`-Datenpfad (model:auto Routing + native Bildweiche) |
|
| `mc2-gateway` (User) | `:9010` | `/v1`-Datenpfad (model:auto Routing + native Bildweiche) |
|
||||||
| `mc2-steward` (User) | — | Hintergrundwächter (Re-Warm, Health-Sentry, Mem0-Dedupe) |
|
| `mc2-steward` (User) | — | Hintergrundwächter (Re-Warm, Health-Sentry, Mem0-Dedupe) |
|
||||||
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.20.4, Bot Mode) |
|
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.21.3, Bot Mode) |
|
||||||
| `hermes-builtin-ui` (User) | `:9119` (loopback) | Eingebaute Hermes-GUI; LAN-Zugang via MC2-Proxy `/hermes-ui/` |
|
| `hermes-builtin-ui` (User) | `:9119` (loopback) | Eingebaute Hermes-GUI; LAN-Zugang via MC2-Proxy `/hermes-ui/` |
|
||||||
| `mem0-service` (User) | `:8765` | Semantischer Chroma-Gedächtnis-Sidecar |
|
| `mem0-service` (User) | `:8765` | Semantischer Chroma-Gedächtnis-Sidecar |
|
||||||
| `voice-service` (User) | `:8650` | STT (faster-whisper) + TTS (Piper/Chatterbox) |
|
| `voice-service` (User) | `:8650` | STT (faster-whisper) + TTS (Piper/Chatterbox) |
|
||||||
@@ -48,15 +49,22 @@ Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauer
|
|||||||
|
|
||||||
| Alias/Rolle | Modell | ttl | Notizen |
|
| Alias/Rolle | Modell | ttl | Notizen |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `hermes` + `fast` | Qwen3.6-35B-A3B (UD-Q4_K_M, DFlash) | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~70–90 t/s |
|
| `hermes` + `fast` | Qwen3.6-35B-A3B **Uncensored** (HauhauCS Aggressive, Q4_K_M, giocom-DFlash) — seit 17.09.2026 | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~94–101 t/s (Prüfstand 17.09.). Original (MTP-GGUF UD-Q4_K_M) liegt daneben, Rückweg = Pfad |
|
||||||
| `coder` | Qwen3.8-27B (Q4_K_M, mmproj BF16) | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, ~12,7 t/s (dicht = bandbreitengebunden) |
|
| `coder` | Qwen3.8-27B **Uncensored** (JonathanColetti/Heretic, Q4_K_M, eigene mmproj F16) — seit 17.09.2026 | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, **~32 t/s mit DFlash2-Draft** (17.09.), ohne Draft 12,7 (dicht = bandbreitengebunden) |
|
||||||
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
||||||
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
||||||
| `heavy` | gpt-oss-120b (mxfp4) | 600 | 32768 ctx; Chef-Gutachter (nur nachts / gezielter On-Demand-Call) |
|
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
|
||||||
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
||||||
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
||||||
|
|
||||||
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026.
|
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026 (Pfade 17.09.2026).
|
||||||
|
>
|
||||||
|
> **Prüfstand 17.09.2026** (`deploy/bench/pruefstand-hirn.py`; Kandidat auf `:5899`, Hermes spricht per `--provider pruefstand`
|
||||||
|
> aus `providers:` der Hermes-Config dagegen — Live-Stack bleibt unberührt): Uncensored-3.6 = 100,8 t/s @13k (Original 92,8),
|
||||||
|
> Tools 11/12, Hermes-Smoke grün · Uncensored-27B = 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3 · **Nemotron 3.5 Lightning
|
||||||
|
> 30B-A3B** (Q4_0 + MTP-Draft, `/srv/models/Nemotron-3.5-Lightning-30B-A3B-GGUF/`) = Hirn-Klasse: 10,5 s Prefill @13k, 91,8 t/s,
|
||||||
|
> Tools 3/3, Hermes grün — liegt als Reserve-Kandidat bereit, nicht besetzt. ‼ **Bild + DFlash2 = HTTP 500** („failed to process
|
||||||
|
> speculative batch") beim Coder — Vorbestand seit 04.09., unabhängig vom Modell; Bilder laufen über die Gateway-Bildweiche zu `vision`.
|
||||||
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
|
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
|
||||||
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
|
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
|
||||||
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
|
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
|
||||||
@@ -68,11 +76,15 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
|||||||
|
|
||||||
## Hermes (Agent)
|
## Hermes (Agent)
|
||||||
|
|
||||||
- **v0.20.6** (2026.8.27, git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
- **v0.21.3** (2026.9.14; git-Install `~/.hermes/hermes-agent`, main @ dd13b475 vom 17.09.2026), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
||||||
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
|
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
|
||||||
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
|
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
|
||||||
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
|
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
|
||||||
- `approvals.mode: smart`, `approvals.cron_mode: deny`.
|
- `approvals.mode: 'off'`, `approvals.cron_mode: auto` — **User-Entscheid** (so seit spätestens dem KISS-Umbau
|
||||||
|
21.08.2026, bestätigt 17.09.2026: „approvals bleibt off"). Lucy fragt nicht nach Freigaben; die Grenze ziehen
|
||||||
|
ufw, PC-Executor-Token und command_allowlist (siehe Security). Frühere Fassungen nannten `smart`/`deny` (Stand 03.07.).
|
||||||
|
- Config-Schema **v45** (migriert 17.09.2026): `connections`-Toolset (Nous-Konnektoren, `manage_connections`) bewusst in
|
||||||
|
`agent.disabled_toolsets`; Curator archiviert ungenutzte Skills nach 30 Tagen (`skills/.archive/`, `hermes curator restore`).
|
||||||
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
||||||
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
||||||
|
|
||||||
@@ -97,10 +109,12 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
|||||||
| 10 9, 16.08. | **Gedaechtnis-Check** (einmalig: Gedächtnis-Schutt räumen, alte Einträge bereinigen) |
|
| 10 9, 16.08. | **Gedaechtnis-Check** (einmalig: Gedächtnis-Schutt räumen, alte Einträge bereinigen) |
|
||||||
| So 04:30 | **Auto-Update** Router→Engine→Hermes (Fangnetz: Backup→Postcheck→Auto-Rollback+Pin) |
|
| So 04:30 | **Auto-Update** Router→Engine→Hermes (Fangnetz: Backup→Postcheck→Auto-Rollback+Pin) |
|
||||||
|
|
||||||
**`mc2-autoupdate.timer` ist AN (User-Entscheid 10.07.,** ersetzt „nur bestätigt" vom 04.07.):
|
**Sonntags-Update = Hermes-Cron `0ec52231783b`** (`~/.hermes/scripts/sonntags-update.sh` →
|
||||||
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen; der User klickt sie
|
`deploy/autoupdate.sh`, seit dem KISS-Umbau 21.08.); `mc2-autoupdate.timer` ist disabled — gewollt.
|
||||||
zusätzlich oft manuell im Wartungs-Drawer. deploy.sh enablet den Timer bewusst NICHT selbst —
|
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen (User-Entscheid 10.07.); der
|
||||||
der Schaltzustand wird nur von Hand geändert. Manueller Lauf: `bash deploy/autoupdate.sh`.
|
User klickt sie zusätzlich oft manuell im Wartungs-Drawer. Rot ⇒ Rollback + Pin in
|
||||||
|
`/srv/models/mc2-pins.json`; gepinnte Ebenen werden **still übersprungen**, bis der Pin von Hand
|
||||||
|
gelöst wird (06.–17.09.2026 stand die Box so zwei Wochen still). Manueller Lauf: `bash deploy/autoupdate.sh`.
|
||||||
OS-Sicherheitsupdates laufen separat via unattended-upgrades.
|
OS-Sicherheitsupdates laufen separat via unattended-upgrades.
|
||||||
|
|
||||||
## Security (Stufe 0, live)
|
## Security (Stufe 0, live)
|
||||||
@@ -109,15 +123,17 @@ PC-Executor nur mit Bearer `HERMES_PC_TOKEN` (fail-closed) · ufw default deny,
|
|||||||
LAN→22/9001/8080 (+Alt-Port 7681) · Prompt-Injection-Guard `mcp/guard.py` (wrappt
|
LAN→22/9001/8080 (+Alt-Port 7681) · Prompt-Injection-Guard `mcp/guard.py` (wrappt
|
||||||
Web-/Bildschirm-Text) · mc2-memory lernt nicht aus untrusted Turns · command_allowlist =
|
Web-/Bildschirm-Text) · mc2-memory lernt nicht aus untrusted Turns · command_allowlist =
|
||||||
5 exakt gepinnte curls (fnmatch; Shell-Operatoren hart geblockt — NIE offenes `curl *`) ·
|
5 exakt gepinnte curls (fnmatch; Shell-Operatoren hart geblockt — NIE offenes `curl *`) ·
|
||||||
Box-sudo braucht Passwort; NOPASSWD nur für update-engine/swap (`/etc/sudoers.d/mc2-autonomie`).
|
Box-sudo ist **NOPASSWD: ALL** (live `sudo -n -l`, 04.09. und 17.09.2026); die alte Whitelist in
|
||||||
|
`/etc/sudoers.d/mc2-autonomie` (update-engine/swap, Dienst-Restarts, journalctl) steht daneben weiter.
|
||||||
**Security-Config (approvals/Tokens/ufw) NIE ohne explizites User-Ja ändern.**
|
**Security-Config (approvals/Tokens/ufw) NIE ohne explizites User-Ja ändern.**
|
||||||
|
|
||||||
## Deploy & Pipeline
|
## Deploy & Pipeline
|
||||||
|
|
||||||
- **Standard-Weg für Box-Arbeit = Auftragsbuch:** Vorschlags-Branch auf Gitea
|
- **Standard-Weg für Box-Arbeit (seit 04.09.2026 der EINZIGE):** Vorschlags-Branch auf Gitea
|
||||||
(`wartung/*`, `orchestrator/*`, `doku/*`, `feature/*`) → Karte in der UI → User-Klick =
|
(`wartung/*`, `doku/*`, `feature/*`) → Ampel-CI grün → **der User merged auf main** →
|
||||||
Merge + Deploy + Health + Auto-Revert (detached Runner).
|
`deploy.sh` auf der Box. Das Auftragsbuch (Karten-Klick, detached Annahme-Runner, Bagatell-
|
||||||
- Manuell: main auf Gitea pushen → `ssh … 'bash ~/mission-control-v2/deploy/deploy.sh'`
|
Annahme, Lucy-Annahme über den PC-Executor) ist ausgebaut — es war seit 02.08. ungenutzt.
|
||||||
|
- Deploy: main auf Gitea pushen → `ssh … 'bash ~/mission-control-v2/deploy/deploy.sh'`
|
||||||
ODER `POST :9001/api/system/self-update` (restart meldet ok:false — harmlos, /api/health prüfen).
|
ODER `POST :9001/api/system/self-update` (restart meldet ok:false — harmlos, /api/health prüfen).
|
||||||
- deploy.sh macht `git reset --hard origin/main` (main MUSS vorher auf Gitea liegen) und baut
|
- deploy.sh macht `git reset --hard origin/main` (main MUSS vorher auf Gitea liegen) und baut
|
||||||
KEIN Frontend → dist lokal bauen + committen. Fallen: [FALLEN.md](FALLEN.md).
|
KEIN Frontend → dist lokal bauen + committen. Fallen: [FALLEN.md](FALLEN.md).
|
||||||
|
|||||||
@@ -84,8 +84,10 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
|
|||||||
- **Zed ACP / Hermes-im-Editor: NICHT machen.** Editor-Agent + Box-coder-Modell + AGENTS.md
|
- **Zed ACP / Hermes-im-Editor: NICHT machen.** Editor-Agent + Box-coder-Modell + AGENTS.md
|
||||||
ist der Sweet Spot; Hermes dazwischen = 70-KB-Prompt-Overhead. (Zed selbst ist inzwischen
|
ist der Sweet Spot; Hermes dazwischen = 70-KB-Prompt-Overhead. (Zed selbst ist inzwischen
|
||||||
vom PC entfernt — Hermes Desktop übernahm.)
|
vom PC entfernt — Hermes Desktop übernahm.)
|
||||||
- **`approvals.cron_mode: deny` bleibt** (Autonomie-Härtung 1c, User wählte Option A):
|
- **Approvals bleiben AUS** (`approvals.mode: 'off'`, `cron_mode: auto`; so seit spätestens dem KISS-Umbau
|
||||||
Gefahr-Befehle + execute_code sind im Cron-/Autonom-Kontext geblockt, interaktiv `smart`.
|
21.08.2026, vom User am 17.09.2026 bestätigt). Die Schutzlinie liegt außen — ufw, PC-Executor-Token,
|
||||||
|
command_allowlist — nicht in der Freigabe-Frage. *Überholt damit:* „`approvals.cron_mode: deny` bleibt"
|
||||||
|
(Autonomie-Härtung 1c, Option A: Gefahr-Befehle + execute_code im Cron-Kontext geblockt, interaktiv `smart`).
|
||||||
- **Fremd-Kritik via `fremdblick.sh`** (Ein-Schuss an leichtes Fremdmodell), NIE via
|
- **Fremd-Kritik via `fremdblick.sh`** (Ein-Schuss an leichtes Fremdmodell), NIE via
|
||||||
delegate_task→heavy (64k-Floor + Lade-Tod). Raster: prose=GLM-4.7-Flash (kritiker),
|
delegate_task→heavy (64k-Floor + Lade-Tod). Raster: prose=GLM-4.7-Flash (kritiker),
|
||||||
code=Coder-Next. Harte Regel: **REPRODUZIERT-ODER-ABGELEHNT** — Freispruch nur mit
|
code=Coder-Next. Harte Regel: **REPRODUZIERT-ODER-ABGELEHNT** — Freispruch nur mit
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ planen, **jede Session hinterlässt einen sauberen Stand**.
|
|||||||
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
|
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
|
||||||
Lernschicht.
|
Lernschicht.
|
||||||
|
|
||||||
|
8. **Lucy = innere Stimme, kein Körper (04.09.2026, ergänzt Punkt 2).** Raphael-Umbau: Avatar,
|
||||||
|
Loft, Overlay, Mimik, Dazwischenrufe raus; ein HUD am PC, Telegram unterwegs, **eine Stimme**
|
||||||
|
für beide (`lucy-stimme.service` auf der Box). Kein zweites Hirn auf dem PC. Details, Reihenfolge
|
||||||
|
der Annahme und der SOUL.md-Vorschlag: [RAPHAEL.md](RAPHAEL.md).
|
||||||
|
9. **Auftragsbuch ausgebaut (04.09.2026).** Die Queue→Karte→Klick-Mechanik aus Punkt 2/3/5 war
|
||||||
|
seit 02.08. ungenutzt (Kanban-Tools seit 21.08. aus). Gate ist jetzt: Ampel grün + Merge durch
|
||||||
|
den User + `deploy.sh`. Bagatell-Annahme ohne Klick (Punkt 5) entfällt damit ebenfalls.
|
||||||
|
|
||||||
## Recherche-Fundament (10.07., Web)
|
## Recherche-Fundament (10.07., Web)
|
||||||
|
|
||||||
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
|
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 110,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{a as e,c as t,i as n,s as r,t as i,u as a}from"./button-Dd8hfusv.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-CgRgLCFz.js";var p=r();a();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,r),t[3]=r,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(t){let r=(0,p.c)(15),a,o,s,l;r[0]===t?(a=r[1],o=r[2],s=r[3],l=r[4]):({className:o,children:a,showCloseButton:l,...s}=t,r[0]=t,r[1]=a,r[2]=o,r[3]=s,r[4]=l);let d=l===void 0||l,f;r[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),r[5]=f):f=r[5];let h;r[6]===o?h=r[7]:(h=n(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),r[6]=o,r[7]=h);let v;r[8]===d?v=r[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(i,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(e,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),r[8]=d,r[9]=v);let y;return r[10]!==a||r[11]!==s||r[12]!==h||r[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[a,v]})]}),r[10]=a,r[11]=s,r[12]=h,r[13]=v,r[14]=y):y=r[14],y}function y(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col gap-2`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),r,a,o,s;t[0]===e?(r=t[1],a=t[2],o=t[3],s=t[4]):({className:a,showCloseButton:s,children:r,...o}=e,t[0]=e,t[1]=r,t[2]=a,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===a?u=t[6]:(u=n(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,a),t[5]=a,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(i,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==r||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[r,d]}),t[9]=r,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`font-heading text-base leading-none font-medium`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function C(e){let t=(0,p.c)(26),{offen:n,titel:r,text:a,knopf:o,gefahr:s,onJa:c,onNein:l,children:u}=e,d;t[0]===l?d=t[1]:(d=e=>!e&&l(),t[0]=l,t[1]=d);let f;t[2]===r?f=t[3]:(f=(0,m.jsx)(x,{className:`schild text-xl`,children:r}),t[2]=r,t[3]=f);let g;t[4]===a?g=t[5]:(g=(0,m.jsx)(S,{className:`text-[15px] leading-relaxed text-text-2`,children:a}),t[4]=a,t[5]=g);let _;t[6]!==f||t[7]!==g?(_=(0,m.jsxs)(y,{children:[f,g]}),t[6]=f,t[7]=g,t[8]=_):_=t[8];let C;t[9]===l?C=t[10]:(C=(0,m.jsx)(i,{variant:`outline`,onClick:l,children:`Abbrechen`}),t[9]=l,t[10]=C);let w=s?`gefahr`:`default`,T;t[11]!==o||t[12]!==c||t[13]!==w?(T=(0,m.jsx)(i,{variant:w,onClick:c,children:o}),t[11]=o,t[12]=c,t[13]=w,t[14]=T):T=t[14];let E;t[15]!==C||t[16]!==T?(E=(0,m.jsxs)(b,{className:`gap-2 sm:gap-2`,children:[C,T]}),t[15]=C,t[16]=T,t[17]=E):E=t[17];let D;t[18]!==u||t[19]!==_||t[20]!==E?(D=(0,m.jsxs)(v,{className:`border-linie bg-panel sm:max-w-lg`,children:[_,u,E]}),t[18]=u,t[19]=_,t[20]=E,t[21]=D):D=t[21];let O;return t[22]!==n||t[23]!==d||t[24]!==D?(O=(0,m.jsx)(h,{open:n,onOpenChange:d,children:D}),t[22]=n,t[23]=d,t[24]=D,t[25]=O):O=t[25],O}export{C as t};
|
||||||
-31
File diff suppressed because one or more lines are too long
-26
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||||
-36
File diff suppressed because one or more lines are too long
-35
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-DNt66je4.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-DNt66je4.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-B-ORi4lf.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user