Compare commits
89
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 | ||
|
|
3d1881f4bc | ||
|
|
2935752613 | ||
|
|
a969898a16 | ||
|
|
57492d6759 | ||
|
|
ba9ea7743f | ||
|
|
84dc34c0a3 | ||
|
|
34a9862591 | ||
|
|
f3139d2b5d | ||
|
|
6e573d5551 | ||
|
|
2210bf5c40 | ||
|
|
dc78114102 | ||
|
|
259e1b2ca8 | ||
|
|
148a039545 | ||
|
|
25dbfb2b61 | ||
|
|
fa69edc1aa | ||
|
|
9cf04c427e | ||
|
|
f446da8ae6 | ||
|
|
c2a54bd25d | ||
|
|
56668ee43d | ||
|
|
c8e5a7162b | ||
|
|
00b64b9fdc | ||
|
|
7c23ebf382 | ||
|
|
e04ace242c | ||
|
|
d880a76b6a | ||
|
|
ff319ff291 | ||
|
|
6e3440379b | ||
|
|
5f35f0d580 | ||
|
|
43fa747bc3 | ||
|
|
83c6ecc613 | ||
|
|
b316ad40ba | ||
|
|
8c371b946b | ||
|
|
1a2051e988 | ||
|
|
5d93822a7e | ||
|
|
22868795c9 | ||
|
|
9a35be91f9 | ||
|
|
241b1d54c6 | ||
|
|
eb4bcfc4b3 | ||
|
|
d2a175b0ad | ||
|
|
bf145a3f97 | ||
|
|
5b0909089f | ||
|
|
4b6c6ebfd5 | ||
|
|
0a6b009cb6 | ||
|
|
adc73c817f | ||
|
|
ff795ba026 | ||
|
|
46edbe50c1 | ||
|
|
cd7e1bc114 | ||
|
|
600d0caad6 | ||
|
|
733582900a | ||
|
|
8b14dfdc14 | ||
|
|
dced44548d | ||
|
|
4c0af92b3c | ||
|
|
5cc1e6bea1 |
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mc2-memory": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
|
||||
]
|
||||
},
|
||||
"mc2-system": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
|
||||
+55
-37
@@ -1,37 +1,55 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mc2",
|
||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--app-dir",
|
||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||
"--port",
|
||||
"9000"
|
||||
],
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mc2",
|
||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--app-dir",
|
||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||
"--port",
|
||||
"9000"
|
||||
],
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5180",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5181",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
#
|
||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
|
||||
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
@@ -42,7 +43,65 @@ jobs:
|
||||
rot=1
|
||||
else
|
||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||
|
||||
|
||||
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
|
||||
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
|
||||
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
|
||||
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
|
||||
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
|
||||
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
|
||||
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
|
||||
if [ $rot -eq 0 ]; then
|
||||
echo "-- Vitest"
|
||||
( cd frontend && npm test --silent ) || rot=1
|
||||
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
|
||||
( cd frontend && npm run lint --silent ) || rot=1
|
||||
fi
|
||||
|
||||
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
|
||||
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
|
||||
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
|
||||
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
|
||||
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
|
||||
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
|
||||
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
|
||||
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
|
||||
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
|
||||
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
|
||||
# bewusst und einmalig, nicht weil es im Weg stand:
|
||||
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
|
||||
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
|
||||
# hinter lazy() wanderten und das Budget scharf blieb.
|
||||
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
|
||||
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
|
||||
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
|
||||
# ueberlegten Plattform-Schritt.
|
||||
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
|
||||
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
|
||||
budget_dist=3145728 # Stand nach P0: 1 477 852 B
|
||||
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
|
||||
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
|
||||
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
|
||||
# Hash den falschen erwischt — und das Budget waere still immer gruen.
|
||||
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
|
||||
haupt="frontend/dist/$einstieg"
|
||||
if [ -n "$haupt" ]; then
|
||||
gz=$(gzip -c "$haupt" | wc -c)
|
||||
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
|
||||
if [ "$gz" -gt "$budget_gz" ]; then
|
||||
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
|
||||
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
|
||||
rot=1
|
||||
fi
|
||||
fi
|
||||
gesamt=$(du -sb frontend/dist | cut -f1)
|
||||
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
|
||||
if [ "$gesamt" -gt "$budget_dist" ]; then
|
||||
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
|
||||
rot=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $rot -eq 0 ]; then
|
||||
echo "== Frontend: Push nach release-dist =="
|
||||
git config --global user.name "Gitea Actions"
|
||||
|
||||
+19
-25
@@ -1,25 +1,19 @@
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Node / Vite
|
||||
frontend/node_modules/
|
||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||
|
||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
||||
frontend/public/avatar.vrm
|
||||
frontend/dist/avatar.vrm
|
||||
|
||||
# Env / local
|
||||
*.env
|
||||
.DS_Store
|
||||
|
||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Node / Vite
|
||||
frontend/node_modules/
|
||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||
|
||||
# Env / local
|
||||
*.env
|
||||
.DS_Store
|
||||
|
||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,55 +1,67 @@
|
||||
# AGENTS.md — Mission Control 2.0
|
||||
|
||||
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
|
||||
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||
|
||||
## Was das ist
|
||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
||||
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
|
||||
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
|
||||
**WICHTIG:** Alle alten Dienste wie `mem0`, `governor` oder `eigenleben` sind komplett gelöscht. Hermes ist das EINZIGE "Gehirn" und läuft autonom. Es nutzt keine alte Mem0-DB, sondern sein eigenes System.
|
||||
## Sprache (nicht verhandelbar)
|
||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
||||
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
||||
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
||||
|
||||
## Build & Deploy (die häufigste Falle)
|
||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||
|
||||
## Agentic IDE & Vibe Coding
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
||||
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
||||
|
||||
## Zeit & Umgebung
|
||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||
(siehe `backend/services/reminders.py`).
|
||||
|
||||
## Backend-Konventionen
|
||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||
keine zweite Allowlist in einem Router duplizieren.
|
||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||
|
||||
## Grenzen (Verdikt, eingehalten)
|
||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
||||
- Alles reversibel halten: Branch + Backup + Pin.
|
||||
|
||||
## Gitea
|
||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
||||
# AGENTS.md — Mission Control 2.0
|
||||
|
||||
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
|
||||
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||
|
||||
## Was das ist
|
||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
||||
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
|
||||
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
|
||||
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
|
||||
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
|
||||
|
||||
## Sprache (nicht verhandelbar)
|
||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
||||
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
||||
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
||||
|
||||
## Build & Deploy (die häufigste Falle)
|
||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||
|
||||
## Agentic IDE & Vibe Coding
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
||||
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
||||
|
||||
## Zeit & Umgebung
|
||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||
(siehe `backend/services/reminders.py`).
|
||||
|
||||
## Backend-Konventionen
|
||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||
keine zweite Allowlist in einem Router duplizieren.
|
||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
|
||||
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
|
||||
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
|
||||
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
|
||||
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
|
||||
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
|
||||
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
|
||||
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
|
||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||
|
||||
## Grenzen (Verdikt, eingehalten)
|
||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
||||
- Alles reversibel halten: Branch + Backup + Pin.
|
||||
|
||||
## Gitea
|
||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
||||
|
||||
@@ -11,19 +11,17 @@ prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
||||
|
||||
| Bahn | Was sie tut | Wo |
|
||||
|---|---|---|
|
||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue | MC2 `:9001` |
|
||||
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
||||
| **Lucy** | 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 über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
||||
Bau-Sitzung und wird nie unterbrochen.
|
||||
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
||||
|
||||
## Ports & Dienste
|
||||
|
||||
| Port | Dienst | Erreichbar |
|
||||
|---|---|---|
|
||||
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||
|
||||
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
||||
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
||||
@@ -32,9 +30,9 @@ Bau-Sitzung und wird nie unterbrochen.
|
||||
|
||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||
Auto-Update und Self-Smoke. `llama-swap` läuft als System-Unit.
|
||||
|
||||
## Der Weg einer Idee
|
||||
## Die Bau-Pipeline
|
||||
|
||||
```
|
||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
|
||||
@@ -51,26 +49,33 @@ Wächtern geboren**:
|
||||
|
||||
## Modelle & Rollen
|
||||
|
||||
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
|
||||
Gemessen auf der Box (Stand: 27.08.2026, Vulkan b10653):
|
||||
|
||||
| Rolle | Modell | Tempo | Aufgabe |
|
||||
|---|---|---|---|
|
||||
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
|
||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
|
||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
|
||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
|
||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
||||
| `coder` | Qwen3.8-27B (dicht, 27B) | **12,7 t/s** | Plant und baut — der Haupt-Coder. **131k Kontext** (ganzer Slot), multimodal |
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6–90 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm, 65k/Slot |
|
||||
| `debugger` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (multimodal) |
|
||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
|
||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
|
||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Vektorsuche + Feinsortierung |
|
||||
|
||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
||||
> Sieben Rollen, mehr nicht. Frühere Fassungen listeten hier auch `kritiker`, `scout` und
|
||||
> `dense-planer` — die Modell-Konsolidierung vom 19.08. hat sie entfernt (ein Coder, ein
|
||||
> Agent-Hirn). Der dichte 27B ist seither nicht mehr „Planer" neben dem Coder, sondern **ist**
|
||||
> der Coder. Details: `docs/wissen/STACK.md`.
|
||||
|
||||
‼️ **Dichte Modelle sind auf dieser Box bandbreitengebunden.** Der Prefill bricht mit wachsendem
|
||||
Kontext ein — beim früheren Kritiker (Devstral, dicht) waren bei 32k gemessene 63 t/s ≈ **9 Minuten
|
||||
nur zum Lesen**, weshalb er hart auf 16k gedeckelt war. Das gilt weiter für jedes dichte Modell:
|
||||
`coder` (Qwen3.8-27B) liefert ~12,7 t/s gegen ~90 t/s des MoE-Hirns — **kein Konfigfehler, sondern
|
||||
das 215-GB/s-Limit der Plattform.** Der Kritiker selbst ist seit dem 19.08. nicht mehr im Stack.
|
||||
|
||||
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
||||
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
||||
(`groups.brains`) steht auf **`persistent: true`** (Verdrängungsschutz; live gegengeprüft
|
||||
27.08.2026). Wichtig ist nicht der Schalter, sondern: **alles, was gleichzeitig warm sein muss,
|
||||
gehört in DIESELBE Gruppe** — zwei Gruppen verdrängen sich gegenseitig, und `persistent` schützt
|
||||
nicht davor (gemessen 25.07.). Details und
|
||||
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||
|
||||
## Qualitäts-Tore
|
||||
@@ -93,7 +98,7 @@ Kontext-Komprimierung löscht still die Regeln mit.
|
||||
## API (Auswahl)
|
||||
|
||||
`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)`
|
||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||
|
||||
@@ -137,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/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||
|
||||
|
||||
+57
-50
@@ -1,50 +1,57 @@
|
||||
# Savepoint — Mission Control 2.0 (MC2)
|
||||
|
||||
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
|
||||
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
|
||||
|
||||
## Was das ist (in einem Satz)
|
||||
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
|
||||
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
|
||||
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
|
||||
Begleiterin „Lucy".
|
||||
|
||||
## Architektur (Kurzform — Details in AGENTS.md/docs/)
|
||||
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
|
||||
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
|
||||
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
|
||||
(Box hat kein Node; Backend liefert die gebauten Assets aus).
|
||||
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
|
||||
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
|
||||
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
|
||||
|
||||
## Aktueller Zustand
|
||||
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
|
||||
sauber gehalten und eingefroren, nicht neu erfunden.
|
||||
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
|
||||
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
|
||||
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
|
||||
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
|
||||
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
|
||||
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
|
||||
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
|
||||
|
||||
## Nordstern & Randbedingungen (WICHTIG für den Review)
|
||||
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
|
||||
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
|
||||
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
|
||||
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
|
||||
|
||||
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
|
||||
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
|
||||
`Europe/Berlin` umgestellt.
|
||||
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
|
||||
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
|
||||
|
||||
## Was NICHT empfohlen werden soll (bereits entschieden)
|
||||
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
|
||||
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
|
||||
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
|
||||
|
||||
## Letzter Sicherungspunkt
|
||||
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
|
||||
# Savepoint — Mission Control 2.0 (MC2)
|
||||
|
||||
_Stand: 2026-08-20, nach der Aufräum-Runde. Alle Zahlen hier sind **auf der Box gemessen**, nicht
|
||||
aus Doku übernommen._
|
||||
|
||||
## Was das ist (in einem Satz)
|
||||
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
|
||||
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
|
||||
Agenten-Status, Updates & Wartung — und dient als Steuerpult für die autonome KI („Lucy").
|
||||
|
||||
## Architektur (Kurzform — Details in AGENTS.md/docs/)
|
||||
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
|
||||
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
|
||||
(Box hat kein Node; Backend liefert die gebauten Assets direkt aus).
|
||||
- **MC2-Gateway** `:9010` (`/v1`-Datenpfad, model:auto Routing + native Bildweiche).
|
||||
- **Modell-Router** `:8080` (llama-swap v250, Vulkan/RADV Engine b10502).
|
||||
- **Hermes Agent** `:8642` (`hermes-gateway`, v0.20.4) + `hermes dashboard` auf `:9119` (nur Loopback).
|
||||
- **Sidecars:** `voice_service/` (:8650, faster-whisper STT + Piper TTS), `mcp/` (4 MCP-Server),
|
||||
`client/hermes-pc` (PC-Executor :7777).
|
||||
‼️ `mem0_service/` liegt noch im Baum, ist aber **stillgelegt** — `:8765` antwortet nicht.
|
||||
|
||||
## Modelle live (gemessen 20.08.2026)
|
||||
| Rolle (Alias) | Modell | Tempo | Zustand |
|
||||
|---|---|---|---|
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B (MoE, DFlash) | **95,7 t/s** · Prompt 221 t/s | immer warm |
|
||||
| `coder` | Qwen3.8-27B (dicht, Vision) | **12,6 t/s** · Prompt 144 t/s | ttl 5400, ~29 s Kaltstart |
|
||||
| `debugger` | Muse-Glimmer-30B (DFlash) | — | ttl 600 |
|
||||
| `heavy` | gpt-oss-120b | — | on-demand |
|
||||
| `vision` | Qwen3-VL-30B-A3B-Instruct | — | on-demand |
|
||||
| `embed` / `reranker` | Qwen3-Embedding-0.6B / Qwen3-Reranker-0.6B | — | immer warm |
|
||||
|
||||
Warm-Gruppe `brains` = embed + reranker + Qwen3.6-35B-A3B. Warm-Wächter weckt nur `fast`.
|
||||
|
||||
**Hintergrund zum Tempo:** Strix Halo liefert ~215 GB/s Speicherbandbreite. Ein *dichtes* Modell
|
||||
muss pro Token seine vollen Gewichte lesen → 17 GB ÷ 215 GB/s ≈ 12,6 t/s ist das **Hardware-Limit**,
|
||||
kein Konfigurationsfehler. MoE-Modelle lesen pro Token nur einen Bruchteil und sind darum ~8× schneller.
|
||||
|
||||
## Aufgeräumt am 20.08.2026
|
||||
- CI-Ampel wieder **grün** (Lauf #77) — zwei ungenutzte `MEM0_SERVICE_URL`-Importe entfernt.
|
||||
- Kaputter systemd-Timer `mc2-morgen-digest` abgeschaltet (der Hermes-Cron macht die Arbeit).
|
||||
- `mcp-stderr.log` (27 MB, 238k Ping-Zeilen) geleert.
|
||||
- Config-Drift aufgelöst: Repo == `/etc/llama-swap/config.yaml`. Warm-Set-Idee geparkt auf
|
||||
Branch `parked/warm-set-5d93822`.
|
||||
- Entfernt: Governor-Reste, OpenCode-/aider-Reste (853 MB), 61 alte Backup-Dateien,
|
||||
verwaiste Modelle Devstral-Small-2 + GLM-4.6V-Flash + mem0 (21,5 GB) → `/srv/models` 209 → 189 GB.
|
||||
- **Fähigkeiten repariert:** `orchestrator`- und `wartung`-Skill sowie `fremdblick.sh` zeigten auf
|
||||
gelöschte Modelle (`Qwen3-Coder-Next`, `GLM-4.6V-Flash`, `GLM-4.7-Flash`) → der Kritiker-Gate war
|
||||
stumm kaputt. Jetzt auf **Rollen-Aliase** umgestellt, damit Modellwechsel sie nicht mehr brechen.
|
||||
- Alles Gelöschte liegt gesichert in `~/archiv-aufraeumen-20260820/` (11 MB).
|
||||
|
||||
## Offen
|
||||
- `mem0_service/` + `_mem0_reachable()` in `backend/routers/system.py` ausbauen (toter Port).
|
||||
- `~/.hermes/scripts/fremdblick.sh` ist **unversioniert** — gehört ins Repo.
|
||||
- MCP-Log dauerhaft deckeln (Hermes' `max_size_mb` greift für gekapertes stderr nicht).
|
||||
- Verschwundene Wächter: Ampel-Wächter, Prüfstand, Stack-Rundumschlag.
|
||||
- Coder-Tempo: Entwurfsmodelle für spekulatives Dekodieren liegen ungenutzt unter
|
||||
`/srv/models/Qwen3.8-27B-DFlash2-GGUF/`.
|
||||
|
||||
+28
-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
|
||||
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||
|
||||
Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar.
|
||||
Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen,
|
||||
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,31 +19,27 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from routers import (
|
||||
agent,
|
||||
auftragsbuch,
|
||||
chronik,
|
||||
connect,
|
||||
console,
|
||||
boxwart,
|
||||
events,
|
||||
gateway_proxy,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
maintenance,
|
||||
models,
|
||||
radar,
|
||||
routing,
|
||||
skills,
|
||||
system,
|
||||
voice,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import metrics_history, reminders, sentry, warmer
|
||||
from services import metrics_history, reminders, warmer
|
||||
from starlette.requests import Request
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
@@ -53,8 +53,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier,
|
||||
sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich
|
||||
sonst nicht selbst melden."""
|
||||
tasks: list[asyncio.Task[Any]] = []
|
||||
if warmer.ENABLED:
|
||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||
@@ -62,11 +63,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||
warmer.INTERVAL,
|
||||
)
|
||||
if sentry.ENABLED:
|
||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und
|
||||
# Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
else:
|
||||
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.")
|
||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
@@ -103,11 +107,12 @@ async def no_cache(
|
||||
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
|
||||
app.include_router(connect.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(
|
||||
voice.router
|
||||
@@ -125,18 +130,11 @@ if V1_UPSTREAM:
|
||||
else:
|
||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
||||
app.include_router(skills.router) # Skills & Jobs Dashboard
|
||||
app.include_router(
|
||||
console.router
|
||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
||||
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||
app.include_router(
|
||||
hermes_ui.router
|
||||
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
@@ -147,6 +145,10 @@ if FRONTEND_DIST.exists():
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def spa(full_path: str):
|
||||
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
||||
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||
try:
|
||||
|
||||
+144
-136
@@ -1,136 +1,144 @@
|
||||
"""
|
||||
Zentrale Konfiguration für Mission Control 2.0.
|
||||
|
||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
|
||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
||||
def _read_hermes_env(key: str) -> str:
|
||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||
try:
|
||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
HERMES_API_KEY = (
|
||||
os.environ.get("HERMES_API_KEY")
|
||||
or os.environ.get("API_SERVER_KEY")
|
||||
or _read_hermes_env("API_SERVER_KEY")
|
||||
)
|
||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
|
||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||
BOX_CONSOLE_PATH = "/console/"
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
# GitHub-Repo für Update-Checks.
|
||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
"""
|
||||
Zentrale Konfiguration für Mission Control 2.0.
|
||||
|
||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
|
||||
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
|
||||
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
|
||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
|
||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
|
||||
def _read_hermes_env(key: str) -> str:
|
||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||
try:
|
||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
HERMES_API_KEY = (
|
||||
os.environ.get("HERMES_API_KEY")
|
||||
or os.environ.get("API_SERVER_KEY")
|
||||
or _read_hermes_env("API_SERVER_KEY")
|
||||
)
|
||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
|
||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||
# passen. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||
BOX_CONSOLE_PATH = "/console/"
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
|
||||
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
|
||||
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
|
||||
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
|
||||
# same-origin unter
|
||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
# GitHub-Repo für Update-Checks.
|
||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
|
||||
"version": "2026-07-03",
|
||||
"models": [
|
||||
{
|
||||
"role": "debugger", "name": "Muse-Glimmer-30B", "repo": "unsloth/Muse-Glimmer-30B-GGUF",
|
||||
"family": "muse", "generation": 1.0, "total_params_b": 30, "active_params_b": 30,
|
||||
"moe": false, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": true
|
||||
},
|
||||
{
|
||||
"role": "coder", "name": "Qwen3.8-27B", "repo": "unsloth/Qwen3.8-27B-GGUF",
|
||||
"family": "qwen", "generation": 3.8, "total_params_b": 27, "active_params_b": 27,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": true
|
||||
},
|
||||
{
|
||||
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
|
||||
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
|
||||
|
||||
@@ -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 pydantic import BaseModel
|
||||
from services import agent_aktivitaet
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
|
||||
def set_brain_model(body: BrainReq) -> dict:
|
||||
ok = update_brain_model(body.model)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@router.get("/agent/aktivitaet")
|
||||
def aktivitaet(limit: int = 60) -> dict:
|
||||
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
|
||||
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
|
||||
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
|
||||
"""
|
||||
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
|
||||
|
||||
@@ -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,20 +0,0 @@
|
||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/connect")
|
||||
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
|
||||
kwargs = {}
|
||||
if mcp_path:
|
||||
kwargs["mcp_script_path"] = mcp_path
|
||||
return build_snippets(host=host, **kwargs)
|
||||
|
||||
|
||||
@router.get("/connect/health")
|
||||
def connect_health() -> dict:
|
||||
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
|
||||
return check_health()
|
||||
@@ -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)
|
||||
+77
-49
@@ -1,25 +1,26 @@
|
||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||
|
||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
||||
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||
|
||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
||||
invalidate-Event nur Lärm).
|
||||
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||
|
||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
||||
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
|
||||
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
|
||||
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
||||
selbst und bis dahin pollt die UI wie bisher).
|
||||
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
|
||||
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
|
||||
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
|
||||
|
||||
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
|
||||
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
|
||||
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -35,7 +36,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
||||
METRIK_S = 1.0 # Takt der Messpunkte
|
||||
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
|
||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
||||
|
||||
|
||||
@@ -50,15 +52,9 @@ def _fingerprints() -> dict[str, object]:
|
||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
||||
fp: dict[str, object] = {}
|
||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
||||
from services import announce
|
||||
fp["chronik"] = announce.list_after(None)["latest"]
|
||||
except Exception:
|
||||
pass
|
||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
||||
from services import ideen
|
||||
d = ideen.list_queue()
|
||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
||||
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||
from services import waechter
|
||||
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
||||
@@ -66,35 +62,67 @@ def _fingerprints() -> dict[str, object]:
|
||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||
except Exception:
|
||||
pass
|
||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
||||
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
|
||||
from services import jobengine
|
||||
fp["jobs"] = json.dumps(
|
||||
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs()]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
|
||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
||||
return fp
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def events(request: Request) -> StreamingResponse:
|
||||
async def strom():
|
||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
alt = _fingerprints()
|
||||
yield ": verbunden\n\n"
|
||||
seit_ping = 0.0
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
await asyncio.sleep(TICK_S)
|
||||
seit_ping += TICK_S
|
||||
async def _strom(request: Request, mit_metrik: bool):
|
||||
"""Gemeinsamer Kern beider Endpunkte.
|
||||
|
||||
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
"""
|
||||
alt = _fingerprints()
|
||||
yield ": verbunden\n\n"
|
||||
|
||||
seit_abdruck = 0.0
|
||||
seit_ping = 0.0
|
||||
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
await asyncio.sleep(takt)
|
||||
seit_abdruck += takt
|
||||
seit_ping += takt
|
||||
|
||||
if mit_metrik:
|
||||
try:
|
||||
from services.system import metrik_punkt
|
||||
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
|
||||
seit_ping = 0.0
|
||||
except Exception:
|
||||
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
|
||||
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
|
||||
|
||||
if seit_abdruck >= ABDRUCK_S:
|
||||
seit_abdruck = 0.0
|
||||
neu = _fingerprints()
|
||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||
alt.update(neu)
|
||||
if keys:
|
||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
||||
seit_ping = 0.0
|
||||
elif seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
if seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
|
||||
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
"""Anstöße UND Messpunkte."""
|
||||
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||
media_type="text/event-stream", headers=_KOPF)
|
||||
|
||||
@@ -22,6 +22,13 @@ _LANG_DIRECTIVE = os.environ.get(
|
||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||
|
||||
|
||||
# Deckel für die Bild-Weiche. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
|
||||
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
|
||||
# Request. Über _BILD_MAX Bilder wird gar nicht erst beschrieben — der Aufrufer fällt
|
||||
# dann auf die normale Vision-Umleitung zurück (ehrlich langsam statt scheinbar hängend).
|
||||
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
|
||||
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
|
||||
|
||||
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
|
||||
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
|
||||
_BILD_BESCHREIB_PROMPT = os.environ.get(
|
||||
@@ -85,6 +92,10 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
|
||||
for i, part in enumerate(m["content"]):
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
fundstellen.append((m, i, part))
|
||||
if len(fundstellen) > _BILD_MAX:
|
||||
log.warning("Bild-Weiche v2: %s Bilder (Deckel %s) — Request geht an das Vision-Modell "
|
||||
"statt einzeln beschrieben zu werden", len(fundstellen), _BILD_MAX)
|
||||
return False
|
||||
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
|
||||
frage = {
|
||||
"model": vision_alias,
|
||||
@@ -99,7 +110,7 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
|
||||
text = ""
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=240.0) as c:
|
||||
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
|
||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||
if r.status_code == 200:
|
||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||
@@ -138,7 +149,15 @@ def _inject_language(body: dict, alias: str) -> None:
|
||||
async def models(request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
data = r.json()
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
|
||||
# statt eines 500ers aus dem Parser.
|
||||
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
|
||||
"type": "upstream_error"}}, status_code=502)
|
||||
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
||||
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
||||
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
|
||||
@@ -187,7 +206,20 @@ async def models(request: Request):
|
||||
|
||||
|
||||
async def _proxy(path: str, request: Request):
|
||||
body = await request.json()
|
||||
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
|
||||
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
|
||||
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
|
||||
f"'{type(body).__name__}' bekommen.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
||||
requested = str(body.get("model") or "auto")
|
||||
if requested.lower() in ("auto", "chat", "coding"):
|
||||
|
||||
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
|
||||
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
||||
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
||||
|
||||
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
||||
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
|
||||
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
||||
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
||||
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||
`:9119/...` weiter.
|
||||
|
||||
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
||||
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
||||
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
||||
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
||||
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
|
||||
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
||||
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
||||
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
||||
|
||||
|
||||
def praefixiere_ort(ort: str) -> str:
|
||||
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
||||
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
||||
return f"/{_BASE}{ort}"
|
||||
return ort
|
||||
|
||||
|
||||
def praefixiere_html(body: bytes) -> bytes:
|
||||
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
||||
|
||||
|
||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
)
|
||||
body = r.content
|
||||
if "text/html" in r.headers.get("content-type", "").lower():
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
||||
body = praefixiere_html(body)
|
||||
resp = Response(content=body, status_code=r.status_code)
|
||||
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||
# dürfen nicht zu einer zusammenfallen.
|
||||
for k, v in r.headers.multi_items():
|
||||
kl = k.lower()
|
||||
if kl in _DROP:
|
||||
continue
|
||||
if kl == "location":
|
||||
v = praefixiere_ort(v)
|
||||
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
||||
return resp
|
||||
|
||||
|
||||
@router.get(f"/{_BASE}")
|
||||
|
||||
@@ -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 services import maintenance
|
||||
from services import geheimnisse, maintenance
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class SudoReq(BaseModel):
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class GeheimnisReq(BaseModel):
|
||||
schluessel: str
|
||||
wert: str | None = None
|
||||
|
||||
|
||||
@router.get("/maintenance/updates")
|
||||
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
|
||||
return maintenance.update_details(kind)
|
||||
|
||||
@router.post("/maintenance/check-updates")
|
||||
def check_updates(body: SudoReq) -> dict:
|
||||
res = maintenance.check_updates_job(body.sudo_password)
|
||||
def check_updates() -> dict:
|
||||
res = maintenance.check_updates_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/os-update")
|
||||
def os_update(body: SudoReq) -> dict:
|
||||
res = maintenance.os_update_job(body.sudo_password)
|
||||
def os_update() -> dict:
|
||||
res = maintenance.os_update_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/engine-update")
|
||||
def engine_update(body: SudoReq) -> dict:
|
||||
res = maintenance.engine_update_job(body.sudo_password)
|
||||
def engine_update() -> dict:
|
||||
res = maintenance.engine_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/swap-update")
|
||||
def swap_update(body: SudoReq) -> dict:
|
||||
res = maintenance.swap_update_job(body.sudo_password)
|
||||
def swap_update() -> dict:
|
||||
res = maintenance.swap_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||
return res
|
||||
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
|
||||
|
||||
|
||||
@router.post("/maintenance/update-all")
|
||||
def update_all(body: SudoReq) -> dict:
|
||||
return maintenance.update_all_job(body.sudo_password)
|
||||
def update_all() -> dict:
|
||||
return maintenance.update_all_job()
|
||||
|
||||
|
||||
@router.post("/maintenance/reboot")
|
||||
def reboot(body: SudoReq) -> dict:
|
||||
return maintenance.reboot(body.sudo_password)
|
||||
def reboot() -> dict:
|
||||
return maintenance.reboot()
|
||||
|
||||
|
||||
@router.post("/maintenance/restart")
|
||||
def restart(body: RestartReq) -> dict:
|
||||
return maintenance.restart_service(body.service, body.sudo_password)
|
||||
return maintenance.restart_service(body.service)
|
||||
|
||||
|
||||
@router.get("/maintenance/logs")
|
||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
||||
return maintenance.logs(service, lines, x_sudo_password)
|
||||
def logs(service: str, lines: int = 200) -> dict:
|
||||
return maintenance.logs(service, lines)
|
||||
|
||||
|
||||
@router.get("/maintenance/geheimnisse")
|
||||
def geheimnisse_status() -> dict:
|
||||
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||
return geheimnisse.status()
|
||||
|
||||
|
||||
@router.post("/maintenance/geheimnisse")
|
||||
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||
return {"ok": True, **geheimnisse.status()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import psutil
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import budget, discover, hf, jobengine, llamaswap
|
||||
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
|
||||
quant: str = "Q4_K_M"
|
||||
ctx: int | None = None
|
||||
jinja: bool = False
|
||||
hf_token: str | None = None
|
||||
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||
|
||||
|
||||
@router.get("/hf/search")
|
||||
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
|
||||
args.append(info["mmproj"])
|
||||
args += ["--local-dir", str(target)]
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
if token := geheimnisse.hf_token():
|
||||
env["HF_TOKEN"] = token
|
||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||
on_done=_apply_role if role else None)
|
||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||
|
||||
@@ -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,79 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
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,
|
||||
"description": description or "Keine Beschreibung verfügbar.",
|
||||
"running": 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"
|
||||
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus"]
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "err": str(e)}
|
||||
@@ -10,7 +10,7 @@ import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import backup as backup_svc
|
||||
@@ -42,13 +42,6 @@ def history(minutes: int = 60) -> dict:
|
||||
return metrics_history.history(minutes)
|
||||
|
||||
|
||||
def _mem0_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _voice_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
@@ -95,18 +88,16 @@ def services() -> dict:
|
||||
rows += [
|
||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
||||
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
|
||||
"ok": _mem0_reachable(), "scope": "user"},
|
||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_reachable(), "scope": "user"},
|
||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
||||
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||
"ok": _user_unit_active("lucy-stimme"), "scope": "user"},
|
||||
]
|
||||
return {
|
||||
"services": rows,
|
||||
|
||||
@@ -3,7 +3,7 @@ Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
||||
|
||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||
eigenem Gedächtnis wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
||||
|
||||
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
@@ -17,7 +17,7 @@ import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -169,7 +169,7 @@ def voice_metrics() -> dict:
|
||||
@router.get("/voice/trace")
|
||||
def voice_trace(limit: int = 20) -> dict:
|
||||
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
||||
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||
Generierung). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
||||
return {"turns": get_trace(limit)}
|
||||
|
||||
@@ -289,8 +289,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
||||
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung zu EINEM
|
||||
# Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||
first = True
|
||||
first_content = True
|
||||
@@ -325,7 +325,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py).
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
@@ -342,7 +342,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
||||
trace.note_ttfb()
|
||||
first = False
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT) —
|
||||
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||
if first_content and b'"content"' in chunk:
|
||||
trace.note_first_content()
|
||||
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
# LAN-only wie alle MC2-Endpoints.
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
with Timer("lucy_tts"):
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
@@ -33,7 +33,7 @@ def list_snapshots() -> dict:
|
||||
|
||||
@router.get("/zeitmaschine/inhalt")
|
||||
def snapshot_contents(file: str) -> dict:
|
||||
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
|
||||
"""Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …)."""
|
||||
if not _FILE_RX.match(file):
|
||||
raise HTTPException(400, "Ungültiger Snapshot-Name.")
|
||||
p = backup_svc.BACKUP_DIR / file
|
||||
|
||||
@@ -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}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
|
||||
Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config).
|
||||
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
|
||||
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
|
||||
"""
|
||||
@@ -70,9 +70,11 @@ def list_backups() -> list[dict]:
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||
st = p.stat()
|
||||
out.append({
|
||||
"snapshot": _ts(p),
|
||||
"file": p.name,
|
||||
"size_mb": round(p.stat().st_size / 1_000_000, 2),
|
||||
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
|
||||
|
||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
|
||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||
PRIMARY_MODEL = "coder"
|
||||
|
||||
|
||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
||||
"prompt_cache_key": False, "chat_completions": True,
|
||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
||||
|
||||
|
||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
||||
IDE_MODELS = [
|
||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
||||
]
|
||||
|
||||
|
||||
def _gw(host: str) -> str:
|
||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
||||
return f"http://{host}:{PORT}/v1"
|
||||
|
||||
|
||||
def build_snippets(host: str = DEFAULT_HOST,
|
||||
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
|
||||
mcp_python: str = "python") -> dict:
|
||||
gw = _gw(host)
|
||||
mc_url = f"http://{host}:{PORT}"
|
||||
|
||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
||||
kilo = json.dumps({
|
||||
"apiProvider": "openai",
|
||||
"openAiBaseUrl": gw,
|
||||
"openAiApiKey": "local",
|
||||
"openAiModelId": PRIMARY_MODEL,
|
||||
}, indent=2)
|
||||
|
||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
||||
hermes_desktop = (
|
||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
||||
f"#\n"\
|
||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
||||
f"#\n"\
|
||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
||||
f"#\n"\
|
||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
||||
f"#\n"\
|
||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
||||
)
|
||||
|
||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
||||
claude_code = (
|
||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
||||
f"\n"
|
||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
||||
f'export ANTHROPIC_MODEL="coder"'
|
||||
)
|
||||
|
||||
memory_mcp = json.dumps({
|
||||
"mcpServers": {
|
||||
"mission-control-memory": {
|
||||
"command": mcp_python,
|
||||
"args": [mcp_script_path],
|
||||
"env": {"MC_URL": mc_url},
|
||||
}
|
||||
}
|
||||
}, indent=2)
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"gateway_url": gw,
|
||||
"mc_url": mc_url,
|
||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
||||
# Claude Code (starke Sessions).
|
||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
||||
"tools": {
|
||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
||||
},
|
||||
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
|
||||
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
|
||||
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
|
||||
}
|
||||
|
||||
|
||||
def check_health() -> dict:
|
||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
||||
sonst llama-swap direkt), Leitung 2 = Gedächtnis-Sidecar (Mem0),
|
||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
||||
if r.status_code == 200:
|
||||
n = len(r.json().get("data", []))
|
||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||
else:
|
||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||
@@ -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()
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||
"""Job-Prozess starten und mitschreiben.
|
||||
|
||||
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
||||
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
||||
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
||||
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
||||
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
try:
|
||||
actual_args = list(args)
|
||||
if sudo_password is not None:
|
||||
for i, arg in enumerate(actual_args):
|
||||
if isinstance(arg, str):
|
||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
||||
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
_PROCS[job_id] = proc
|
||||
|
||||
if sudo_password is not None and proc.stdin:
|
||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
||||
proc.stdin.flush()
|
||||
proc.stdin.close()
|
||||
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||
|
||||
# Check if failed due to sudo authorization failure
|
||||
if proc.returncode != 0 and job["log"]:
|
||||
log_str = "\n".join(job["log"])
|
||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||
job["sudo_failed"] = True
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
||||
group: str | None = None) -> str:
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
# Mask password in log if present in args
|
||||
log_args = list(args)
|
||||
JOBS[job_id] = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
|
||||
}
|
||||
if on_done:
|
||||
JOBS[job_id]["_on_done"] = on_done
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
|
||||
@@ -474,6 +474,21 @@ def set_role(model_id: str, role: str | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def add_role(model_id: str, role: str) -> bool:
|
||||
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||
cfg = read_config()
|
||||
models = cfg.get("models") or {}
|
||||
rolle = (role or "").strip().lower()
|
||||
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||
return False
|
||||
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||
models[model_id]["aliases"] = bisher + [rolle]
|
||||
write_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||
cfg = read_config()
|
||||
|
||||
+821
-801
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -3,7 +3,7 @@ Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
|
||||
|
||||
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
|
||||
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
|
||||
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
|
||||
- **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`;
|
||||
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
||||
|
||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""
|
||||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
||||
|
||||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
|
||||
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
|
||||
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
|
||||
|
||||
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
|
||||
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
|
||||
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
|
||||
|
||||
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
|
||||
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from services import announce, llamaswap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
||||
|
||||
|
||||
def _reach(url: str, path: str = "/health") -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
return c.get(f"{url}{path}").status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_engine() -> bool:
|
||||
return llamaswap.engine_reachable()
|
||||
|
||||
|
||||
def _check_brain() -> bool:
|
||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
||||
st = llamaswap.brain_status()
|
||||
if st.get("ready"):
|
||||
return True
|
||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
||||
|
||||
|
||||
def _check_disk() -> bool:
|
||||
try:
|
||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
||||
except Exception:
|
||||
return True # kein Messwert ≠ Alarm
|
||||
|
||||
|
||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
||||
CHECKS: dict[str, tuple] = {
|
||||
"engine": (_check_engine,
|
||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
||||
"Die Modell-Engine ist wieder da."),
|
||||
"brain": (_check_brain,
|
||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
||||
"hermes": (lambda: _reach(HERMES_API_URL),
|
||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||||
"Der Agent-Dienst läuft wieder."),
|
||||
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
|
||||
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
|
||||
"Mein Gedächtnis ist wieder online."),
|
||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
||||
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
|
||||
"Der Hör-Dienst läuft wieder."),
|
||||
"disk": (_check_disk,
|
||||
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
|
||||
"Die Platte hat wieder genug Luft."),
|
||||
}
|
||||
|
||||
|
||||
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
|
||||
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
|
||||
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
|
||||
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
||||
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
|
||||
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
|
||||
"Das Steuerpult ist wieder da.")
|
||||
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
|
||||
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
|
||||
"Der Modell-Gateway läuft wieder.")
|
||||
|
||||
|
||||
class _Watch:
|
||||
__slots__ = ("alert_ts", "alerted", "fails")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fails = 0 # Fehl-Ticks in Folge
|
||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
||||
|
||||
|
||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
||||
|
||||
|
||||
def _notify_telegram(subject: str, text: str) -> None:
|
||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
||||
legt der Wächter selbst ab."""
|
||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
||||
|
||||
|
||||
def _tick() -> None:
|
||||
now = time.time()
|
||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
||||
w = _watches[name]
|
||||
try:
|
||||
ok = bool(check())
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
w.fails = 0
|
||||
if w.alerted:
|
||||
w.alerted = False
|
||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
||||
continue
|
||||
w.fails += 1
|
||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
||||
if due:
|
||||
prefix = "" if not w.alerted else "Immer noch: "
|
||||
w.alerted = True
|
||||
w.alert_ts = now
|
||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
||||
|
||||
|
||||
async def sentry_loop() -> None:
|
||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
||||
await asyncio.sleep(START_DELAY)
|
||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_tick)
|
||||
except Exception:
|
||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
+75
-27
@@ -8,8 +8,10 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
@@ -126,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
||||
|
||||
|
||||
def get_engine_version() -> dict:
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
||||
git_info = get_git_info(engine_path)
|
||||
if git_info:
|
||||
return {**git_info, "type": "git"}
|
||||
|
||||
candidates = [
|
||||
os.path.join(engine_path, "llama-server"),
|
||||
os.path.join(engine_path, "bin", "llama-server"),
|
||||
"/usr/local/bin/llama-server",
|
||||
"/usr/bin/llama-server",
|
||||
"llama-server"
|
||||
]
|
||||
|
||||
for binary in candidates:
|
||||
if binary != "llama-server" and not os.path.exists(binary):
|
||||
continue
|
||||
try:
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
||||
if output:
|
||||
lines = output.splitlines()
|
||||
ver = lines[0] if lines else "unknown"
|
||||
return {"version_text": ver, "type": "binary"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"type": "unknown"}
|
||||
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||
|
||||
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||
binary = os.path.join(engine_path, "llama-server")
|
||||
if not os.path.exists(binary):
|
||||
return {"type": "unknown"}
|
||||
try:
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||
text = (res.stdout or "") + (res.stderr or "")
|
||||
except Exception:
|
||||
return {"type": "unknown"}
|
||||
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||
|
||||
|
||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||
@@ -197,5 +191,59 @@ def system_status() -> dict:
|
||||
"gpu": _gpu_sysfs(),
|
||||
"temp": _temps(),
|
||||
"disk": disk,
|
||||
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
|
||||
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
|
||||
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
|
||||
"uptime_s": _uptime_s(),
|
||||
"versions": check_versions_cached(),
|
||||
}
|
||||
|
||||
|
||||
def metrik_punkt() -> dict:
|
||||
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
|
||||
|
||||
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
|
||||
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
|
||||
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
|
||||
|
||||
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
|
||||
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
|
||||
|
||||
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
|
||||
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
|
||||
verfaelscht nichts."""
|
||||
vm = psutil.virtual_memory()
|
||||
temp = _temps() or {}
|
||||
gpu = _gpu_sysfs() or {}
|
||||
try:
|
||||
from services.token_stats import get_stats
|
||||
tok = get_stats()
|
||||
except Exception:
|
||||
tok = {}
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception:
|
||||
disk = None
|
||||
return {
|
||||
"cpu": psutil.cpu_percent(interval=None),
|
||||
"ram": vm.percent,
|
||||
"ram_used": vm.used,
|
||||
"ram_total": vm.total,
|
||||
"gpu": gpu.get("busy_percent"),
|
||||
"disk": disk,
|
||||
"temp_cpu": temp.get("cpu"),
|
||||
"temp_gpu": temp.get("gpu"),
|
||||
"uptime_s": _uptime_s(),
|
||||
"tok_p": tok.get("prompt_tokens", 0),
|
||||
"tok_c": tok.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
|
||||
def _uptime_s() -> int | None:
|
||||
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
|
||||
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
|
||||
try:
|
||||
return int(time.time() - psutil.boot_time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
@@ -11,14 +11,14 @@ Zwei Sichten auf dieselben Messungen:
|
||||
|
||||
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
|
||||
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
|
||||
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
|
||||
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
|
||||
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
|
||||
**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es
|
||||
gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
|
||||
**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket.
|
||||
|
||||
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
|
||||
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
|
||||
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
|
||||
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
|
||||
STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
|
||||
schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom
|
||||
nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client
|
||||
nötig.
|
||||
|
||||
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
||||
"""
|
||||
@@ -34,11 +34,11 @@ _STAGES: dict[str, deque] = {}
|
||||
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
|
||||
|
||||
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
||||
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
|
||||
STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts")
|
||||
|
||||
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
|
||||
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
|
||||
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
|
||||
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
|
||||
# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
|
||||
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None}
|
||||
|
||||
|
||||
def record_stage(stage: str, ms: float) -> None:
|
||||
@@ -53,8 +53,8 @@ def record_stage(stage: str, ms: float) -> None:
|
||||
|
||||
|
||||
def park(kind: str, ms: float) -> None:
|
||||
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
|
||||
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
|
||||
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken,
|
||||
damit der nächste Chat-Turn sie einsammeln kann."""
|
||||
if ms is None or ms < 0 or kind not in _PARKED:
|
||||
return
|
||||
with _LOCK:
|
||||
@@ -71,17 +71,6 @@ def _take_stt(max_age: float = 20.0) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
|
||||
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
|
||||
Turns) und frisch ist."""
|
||||
with _LOCK:
|
||||
v = _PARKED.get("retrieve")
|
||||
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
|
||||
_PARKED["retrieve"] = None
|
||||
return round(v[0], 1)
|
||||
return None
|
||||
|
||||
|
||||
class Timer:
|
||||
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
||||
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
||||
@@ -102,8 +91,7 @@ class TurnTrace:
|
||||
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
|
||||
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
|
||||
|
||||
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
|
||||
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
|
||||
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen."""
|
||||
|
||||
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
|
||||
self.id = uuid.uuid4().hex[:8]
|
||||
@@ -113,7 +101,7 @@ class TurnTrace:
|
||||
self.session_id = (session_id or "")[:24]
|
||||
self.kind = kind
|
||||
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
|
||||
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
|
||||
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT)
|
||||
self.had_images = False
|
||||
self.error: str | None = None
|
||||
|
||||
@@ -130,7 +118,7 @@ class TurnTrace:
|
||||
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
|
||||
|
||||
def note_first_content(self) -> None:
|
||||
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
|
||||
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT)."""
|
||||
ms = (time.perf_counter() - self._brain0) * 1000.0
|
||||
self.hirn_ms = round(ms, 1)
|
||||
record_stage("chat_first_content", ms)
|
||||
@@ -138,7 +126,6 @@ class TurnTrace:
|
||||
def commit(self) -> dict:
|
||||
total = (time.perf_counter() - self.perf0) * 1000.0
|
||||
stt = _take_stt() # rollend bereits in /voice/stt erfasst
|
||||
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
|
||||
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
|
||||
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
|
||||
rec = {
|
||||
@@ -151,7 +138,6 @@ class TurnTrace:
|
||||
"vision_ms": self.vision_ms,
|
||||
"hirn_ms": self.hirn_ms,
|
||||
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
|
||||
"mem0_ms": mem0,
|
||||
"total_ms": round(total, 1),
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
@@ -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])}
|
||||
+12
-14
@@ -1,24 +1,27 @@
|
||||
"""
|
||||
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
||||
|
||||
Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
|
||||
Bisher hingen Re-Warm-Wächter und Health-Wächter am
|
||||
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
|
||||
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
|
||||
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
|
||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
||||
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
|
||||
Mini-Dienst (mc2-steward.service, Restart=always):
|
||||
|
||||
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
||||
In-Process-Nudge aus llamaswap.write_config)
|
||||
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
|
||||
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
|
||||
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
|
||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
||||
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
|
||||
|
||||
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
||||
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
|
||||
|
||||
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
|
||||
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
|
||||
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
|
||||
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
|
||||
|
||||
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
|
||||
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED)
|
||||
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
|
||||
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
|
||||
"""
|
||||
@@ -28,8 +31,7 @@ import logging
|
||||
import os
|
||||
|
||||
from config import CONFIG_PATH
|
||||
from services import memory as memory_svc
|
||||
from services import sentry, warmer
|
||||
from services import waechter, warmer
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
@@ -64,12 +66,8 @@ async def main() -> None:
|
||||
tasks.append(asyncio.create_task(config_watch()))
|
||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
||||
warmer.INTERVAL, CONFIG_WATCH_S)
|
||||
if sentry.ENABLED:
|
||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
||||
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
||||
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
|
||||
if waechter.ENABLED:
|
||||
tasks.append(asyncio.create_task(waechter.waechter_loop()))
|
||||
if not tasks:
|
||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||
return
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -28,7 +28,7 @@ hint=""
|
||||
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
|
||||
ctx="$(cat <<EOF
|
||||
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
|
||||
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
|
||||
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weiterer Dienst: hermes-gateway :8642 (fuehrt sein Gedaechtnis selbst — es gibt KEINEN separaten Gedaechtnis-Dienst mehr). Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
|
||||
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
|
||||
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
|
||||
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
|
||||
|
||||
@@ -67,6 +67,22 @@ jobs:
|
||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||
|
||||
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
|
||||
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
|
||||
# reicht). Gemessen wird der frische Build, nicht das committete dist.
|
||||
if [ -d "$d/dist/assets" ]; then
|
||||
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
|
||||
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
|
||||
# falschen — das Budget waere still immer gruen.
|
||||
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
|
||||
haupt="$d/$einstieg"
|
||||
if [ -n "$haupt" ]; then
|
||||
gz=$(gzip -c "$haupt" | wc -c)
|
||||
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
|
||||
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
|
||||
fi
|
||||
fi
|
||||
done <<< "$pkg_dateien"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
|
||||
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
|
||||
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
|
||||
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
|
||||
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
|
||||
#
|
||||
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
|
||||
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
|
||||
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
|
||||
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
|
||||
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
|
||||
#
|
||||
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
|
||||
set -uo pipefail
|
||||
|
||||
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
|
||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
||||
API="${MC_API:-http://127.0.0.1:9001}"
|
||||
SLUG="$(echo "$BRANCH" | tr '/' '-')"
|
||||
WT="/tmp/annahme-$SLUG"
|
||||
LOG="/tmp/annahme-$SLUG.log"
|
||||
|
||||
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
||||
|
||||
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
||||
status(){ # $1=state $2=detail
|
||||
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
|
||||
import json, os, sys, time
|
||||
p, branch, state, detail = sys.argv[1:5]
|
||||
try:
|
||||
d = json.load(open(p, encoding="utf-8"))
|
||||
except Exception:
|
||||
d = {}
|
||||
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
|
||||
tmp = p + ".tmp"
|
||||
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
os.replace(tmp, p)
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
|
||||
|
||||
fail(){
|
||||
status "fehlgeschlagen" "$1"
|
||||
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
||||
cleanup_wt
|
||||
exit 1
|
||||
}
|
||||
|
||||
status "laeuft" "Merge wird vorbereitet"
|
||||
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
|
||||
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
||||
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
||||
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
||||
|
||||
PRE="$(git rev-parse origin/main)"
|
||||
|
||||
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
|
||||
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
|
||||
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
||||
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
||||
|
||||
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
|
||||
cleanup_wt
|
||||
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
||||
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
|
||||
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
||||
git -C "$WT" merge --abort 2>/dev/null || true
|
||||
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
|
||||
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
|
||||
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
||||
WT2="/tmp/annahme-rebase-$SLUG"
|
||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||
REBASED=""
|
||||
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
||||
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
rebase origin/main >>"$LOG" 2>&1; then
|
||||
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
||||
else
|
||||
git -C "$WT2" rebase --abort 2>/dev/null || true
|
||||
fi
|
||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
||||
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
||||
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
||||
fi
|
||||
|
||||
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
|
||||
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
|
||||
if [ -n "${PYS// /}" ]; then
|
||||
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
|
||||
# shellcheck disable=SC2086
|
||||
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
|
||||
fi
|
||||
|
||||
status "laeuft" "Push nach main läuft"
|
||||
PUSHED=0
|
||||
for _ in 1 2 3; do
|
||||
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
||||
cleanup_wt
|
||||
|
||||
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
|
||||
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
|
||||
|
||||
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
|
||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
|
||||
DEPLOY_OK=1
|
||||
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
|
||||
|
||||
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
|
||||
HEALTH=0
|
||||
if [ "$DEPLOY_OK" = 1 ]; then
|
||||
for _ in $(seq 1 18); do
|
||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$HEALTH" = 1 ]; then
|
||||
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
|
||||
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
||||
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
|
||||
status "eingespielt" "Deploy grün, Health-Check bestanden"
|
||||
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
|
||||
status "rollback" "Health rot — automatischer Revert läuft"
|
||||
git fetch -q origin || true
|
||||
cleanup_wt
|
||||
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
||||
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
||||
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
||||
cleanup_wt
|
||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
|
||||
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
|
||||
R=0
|
||||
for _ in $(seq 1 12); do
|
||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
|
||||
sleep 5
|
||||
done
|
||||
if [ "$R" = 1 ]; then
|
||||
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
|
||||
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
cleanup_wt
|
||||
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
|
||||
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
|
||||
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|
||||
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
|
||||
exit 2
|
||||
+62
-1
@@ -123,7 +123,7 @@ check_hermes(){
|
||||
done
|
||||
if [ "$state" = "done" ]; then
|
||||
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
|
||||
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
|
||||
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
|
||||
return
|
||||
fi
|
||||
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
|
||||
@@ -165,6 +165,63 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
||||
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
||||
}
|
||||
|
||||
# ── Neustart, wenn das OS einen verlangt ─────────────────────────────────────
|
||||
# Kernel- und libc-Updates werden erst nach einem Neustart wirksam; bis dahin
|
||||
# laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am
|
||||
# 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da.
|
||||
#
|
||||
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an
|
||||
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
|
||||
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
|
||||
#
|
||||
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
|
||||
# startet systemd die User-Dienste nach einem Neustart NICHT — die Box kaeme ohne
|
||||
# Steuerpult, Gateway und Waechter hoch, und niemand koennte sie aus der Ferne
|
||||
# wieder anschalten. Im Zweifel lieber nicht neustarten.
|
||||
#
|
||||
# Abschalten: MC_AUTOUPDATE_REBOOT=0 in der Umgebung des Cron-Jobs.
|
||||
reboot_wenn_noetig(){
|
||||
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
|
||||
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
|
||||
|
||||
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr "
|
||||
" " ")"
|
||||
|
||||
# Sicherung 1: kommen die Dienste ohne Login von selbst wieder hoch?
|
||||
# $USER ist in systemd-/Cron-Umgebungen NICHT gesetzt — mit set -u waere das ein
|
||||
# sofortiger Abbruch des ganzen Update-Laufs. id -un funktioniert immer.
|
||||
local nutzer; nutzer="$(id -un)"
|
||||
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
|
||||
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Sicherung 2: kein halbfertiges Update mitten im Neustart abwuergen.
|
||||
local laufend
|
||||
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
|
||||
if [ "${laufend:-0}" -gt 0 ]; then
|
||||
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Sicherung 3: die Dienste muessen fuer den Autostart vorgemerkt sein.
|
||||
local fehlend=""
|
||||
for dienst in mission-control-2 mc2-gateway mc2-steward; do
|
||||
systemctl --user is-enabled "$dienst" >/dev/null 2>&1 || fehlend="$fehlend $dienst"
|
||||
done
|
||||
if [ -n "$fehlend" ]; then
|
||||
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
|
||||
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
|
||||
sleep 20 # der Meldung Zeit lassen, rauszugehen
|
||||
sudo -n systemctl reboot || {
|
||||
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Lauf ─────────────────────────────────────────────────────────────────────
|
||||
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
|
||||
pins_init
|
||||
@@ -180,3 +237,7 @@ check_hermes
|
||||
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
$(printf '• %s\n' "${SUMMARY[@]}")"
|
||||
say "Fertig."
|
||||
|
||||
# Ganz zuletzt: der Neustart wuerde die Abschlussmeldung oben sonst verschlucken.
|
||||
# Die Funktion meldet in jedem Fall selbst, ob sie neustartet oder warum nicht.
|
||||
reboot_wenn_noetig
|
||||
|
||||
+8
-9
@@ -3,8 +3,10 @@
|
||||
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
|
||||
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
|
||||
#
|
||||
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
|
||||
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
|
||||
# Inhalt: ~/.hermes (config.yaml, .env, plugins/) · /etc/llama-swap/config.yaml ·
|
||||
# known-good/ (Versions-Manifest, s. u.)
|
||||
# Das abgeloeste Gedaechtnis-Sidecar (/srv/models, Port 8765) faellt seit 27.08.2026 weg —
|
||||
# sein Datenverzeichnis existiert nicht mehr (Hintergrund: docs/wissen/VERDIKTE.md).
|
||||
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
|
||||
#
|
||||
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
|
||||
@@ -20,16 +22,14 @@ RETAIN="${MC_BACKUP_RETAIN:-14}"
|
||||
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
|
||||
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
|
||||
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
|
||||
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
|
||||
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
|
||||
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
|
||||
mkdir -p "$STAGE/hermes" "$STAGE/llama-swap"
|
||||
|
||||
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
|
||||
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
|
||||
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
|
||||
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
|
||||
@@ -60,7 +60,7 @@ SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
|
||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||
|
||||
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
|
||||
# pip freeze je venv; das voice-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
|
||||
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|
||||
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
|
||||
"$1" -m pip freeze > "$2" 2>/dev/null \
|
||||
@@ -68,7 +68,6 @@ freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|
||||
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
|
||||
}
|
||||
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
|
||||
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
|
||||
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
|
||||
|
||||
{
|
||||
@@ -79,7 +78,7 @@ freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
|
||||
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
|
||||
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
|
||||
echo "python-venvs :"
|
||||
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
|
||||
for P in "$SRC/backend/.venv/bin/python" "$HOME/.voice/venv/bin/python"; do
|
||||
echo " $P → $("$P" --version 2>&1 || echo 'fehlt')"
|
||||
done
|
||||
} > "$KG/versions.txt" || true
|
||||
@@ -92,7 +91,7 @@ cat > "$STAGE/MANIFEST.txt" <<EOF
|
||||
mc2-state backup
|
||||
created : $TS
|
||||
host : $(hostname)
|
||||
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
|
||||
inhalt : hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
|
||||
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
|
||||
EOF
|
||||
|
||||
|
||||
@@ -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}"
|
||||
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||
PORT="${BENCH_PORT:-5899}"
|
||||
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
|
||||
BASE="-c 65536 -ngl 999 -fa on --load-mode none --jinja --parallel 1"
|
||||
|
||||
run() { # $1=name $2=extra-flags
|
||||
echo "=== $1 ==="
|
||||
|
||||
@@ -13,7 +13,7 @@ PORT="${BENCH_PORT:-5899}"
|
||||
CTX="${BENCH_CTX:-32768}"
|
||||
|
||||
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
|
||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --load-mode none --jinja $EXTRA \
|
||||
>/tmp/model-bench-server.log 2>&1 &
|
||||
PID=$!
|
||||
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
||||
|
||||
@@ -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
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# coder-vollkontext.sh — gibt dem Coder den ganzen Kontext (23.08.2026).
|
||||
#
|
||||
# WARUM: llama-swap startet den Coder mit `-c 131072 --parallel 2`. Das sind
|
||||
# zwei Slots à 65.536 — und nur diese 65.536 stehen einer Sitzung zur Verfuegung.
|
||||
# Der Coder hat aber genau EINEN Verbraucher: OpenChamber. Lucy benutzt `hermes`,
|
||||
# der explore-Subagent auch, `review` benutzt `heavy`. Die zweite Bahn liegt brach.
|
||||
#
|
||||
# Mit `--parallel 1` bekommt eine Sitzung die vollen 131.072 — doppelt so viel
|
||||
# Platz, halb so viel Komprimieren.
|
||||
#
|
||||
# ‼️ NICHT waehrend eines laufenden Auftrags ausfuehren: llama-swap laeuft mit
|
||||
# --watch-config und laedt beim Speichern SOFORT neu. Das schneidet eine
|
||||
# laufende Sitzung mitten im Satz ab.
|
||||
#
|
||||
# Aufruf: bash deploy/coder-vollkontext.sh (Trockenlauf, zeigt den Diff)
|
||||
# bash deploy/coder-vollkontext.sh --ja (wendet an)
|
||||
set -uo pipefail
|
||||
|
||||
CONF="${LLAMA_SWAP_CONF:-/etc/llama-swap/config.yaml}"
|
||||
BLOCK="${CODER_BLOCK:- Qwen3.8-27B:}"
|
||||
|
||||
if [ ! -r "$CONF" ]; then echo "ABBRUCH: $CONF nicht lesbar"; exit 1; fi
|
||||
|
||||
TMP="$(mktemp)"
|
||||
cp "$CONF" "$TMP"
|
||||
|
||||
python3 - "$TMP" "$BLOCK" <<'PY'
|
||||
import io, re, sys
|
||||
pfad, block = sys.argv[1], sys.argv[2]
|
||||
z = io.open(pfad, encoding="utf-8").read().split("\n")
|
||||
try:
|
||||
start = next(i for i, l in enumerate(z) if l.startswith(block))
|
||||
except StopIteration:
|
||||
print("ABBRUCH: Block '%s' nicht gefunden" % block); sys.exit(1)
|
||||
ende = next((i for i in range(start + 1, len(z)) if re.match(r"^ [A-Za-z]", z[i])), len(z))
|
||||
|
||||
# Streng auf den Coder-Block begrenzt — `fast`/`hermes` MUESSEN ihre zwei Slots
|
||||
# behalten, dort greifen Lucy und der Explorer wirklich gleichzeitig zu.
|
||||
n_par = n_ctx = 0
|
||||
for i in range(start, ende):
|
||||
if "--parallel 2" in z[i]:
|
||||
z[i] = z[i].replace("--parallel 2", "--parallel 1"); n_par += 1
|
||||
if z[i].strip() == "context: 65536":
|
||||
z[i] = z[i].replace("65536", "131072"); n_ctx += 1
|
||||
|
||||
if n_par != 1 or n_ctx != 1:
|
||||
print(f"ABBRUCH: unerwartet (parallel {n_par}x, context {n_ctx}x) — Konfiguration hat sich geaendert")
|
||||
sys.exit(1)
|
||||
io.open(pfad, "w", encoding="utf-8").write("\n".join(z))
|
||||
PY
|
||||
if [ $? -ne 0 ]; then rm -f "$TMP"; exit 1; fi
|
||||
|
||||
echo "=== Aenderung ==="
|
||||
diff "$CONF" "$TMP"
|
||||
echo
|
||||
|
||||
if [ "${1:-}" != "--ja" ]; then
|
||||
echo "TROCKENLAUF — nichts geaendert. Anwenden mit: bash $0 --ja"
|
||||
rm -f "$TMP"; exit 0
|
||||
fi
|
||||
|
||||
SICHER="${CONF}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
sudo -n cp "$CONF" "$SICHER" && echo "Sicherung: $SICHER"
|
||||
sudo -n cp "$TMP" "$CONF" && echo "angewendet — llama-swap laedt durch --watch-config selbst neu"
|
||||
rm -f "$TMP"
|
||||
|
||||
echo
|
||||
echo "Warte auf den Neustart des Coders..."
|
||||
sleep 8
|
||||
curl -s -m 15 "http://127.0.0.1:8080/v1/models" >/dev/null 2>&1 \
|
||||
&& echo "llama-swap antwortet wieder" \
|
||||
|| echo "ACHTUNG: llama-swap antwortet nicht — Sicherung liegt unter $SICHER"
|
||||
|
||||
echo
|
||||
echo "Danach in ~/.config/opencode/opencode.json auf dem PC nachziehen:"
|
||||
echo " coder: limit.context 65536 -> 131072"
|
||||
+70
-23
@@ -1,33 +1,80 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starte MC2 Deployment..."
|
||||
# ‼️ Das ganze Skript liegt in EINER Funktion — das ist Absicht, kein Stil.
|
||||
# Bash liest ein Skript haeppchenweise WAEHREND es laeuft. `git pull` unten ersetzt
|
||||
# genau diese Datei mitten im Lauf; danach liest bash am selben Byte-Offset im NEUEN
|
||||
# Text weiter und fuehrt zerschnittene Zeilen aus. Live erlebt am 27.08.2026: der Pull
|
||||
# brachte eine neue Fassung dieses Schrittes mit, ausgefuehrt wurde trotzdem die alte.
|
||||
# Eine Funktion wird komplett geparst, bevor sie startet -> der Lauf bleibt in sich
|
||||
# stimmig, und Aenderungen greifen ab dem naechsten Aufruf statt mittendrin.
|
||||
main() {
|
||||
|
||||
# 1. Neuesten Code holen
|
||||
git pull origin main
|
||||
echo "Starte MC2 Deployment..."
|
||||
|
||||
# 1.5 Frontend aus dem Release-Branch holen (nur dist Ordner)
|
||||
echo "Hole gebautes Frontend aus release-dist Branch..."
|
||||
git fetch origin release-dist:refs/remotes/origin/release-dist || true
|
||||
git restore --source=origin/release-dist --staged --worktree frontend/dist 2>/dev/null || echo "Kein release-dist Branch gefunden (erster Lauf?)."
|
||||
# 1. Neuesten Code holen
|
||||
git pull origin main
|
||||
|
||||
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
||||
echo "Aktualisiere Python Abhängigkeiten..."
|
||||
backend/.venv/bin/pip install -r backend/requirements.txt
|
||||
|
||||
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
||||
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
|
||||
echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..."
|
||||
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
||||
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
||||
sudo systemctl restart llama-swap
|
||||
fi
|
||||
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
||||
echo "Aktualisiere Python Abhängigkeiten..."
|
||||
backend/.venv/bin/pip install -r backend/requirements.txt
|
||||
|
||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||
systemctl --user daemon-reload
|
||||
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
||||
#
|
||||
# ‼️ ACHTUNG (27.08.2026): /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit — MC2
|
||||
# schreibt sie selbst (backend/services/llamaswap.py::write_config), sobald jemand über
|
||||
# die Oberfläche ein Modell hinzufügt oder eine Rolle ändert. deploy/llama-swap.config.yaml
|
||||
# ist nur ein Abzug und hinkt regelmäßig hinterher (erlebt: der Coder-Vollkontext aus 34a9862
|
||||
# stand tagelang nur live, nicht im Abzug — ein Deploy hätte ihn zurückgesetzt).
|
||||
# Bis hierher wurde die Live-Datei kommentarlos mit dem Abzug überschrieben — alles per
|
||||
# UI Hinzugefügte war nach jedem Deploy weg. Jetzt: erst sichern, dann kopieren, und die
|
||||
# Abweichung sichtbar machen. MC_DEPLOY_SKIP_SWAP_CONFIG=1 überspringt den Schritt ganz.
|
||||
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
|
||||
if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
|
||||
echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
|
||||
else
|
||||
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "Llama-Swap Konfiguration hat sich geändert."
|
||||
echo " Abweichung (live -> Repo-Abzug):"
|
||||
diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
|
||||
echo " Sichere die laufende Datei nach $SWAP_BAK"
|
||||
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
||||
sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
|
||||
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
||||
sudo systemctl restart llama-swap
|
||||
echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Dienste neu starten
|
||||
echo "Starte Backend neu..."
|
||||
systemctl --user restart mc2-gateway.service mission-control-2.service
|
||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
|
||||
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
|
||||
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now mc2-radar.timer
|
||||
|
||||
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||
# 4.5 Hermes Skills synchronisieren
|
||||
echo "Synchronisiere Hermes Skills..."
|
||||
mkdir -p ~/.hermes/skills
|
||||
# Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
|
||||
for skill_dir in deploy/skills/*; do
|
||||
if [ -d "$skill_dir" ]; then
|
||||
skill_name=$(basename "$skill_dir" | tr '-' '_')
|
||||
mkdir -p ~/.hermes/skills/"$skill_name"
|
||||
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Dienste neu starten
|
||||
# ‼️ mc2-steward gehoert hier dazu: er laeuft aus DEMSELBEN Checkout (backend/steward.py).
|
||||
# Ohne ihn lief der Waechter-Prozess nach jedem Deploy mit dem alten Code weiter — der
|
||||
# Neustart wurde 27.08.2026 nachgetragen. `--user restart` auf einer nicht installierten
|
||||
# Unit ist folgenlos, darum ohne Vorabpruefung.
|
||||
echo "Starte Backend neu..."
|
||||
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
|
||||
|
||||
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+15
-32
@@ -1,35 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-Update-Check nach einem Hermes-Agent-Update: läuft unser geteiltes „Gehirn"
|
||||
# (Mem0 + die mc2-memory-Integration) noch? Exit 0 = alles ok, sonst 1 → der Job wird
|
||||
# im UI rot, damit ein kaputtes Gehirn sofort auffällt.
|
||||
# Post-Update-Check nach einem Hermes-Agent-Update: laeuft unser "Gehirn" noch?
|
||||
# Exit 0 = alles ok, sonst 1 -> der Job wird im UI rot, damit ein kaputtes Gehirn auffaellt.
|
||||
#
|
||||
# WICHTIG (27.08.2026) - VIER TOTE CHECKS ENTFERNT: bis hierher prueften die ersten Bloecke
|
||||
# den abgeloesten Gedaechtnis-Sidecar (:8765), MC2s /api/memory und das zugehoerige
|
||||
# Hermes-Plugin. Alle drei fielen am 07.08. weg (docs/wissen/VERDIKTE.md) - die Checks KONNTEN nicht
|
||||
# mehr gruen werden. Folge: jedes Hermes-Update endete rot und wurde von autoupdate.sh
|
||||
# zurueckgerollt. Kein Ersatz-Check auf Hermes native Gedaechtnis, weil es dafuer keinen
|
||||
# nachgemessenen Endpunkt gibt - geraten waere eine Fassade. Der Tool-Smoke weiter unten
|
||||
# laeuft ohnehin durch den ECHTEN Agenten und faengt ein kaputtes Gehirn.
|
||||
set -uo pipefail
|
||||
|
||||
MC_URL="${MC_URL:-http://127.0.0.1:9001}"
|
||||
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
|
||||
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
||||
fail=0
|
||||
|
||||
echo "=== Hermes Post-Update: Gehirn-Check ==="
|
||||
|
||||
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then
|
||||
echo "PASS · Mem0-Sidecar erreichbar"
|
||||
else
|
||||
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
|
||||
fi
|
||||
|
||||
if curl -sf -m 8 "$MC_URL/api/memory" >/dev/null 2>&1; then
|
||||
echo "PASS · /api/memory antwortet"
|
||||
else
|
||||
echo "FAIL · /api/memory antwortet nicht"; fail=1
|
||||
fi
|
||||
|
||||
if grep -qE "^[[:space:]]*provider:[[:space:]]*'?mc2-memory'?" "$HERMES/config.yaml" 2>/dev/null \
|
||||
&& grep -qE "memory_enabled:[[:space:]]*true" "$HERMES/config.yaml" 2>/dev/null; then
|
||||
echo "PASS · memory.provider=mc2-memory aktiv"
|
||||
else
|
||||
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
|
||||
fi
|
||||
|
||||
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
|
||||
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
|
||||
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
|
||||
@@ -46,15 +33,11 @@ if [ -f "$PATCH_APPLY" ]; then
|
||||
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
|
||||
fi
|
||||
else
|
||||
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
|
||||
fi
|
||||
|
||||
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
|
||||
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
|
||||
>/dev/null 2>&1 ); then
|
||||
echo "PASS · mc2-memory-Plugin lädt unter dem neuen Hermes"
|
||||
else
|
||||
echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1
|
||||
# Kein Fehler: Der Patch-Traeger wurde mit dem MC2-Kahlschlag (1e68f62) entfernt. Seine
|
||||
# Fixes zielten auf einen Hermes-Stand, der inzwischen tausende Commits zurueckliegt —
|
||||
# sie wuerden auf dem heutigen Quellcode ohnehin nicht mehr greifen. WARN statt FAIL, damit
|
||||
# es sichtbar bleibt, falls jemand wieder Runtime-Patches braucht.
|
||||
echo "WARN · hermes-patches/apply.py nicht vorhanden (mit 1e68f62 bewusst entfernt) — uebersprungen"
|
||||
fi
|
||||
|
||||
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Die drei Jobs (KISS-Umbau, 21.08.2026)
|
||||
|
||||
Vorher: **4 Hermes-Crons + 6 systemd-Timer**, verteilt auf zwei Mechanismen.
|
||||
Deshalb fiel am 20.08. tagelang niemandem auf, dass ein Waechter fehlte —
|
||||
niemand schaut an zwei Orten nach.
|
||||
|
||||
Jetzt: **drei Jobs, ein Ort.** `hermes cron list` zeigt die gesamte Automatik.
|
||||
|
||||
| Job | Wann | Art | Skript |
|
||||
|---|---|---|---|
|
||||
| **Daily News Report** | taeglich 07:00 | Agent + Websuche | — |
|
||||
| **KI und Stack Radar** | samstags 08:00 | `--no-agent` | `stack-radar.sh` → `stack-ist.sh` |
|
||||
| **Updates am Sonntag** | sonntags 04:30 | `--no-agent` | `sonntags-update.sh` → `autoupdate.sh` |
|
||||
|
||||
Daneben laufen als stille Rohrleitung weiter: `mc2-backup` (03:33) und
|
||||
`projekte-sync` (stuendlich). Die melden sich nie, die sichern und synchronisieren nur.
|
||||
|
||||
## Der Entwurfsgrundsatz
|
||||
|
||||
> **Fakten sammelt ein Skript, Prosa schreibt das Modell.**
|
||||
|
||||
Der alte Selbsttest war am 20.08. gruen, waehrend vier Dinge kaputt waren: das
|
||||
Kritiker-Gate zeigte seit einem Tag auf ein umbenanntes Modell (404), Lucys
|
||||
`SOUL.md` war blockiert, das Dashboard stand ohne Passwort offen, die CI war rot.
|
||||
Er hatte geprueft, ob Dienste **antworten** — nicht, ob sie **stimmen**.
|
||||
|
||||
`stack-ist.sh` prueft deshalb Ergebnisse: loesen die Rollen-Aliase noch auf? Ist
|
||||
der letzte Timer-Lauf gutgegangen? Ist eine Sicherung juenger als zwei Tage?
|
||||
Liefert die oeffentliche Adresse Daten ohne Anmeldung? Beim ersten Lauf hat es
|
||||
sofort zwei echte Befunde gefunden.
|
||||
|
||||
## Kugelsicher heisst konkret
|
||||
|
||||
- **Das Modell steht nicht im kritischen Pfad.** Antwortet es nicht, gehen die
|
||||
Rohbefunde trotzdem raus. Nur die Prosa faellt weg, nie die Meldung.
|
||||
- **Es meldet immer.** „Alles gruen" ist ein Ergebnis, kein Grund zu schweigen.
|
||||
- **Kein `set -e`.** Ein einzelner fehlschlagender Test darf den Bericht nicht
|
||||
abschneiden.
|
||||
- **Ein Melderweg:** `deploy/notify.sh` erreicht Telegram **und** Lucys
|
||||
Briefkasten (aus dem sie spricht) in einem Aufruf. Die Jobs laufen mit
|
||||
`--deliver local`, damit Hermes nicht ein zweites Mal sendet.
|
||||
- **Kein Fuellmaterial.** Die Prompts verbieten ausgedachte Vorschlaege
|
||||
ausdruecklich — ein Bericht mit Fuellmaterial wird nicht gelesen, und dann
|
||||
auch der echte Befund nicht.
|
||||
|
||||
## Ausbringen
|
||||
|
||||
Die Skripte muessen unter `~/.hermes/scripts/` liegen (Vorgabe von `hermes cron --script`).
|
||||
Quelle ist dieses Verzeichnis:
|
||||
|
||||
```bash
|
||||
scp deploy/jobs/*.sh hitonabi@192.168.178.151:/home/hitonabi/.hermes/scripts/
|
||||
ssh hitonabi@192.168.178.151 'cd ~/.hermes/scripts && sed -i "s/\r$//" *.sh && chmod +x *.sh'
|
||||
```
|
||||
|
||||
‼️ Das `sed` ist Pflicht: vom Windows-PC kopierte Dateien haben CRLF, und bash
|
||||
scheitert daran mit unverstaendlichen Meldungen.
|
||||
|
||||
## Abgeschaltet am 21.08.
|
||||
|
||||
| Weg | Warum |
|
||||
|---|---|
|
||||
| Cron `morgen-digest` | geht im Daily News Report auf |
|
||||
| Cron `tech-radar` | geht im Stack Radar auf |
|
||||
| Cron `nacht-wartung` | pruefte Lebenszeichen, nicht Ergebnisse |
|
||||
| Cron `wissens-sync` | **war nie gelaufen** — kein `last_run_at` |
|
||||
| Timer `mc2-bagatell` | **15 Naechte hintereinander „0 eingespielt"** |
|
||||
| Timer `mc2-selfsmoke` | geht in `stack-ist.sh` auf |
|
||||
| Timer `pbs-backup` | doppelt zu `mc2-backup` und seit 21.08. rot (`/srv/models/mem0` gibt es nicht mehr) |
|
||||
| Timer `mc2-autoupdate` | wird jetzt vom Cron „Updates am Sonntag" gestartet |
|
||||
|
||||
Die Unit-Dateien liegen noch da, nur `disable`d — Rueckbau ist ein Befehl.
|
||||
|
||||
---
|
||||
|
||||
## Nachtrag 21.08.: Persona, Quellen-Links und Stimme
|
||||
|
||||
**Fehler, den ich gemacht hatte:** Mein erster Prompt fuer den Daily News Report
|
||||
schrieb woertlich *„keine Emojis, keine Aufzaehlungspunkte, keine
|
||||
Ueberschriften-Deko"* — und hat damit genau das wegoptimiert, was den Bericht
|
||||
vorher gut machte.
|
||||
|
||||
★★ **Die Formatierung kam nie aus dem Job-Prompt.** Die alten Prompts waren
|
||||
kurz (*„eine praegnante 3-Punkte-Zusammenfassung an den Commander"*). Emojis,
|
||||
Ton und die Anrede stehen in **`~/.hermes/SOUL.md`** — Lucys Persona:
|
||||
|
||||
> Du sprichst den Nutzer IMMER mit **Commander** an.
|
||||
> Locker, herzlich, schlagfertig, charmant, selbstbewusst.
|
||||
|
||||
**Lehre: den Job-Prompt kurz halten und die Persona arbeiten lassen.** Wer im
|
||||
Prompt Stil verbietet, ueberstimmt die Persona — und merkt es erst, wenn die
|
||||
Nachricht seelenlos ankommt.
|
||||
|
||||
### Stimme in Telegram
|
||||
|
||||
`hermes send` kann **`MEDIA:<pfad>`**. Damit geht eine fertige Audiodatei als
|
||||
Anhang nach Telegram. `deploy/jobs/news-melden.sh` macht daraus einen Aufruf:
|
||||
|
||||
```
|
||||
Agent schreibt zwei Dateien
|
||||
/tmp/news-text.md (Emojis, Links, Formatierung) -> notify.sh -> Telegram + Briefkasten
|
||||
/tmp/news-sprich.txt (3-4 Saetze, nichts Vorlesbares fehlt) -> :8650/tts -> WAV -> hermes send MEDIA:
|
||||
```
|
||||
|
||||
‼️ **Nicht Hermes' eingebautes TTS nehmen.** Das steht auf `tts.provider: edge`
|
||||
mit `en-US-AriaNeural` — englisch. Lucys echte Stimme ist MC2s `voice-service`
|
||||
auf `:8650` (Piper `de_DE-thorsten-medium`). Deshalb ruft das Skript den
|
||||
Sidecar direkt per curl.
|
||||
|
||||
‼️ **Kein ffmpeg noetig.** Telegram nimmt die WAV direkt an. Fuer eine echte
|
||||
Sprachnachricht mit Wellenform braeuchte es OGG/Opus und damit ffmpeg — bewusst
|
||||
nicht installiert, eine Abhaengigkeit weniger.
|
||||
|
||||
Der Agent ruft **einen** Befehl auf, alles danach ist deterministisch. Faellt
|
||||
die Stimme aus, geht der Text trotzdem raus.
|
||||
|
||||
### ‼️ Folgefehler der Werkzeug-Abschaltung — gefunden und behoben
|
||||
|
||||
Nach dem Abschalten von `kanban` und `delegation` standen in `SOUL.md` noch
|
||||
zwei Anweisungen, die ins Leere zeigten: *„rufst du sofort dein Werkzeug
|
||||
delegate_task auf"* und *„Ideen traegst du sofort im Kanban-Auftragsbuch ein"*.
|
||||
Genau die Klasse stiller Defekt, die am 19.08. das Kritiker-Gate zerlegt hat.
|
||||
Beide Abschnitte ersetzt (Sicherung: `~/.hermes/SOUL.md.bak-20260821`).
|
||||
|
||||
**Merke: wer einen Werkzeugsatz abschaltet, muss `SOUL.md` mitlesen.**
|
||||
|
||||
### Kugelsicher-Regeln, die dazugekommen sind
|
||||
|
||||
- `hermes cron edit <id> "text"` **speichert nichts** — der Prompt muss ueber
|
||||
**`--prompt`** kommen. Ohne Flag gibt der Befehl den Text nur aus und die
|
||||
alte Fassung bleibt stehen. Nach jeder Aenderung in `jobs.json` nachsehen.
|
||||
- Alte Job-Fassungen liegen im Zustands-Backup:
|
||||
`tar -xzf /srv/models/mc2-backups/mc2-state-*.tar.gz ./hermes/cron/jobs.json`
|
||||
- Tagesaktualitaet muss man erzwingen: Datum per `date` feststellen lassen,
|
||||
mehrere Suchen verlangen, und Meldungen ohne belegbares Datum verwerfen.
|
||||
|
||||
---
|
||||
|
||||
## Nachtrag 3 (21.08.): Lucys ECHTE Stimme, echte Sprachnachricht
|
||||
|
||||
### Der Fehler: :8650 ist nicht Lucy
|
||||
|
||||
Ich hatte `/tts` auf MC2s `voice-service` (`:8650`) ohne Angabe von Engine und
|
||||
Stimme aufgerufen. Dessen `/health` sagt:
|
||||
|
||||
```
|
||||
"engines":["elevenlabs","edge"]
|
||||
```
|
||||
|
||||
Piper und Chatterbox sind dort **gar nicht geladen** — die Vorgabe fiel auf die
|
||||
erste verfuegbare: ElevenLabs *„Artoria DE · Saber · Hermes-Stimme"*. Das ist
|
||||
**Hermes' Stimme**, nicht Lucys. Deutsch und weiblich, deshalb faellt es nicht
|
||||
sofort auf. Beide verfuegbaren Engines sind ausserdem **Cloud** — gegen die
|
||||
100-%-lokal-Praemisse.
|
||||
|
||||
### Lucys Stimme: `lucy-stimme.service` auf `:8021`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Engine | **Kyutai pocket-tts 2.1.0** (neueste, seit 04.05. unveraendert) |
|
||||
| Modell | **`german_24l`** — die volle Fassung, nicht die destillierte |
|
||||
| Stimme | geklont aus `ref.mp3`, gecacht in `lucy_voice.safetensors` (44 MB) |
|
||||
| Laeuft | `~/.lucy-stimme/`, systemd-**user**-Dienst, `enable`d, CPU |
|
||||
| Start | ~48 s Ladezeit, danach ~5 s fuer 3,7 s Audio |
|
||||
|
||||
Mitgezogen wurde die **ganze Abstimmung**, nicht nur das Modell: `text_norm.py`
|
||||
(Symbole/Pfade/URLs → Zahlen → Akronyme), Emotions-Voreinstellungen mit
|
||||
Anlaufwoertern, Umlaut-Wortliste, Hochpass auf der Referenz, kalibrierter Pegel.
|
||||
Ohne die klingt pocket nicht wie Lucy.
|
||||
|
||||
‼️ Der Sweep im TTS-Plan (*„seriell schlaegt parallel"*) wurde auf einem **9700X**
|
||||
gemessen. Die Box ist ein Ryzen AI MAX+ 395 — das Ergebnis ist **nicht
|
||||
uebertragen**, nur uebernommen. Wer Tempo braucht, misst neu.
|
||||
|
||||
### Recherche 21.08.: pocket bleibt
|
||||
|
||||
Nichts seit Mai schlaegt es auf dieser Achse. Die Alternativen sind 5–17× groesser:
|
||||
Qwen3-TTS 0,6–1,7 Mrd. (Apache 2.0, Deutsch, 3-Sekunden-Klon), NeuTTS Air 0,5 Mrd.
|
||||
(GGUF), CosyVoice2 0,5 Mrd. — gegen pockets **100 Mio.** Fuer einen taeglichen
|
||||
Sprachnachrichten-Job auf CPU ist das der falsche Handel.
|
||||
**Ihr wart bereits auf dem neuesten Stand** — nichts zu aktualisieren.
|
||||
|
||||
### Echte Sprachnachricht braucht OGG/Opus
|
||||
|
||||
Eine WAV kommt in Telegram als **Dateianhang** an. Fuer das runde Sprachmemo mit
|
||||
Wellenform braucht es OGG/Opus — dafuer wurde **ffmpeg installiert** (8.0.1):
|
||||
|
||||
```bash
|
||||
ffmpeg -y -i ton.wav -c:a libopus -b:a 32k -ar 48000 -ac 1 ton.ogg
|
||||
hermes send --to telegram "MEDIA:ton.ogg"
|
||||
```
|
||||
|
||||
Ganze Kette (Text + Stimme + Versand) gemessen: **10 Sekunden**.
|
||||
|
||||
### Altlast am Rande
|
||||
|
||||
`F:\Coding Stuff\lucy\lucy-tts\Lucy-Startklar.bat` zeigt auf
|
||||
`mission-control-2\client\lucy-tts` — den Ordner gibt es seit der Repo-Trennung
|
||||
nicht mehr.
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# news-melden.sh — schickt den fertigen Nachrichtenbericht als TEXT und als
|
||||
# SPRACHNACHRICHT an den Commander (21.08.2026, überarbeitet 22.08.).
|
||||
#
|
||||
# Der Agent ruft genau EINEN Befehl auf. Alles danach ist deterministisch —
|
||||
# Zustellung darf nicht davon abhaengen, ob ein Modell die Reihenfolge einhaelt.
|
||||
#
|
||||
# ‼️ WARUM DAS SKRIPT SOFORT ZURUECKKEHRT (Fund vom 22.08., erster echter Lauf):
|
||||
# Hermes' terminal-Werkzeug bricht nach 30 s ab. Die Sprachsynthese dauert
|
||||
# laenger. Der Agent sah den Timeout, hielt den Aufruf fuer gescheitert und
|
||||
# rief das Skript ein zweites Mal auf — der Text ging DOPPELT raus
|
||||
# (07:01:42 und 07:02:14).
|
||||
# Deshalb: Text senden, Stimme abgekoppelt im Hintergrund, sofort raus.
|
||||
# Dazu eine Doppelversand-Sperre, die auch kuenftige Wiederholungen abfaengt.
|
||||
#
|
||||
# Erwartet zwei Dateien, die der Agent vorher geschrieben hat:
|
||||
# $1 Textfassung (mit Emojis, Links, Formatierung — geht nach Telegram)
|
||||
# $2 Sprechfassung (kurz, ohne Links/Emojis — wird vorgelesen)
|
||||
#
|
||||
# ‼️ STIMME: :8021, nicht :8650.
|
||||
# :8021 = lucy-stimme.service — Kyutai pocket-tts, Modell `german_24l`,
|
||||
# Stimme geklont aus ref.mp3. DAS ist Lucy. Lokal, auf der CPU.
|
||||
# :8650 = MC2s voice-service. Dort sind nur `elevenlabs` und `edge` geladen —
|
||||
# beides CLOUD, und die Vorgabe ist die ElevenLabs-Stimme
|
||||
# "Artoria/Saber", also HERMES' Stimme, nicht Lucys.
|
||||
set -uo pipefail
|
||||
|
||||
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
|
||||
VOICE="${VOICE_URL:-http://127.0.0.1:8021}"
|
||||
HERMES="${HERMES_BIN:-$HOME/.local/bin/hermes}"
|
||||
TON="/tmp/news-stimme.wav"
|
||||
TON_OGG="/tmp/news-stimme.ogg"
|
||||
|
||||
# ---------------------------------------------- Zweiter Durchgang: Stimme ---
|
||||
# Wird vom ersten Durchgang abgekoppelt neu gestartet. Laeuft beliebig lange,
|
||||
# ohne dass irgendein Werkzeug-Timeout hineinredet.
|
||||
if [ "${1:-}" = "--nur-stimme" ]; then
|
||||
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
||||
[ -s "$SPRECHDATEI" ] || exit 0
|
||||
|
||||
CODE=$(curl -s -m 600 -X POST "$VOICE/tts" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
|
||||
-o "$TON" -w '%{http_code}' 2>/dev/null)
|
||||
|
||||
# Sprechfassung wegraeumen (Grund siehe Textfassung unten) — auch wenn die Stimme scheiterte.
|
||||
mv -f "$SPRECHDATEI" "${SPRECHDATEI%.txt}.gesendet.txt" 2>/dev/null || true
|
||||
|
||||
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
|
||||
|
||||
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
|
||||
# OGG/Opus. Eine WAV kaeme als Dateianhang an. Mono/48k ist das erwartete Format.
|
||||
if ! ffmpeg -y -loglevel error -i "$TON" -c:a libopus -b:a 32k -ar 48000 -ac 1 "$TON_OGG" 2>/dev/null; then
|
||||
TON_OGG="$TON"
|
||||
fi
|
||||
"$HERMES" send --to telegram "MEDIA:$TON_OGG" >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------- Erster Durchgang: Text + Start ---
|
||||
TEXTDATEI="${1:-/tmp/news-text.md}"
|
||||
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
||||
GESENDET="${TEXTDATEI%.md}.gesendet.md"
|
||||
|
||||
if [ ! -s "$TEXTDATEI" ]; then
|
||||
# Zweiter Aufruf desselben Laufs, nachdem der erste den Bericht schon verschickt und
|
||||
# weggeraeumt hat? Dann ist nichts zu tun (die Doppelversand-Sperre unten sieht die
|
||||
# Datei nicht mehr).
|
||||
if [ -s "$GESENDET" ] && [ $(( $(date +%s) - $(stat -c %Y "$GESENDET" 2>/dev/null || echo 0) )) -lt 7200 ]; then
|
||||
echo "gemeldet (dieser Bericht ging bereits raus — nicht doppelt geschickt)"
|
||||
exit 0
|
||||
fi
|
||||
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Doppelversand-Sperre. Greift, egal WARUM ein zweiter Aufruf kommt —
|
||||
# Timeout, Modell-Wiederholung, versehentlicher Handstart.
|
||||
MARKE="/tmp/.news-gesendet-$(sha256sum "$TEXTDATEI" | cut -c1-16)"
|
||||
if [ -f "$MARKE" ]; then
|
||||
ALTER=$(( $(date +%s) - $(stat -c %Y "$MARKE" 2>/dev/null || echo 0) ))
|
||||
if [ "$ALTER" -lt 7200 ]; then
|
||||
echo "gemeldet (dieser Bericht ging vor ${ALTER}s bereits raus — nicht doppelt geschickt)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
: > "$MARKE"
|
||||
find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
|
||||
|
||||
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
|
||||
|
||||
# Bericht wegraeumen (23.09.2026): Hermes' write_file ueberschreibt keine vorhandene Datei.
|
||||
# Blieb der Bericht vom Vortag liegen, scheiterte jeder Morgenlauf erst an
|
||||
# „Refusing to overwrite /tmp/news-text.md“ (4 von 5 Versuchen, jeden Tag seit Wochen).
|
||||
mv -f "$TEXTDATEI" "$GESENDET" 2>/dev/null && touch "$GESENDET" 2>/dev/null || true
|
||||
|
||||
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
|
||||
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
|
||||
if [ -s "$SPRECHDATEI" ]; then
|
||||
setsid nohup bash "$0" --nur-stimme "$SPRECHDATEI" >/dev/null 2>&1 < /dev/null &
|
||||
disown 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "gemeldet"
|
||||
exit 0
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# sonntags-update.sh — Job 2: "Updates am Sonntag" (21.08.2026).
|
||||
#
|
||||
# Duenner Aufsatz auf deploy/autoupdate.sh. Bewusst KEIN Neubau: autoupdate.sh
|
||||
# macht seit Juli genau das Richtige — Router -> Engine -> Hermes, jede Ebene
|
||||
# mit Update-Pruefung, Postcheck, und bei Rot automatischem Rueckbau plus
|
||||
# Selbst-Pinnung. Es meldet auch schon selbst ueber notify.sh, also Telegram
|
||||
# UND Lucys Stimme.
|
||||
#
|
||||
# Dieser Aufsatz existiert nur, damit der Job unter `hermes cron list` steht
|
||||
# statt in einem separaten systemd-Timer. Ein Ort fuer alle Automatik — genau
|
||||
# deshalb ist am 20.08. tagelang niemandem aufgefallen, dass ein Waechter fehlte.
|
||||
#
|
||||
# Aufruf ueber Hermes-Cron:
|
||||
# hermes cron create '30 4 * * 0' --name 'Updates am Sonntag' \
|
||||
# --no-agent --script sonntags-update.sh --deliver local
|
||||
set -uo pipefail
|
||||
|
||||
AUTOUPDATE="${AUTOUPDATE:-$HOME/mission-control-v2/deploy/autoupdate.sh}"
|
||||
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
|
||||
|
||||
melde() { bash "$NOTIFY" -s "[Box-Update]" "$1" 2>/dev/null || echo "$1" >&2; }
|
||||
|
||||
if [ ! -f "$AUTOUPDATE" ]; then
|
||||
melde "Sonntags-Update ausgefallen: $AUTOUPDATE gibt es nicht. Nichts wurde aktualisiert."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# autoupdate.sh meldet waehrend des Laufs selbst (inkl. der KRITISCH-Faelle, in
|
||||
# denen ein Bericht am Ende zu spaet kaeme). Wir fangen hier nur den Fall ab,
|
||||
# dass es gar nicht erst durchlaeuft — sonst waere das Schweigen zweideutig.
|
||||
AUSGABE="$(bash "$AUTOUPDATE" 2>&1)"
|
||||
CODE=$?
|
||||
|
||||
if [ "$CODE" -ne 0 ]; then
|
||||
melde "Sonntags-Update mit Fehler beendet (exit $CODE). Letzte Zeilen:
|
||||
|
||||
$(printf '%s\n' "$AUSGABE" | tail -15)"
|
||||
fi
|
||||
|
||||
# Erfolgsfall: autoupdate.sh hat bereits gemeldet. Leere Ausgabe -> Hermes
|
||||
# schweigt, also genau eine Nachricht statt zwei.
|
||||
exit 0
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# stack-ist.sh — der IST-Zustand der Box, in Fakten (21.08.2026).
|
||||
#
|
||||
# Wird von Hermes-Cron "stack-radar" ueber --script eingespeist: die Ausgabe hier
|
||||
# landet im Prompt des Agenten, der daraus den Bericht schreibt.
|
||||
#
|
||||
# GRUNDSATZ: Dieses Skript prueft ERGEBNISSE, nicht Lebenszeichen.
|
||||
# Der alte Selbsttest war am 20.08. gruen, waehrend vier Dinge kaputt waren:
|
||||
# das Kritiker-Gate zeigte auf ein umbenanntes Modell (404), Lucys SOUL.md war
|
||||
# blockiert, das Dashboard stand ohne Passwort offen, die CI war rot.
|
||||
# "Dienst antwortet" haette das nie gefunden. Deshalb wird hier nachgesehen,
|
||||
# ob Dinge STIMMEN — nicht ob sie laufen.
|
||||
#
|
||||
# Nie mit set -e: ein einzelner fehlschlagender Test darf den Bericht nicht
|
||||
# abschneiden. Fehler werden gemeldet, nicht verschwiegen.
|
||||
set -uo pipefail
|
||||
|
||||
MC2="${MC2_URL:-http://127.0.0.1:9001}"
|
||||
SWAP="${SWAP_URL:-http://127.0.0.1:8080}"
|
||||
GITEA="${GITEA_URL:-http://192.168.178.153:3000}"
|
||||
OEFFENTLICH="${OEFFENTLICH_URL:-https://mc.tobisniceshomelab.ddnsfree.com}"
|
||||
|
||||
ok() { printf ' OK %s\n' "$*"; }
|
||||
warn() { printf ' ACHTUNG %s\n' "$*"; }
|
||||
bad() { printf ' ROT %s\n' "$*"; }
|
||||
|
||||
echo "IST-ZUSTAND DER BOX — $(date '+%d.%m.%Y %H:%M')"
|
||||
echo
|
||||
|
||||
# ---------------------------------------------------------------- Dienste ---
|
||||
echo "DIENSTE"
|
||||
# systemd stellt gescheiterten Units ein "●" voran — das muss weg, sonst meldet
|
||||
# der Bericht lauter Aufzaehlungspunkte als kaputte Dienste.
|
||||
# EGAL: Units, die dauerhaft scheitern und niemanden stoeren. Ein Bericht, der
|
||||
# jeden Tag denselben harmlosen Fehler zeigt, erzieht zum Wegsehen.
|
||||
EGAL="at-spi-dbus-bus.service"
|
||||
fehl=$(systemctl --user list-units --type=service --state=failed --no-legend --no-pager 2>/dev/null \
|
||||
| sed 's/^[^a-zA-Z]*//' | awk '{print $1}')
|
||||
gefunden=0
|
||||
for d in $fehl; do
|
||||
case " $EGAL " in *" $d "*) continue ;; esac
|
||||
bad "Dienst gescheitert: $d"; gefunden=1
|
||||
done
|
||||
[ "$gefunden" = "0" ] && ok "kein Dienst im Fehlerzustand"
|
||||
|
||||
for d in mc2-gateway mission-control-2 hermes-gateway voice-service; do
|
||||
systemctl --user is-active --quiet "$d.service" 2>/dev/null \
|
||||
&& ok "$d laeuft" || bad "$d laeuft NICHT"
|
||||
done
|
||||
systemctl is-active --quiet llama-swap.service 2>/dev/null \
|
||||
&& ok "llama-swap laeuft" || bad "llama-swap laeuft NICHT"
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------------------ Timer ---
|
||||
# Nicht "ist der Timer aktiv", sondern "ist der letzte Lauf gutgegangen".
|
||||
echo "TIMER — letzter Lauf"
|
||||
for t in $(systemctl --user list-timers --no-legend --no-pager 2>/dev/null | awk '{print $NF}'); do
|
||||
dienst="${t%.service}.service"
|
||||
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
||||
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)
|
||||
# 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"
|
||||
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
||||
done
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------- Modell-Aliase ---
|
||||
# Der Fund vom 19./20.08.: Skripte und Configs nennen Modelle beim Eigennamen,
|
||||
# das Modell wird umbenannt, und alles laeuft still in 404. Hier wird geprueft,
|
||||
# ob jeder Alias, den irgendwer benutzt, heute noch antwortet.
|
||||
echo "MODELL-ALIASE (Rollen muessen aufloesen)"
|
||||
liste=$(curl -s -m 10 "$MC2/v1/models" 2>/dev/null \
|
||||
| python3 -c 'import sys,json; print(" ".join(m["id"] for m in json.load(sys.stdin)["data"]))' 2>/dev/null)
|
||||
if [ -z "$liste" ]; then bad "Modell-Liste nicht abrufbar ueber $MC2/v1"
|
||||
else
|
||||
ok "verfuegbar: $liste"
|
||||
for rolle in hermes fast heavy; do
|
||||
case " $liste " in
|
||||
*" $rolle "*) ok "Rolle '$rolle' loest auf" ;;
|
||||
*) bad "Rolle '$rolle' FEHLT — Skripte, die sie nennen, laufen in 404" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------------- Erreichbar ---
|
||||
echo "ERREICHBARKEIT"
|
||||
for paar in "MC2:$MC2/api/health" "llama-swap:$SWAP/v1/models" "Gitea:$GITEA/api/v1/version"; do
|
||||
name="${paar%%:*}"; url="${paar#*:}"
|
||||
code=$(curl -s -m 10 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
|
||||
[ "$code" = "200" ] && ok "$name antwortet (200)" || bad "$name antwortet mit '$code'"
|
||||
done
|
||||
echo
|
||||
|
||||
# ------------------------------------------------ Offen im Internet? --------
|
||||
# Der Fund vom 21.08.: MC2 hing ueber den Nginx Proxy Manager ungeschuetzt im
|
||||
# Netz. ufw sah nichts davon, weil der Proxy im LAN steht. Also von aussen
|
||||
# nachsehen — und zwar auf einem Endpunkt, der ECHTE Daten liefert.
|
||||
echo "OEFFENTLICHE ERREICHBARKEIT"
|
||||
code=$(curl -s -m 15 -o /tmp/.ist-oeff -w '%{http_code}' "$OEFFENTLICH/api/system/status" 2>/dev/null)
|
||||
if [ "$code" = "200" ] && head -c1 /tmp/.ist-oeff 2>/dev/null | grep -q '{'; then
|
||||
bad "MC2 liefert Systemdaten OHNE Anmeldung ins Internet ($OEFFENTLICH)"
|
||||
elif [ "$code" = "401" ] || [ "$code" = "403" ]; then
|
||||
ok "oeffentlicher Zugang verlangt Anmeldung ($code)"
|
||||
elif [ "$code" = "000" ]; then
|
||||
ok "von aussen nicht erreichbar"
|
||||
else
|
||||
warn "oeffentlicher Zugang antwortet mit '$code' — nachsehen"
|
||||
fi
|
||||
rm -f /tmp/.ist-oeff
|
||||
echo
|
||||
|
||||
# ----------------------------------------------------------------- Ports ---
|
||||
echo "OFFENE PORTS (ufw)"
|
||||
sudo -n ufw status 2>/dev/null | grep ALLOW | sed 's/^/ /' || warn "ufw-Status nicht lesbar"
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------- CI-Ampel ---
|
||||
echo "CI-AMPEL (Gitea)"
|
||||
T=$(tr -d '[:space:]' < "$HOME/.config/gitea/create-token" 2>/dev/null)
|
||||
if [ -z "$T" ]; then warn "kein Gitea-Token — CI nicht pruefbar"
|
||||
else
|
||||
curl -s -m 20 -H "Authorization: token $T" "$GITEA/api/v1/user/repos?limit=100" 2>/dev/null \
|
||||
| python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
repos = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print(" ACHTUNG Repo-Liste nicht lesbar:", e); sys.exit(0)
|
||||
print(f" OK {len(repos)} Repos in Gitea")
|
||||
' 2>/dev/null || warn "Repo-Liste nicht lesbar"
|
||||
fi
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------------- Sicherung ---
|
||||
echo "SICHERUNGEN"
|
||||
# Pfad aus backup.sh: DEST_DIR="$MODELS_DIR/mc2-backups"
|
||||
SICHER="${MC_BACKUP_DIR:-/srv/models/mc2-backups}"
|
||||
neuestes=$(find "$SICHER" -maxdepth 2 -type f -newermt '-2 days' 2>/dev/null | head -1)
|
||||
anzahl=$(find "$SICHER" -maxdepth 2 -type f 2>/dev/null | wc -l)
|
||||
if [ -n "$neuestes" ]; then
|
||||
ok "Sicherung juenger als 2 Tage vorhanden ($anzahl Staende in $SICHER)"
|
||||
elif [ "$anzahl" -gt 0 ]; then
|
||||
bad "juengste Sicherung ist AELTER als 2 Tage ($anzahl Staende in $SICHER)"
|
||||
else
|
||||
bad "gar keine Sicherung in $SICHER"
|
||||
fi
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------------------ Platz ---
|
||||
echo "PLATZ UND LAST"
|
||||
# Nur eindeutige Dateisysteme: / und /srv liegen hier auf demselben Geraet,
|
||||
# doppelt gemeldet las sich das wie ein Befund.
|
||||
df -h --output=source,size,used,avail,pcent,target / /srv 2>/dev/null \
|
||||
| awk '!gesehen[$1]++ {printf " %s\n", $0}'
|
||||
echo " RAM: $(free -h | awk '/^Mem:/{print $3" von "$2" belegt"}')"
|
||||
echo
|
||||
|
||||
# ------------------------------------------------------------- Warm/Kalt ---
|
||||
# ---------------------------------------------------------------- Curator ---
|
||||
# Hierher gefaltet am 22.08.2026 aus dem Repo `curator-staleness-check`.
|
||||
# Das war ein eigener Wachhund mit eigenem systemd-Timer und eigener CI-Ampel —
|
||||
# und er ueberwachte einen Cron-Job ueber eine FEST VERDRAHTETE ID
|
||||
# (a47910e2ef05), den es laengst nicht mehr gibt. Er haette bei jedem Lauf
|
||||
# Alarm geschlagen fuer etwas, das gar nicht existiert.
|
||||
# Hier steht dieselbe Frage in vier Zeilen, ohne Repo, Timer und Ampel.
|
||||
echo "CURATOR"
|
||||
AN=$(grep -A3 '^curator:' "$HOME/.hermes/config.yaml" 2>/dev/null | grep -m1 'enabled:' | awk '{print $2}')
|
||||
if [ "${AN:-false}" != "true" ]; then
|
||||
ok "Curator ist abgeschaltet — nichts zu ueberwachen"
|
||||
else
|
||||
# %Ts liefert GANZE Sekunden. %T@ waere ein Float — bash rechnet damit nicht
|
||||
# und bricht mit "invalid arithmetic operator" ab.
|
||||
JUENGSTES=$(find "$HOME/.hermes/logs/curator" -type f -name '*.log' -printf '%Ts\n' 2>/dev/null | sort -rn | head -1)
|
||||
if [ -z "$JUENGSTES" ]; then
|
||||
bad "Curator ist AN, hat aber noch nie etwas protokolliert"
|
||||
else
|
||||
TAGE=$(( ( $(date +%s) - JUENGSTES ) / 86400 ))
|
||||
if [ "$TAGE" -le 7 ]; then ok "Curator war vor $TAGE Tagen zuletzt aktiv"
|
||||
elif [ "$TAGE" -le 21 ]; then warn "Curator seit $TAGE Tagen still — laeuft er noch?"
|
||||
else bad "Curator seit $TAGE Tagen still, obwohl eingeschaltet"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "MODELLE IM SPEICHER"
|
||||
curl -s -m 10 "$SWAP/running" 2>/dev/null \
|
||||
| python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(" ACHTUNG llama-swap-Status nicht lesbar"); sys.exit(0)
|
||||
lauf = d.get("running", [])
|
||||
if not lauf:
|
||||
print(" OK kein Modell geladen (alles kalt)")
|
||||
for m in lauf:
|
||||
print(" OK " + str(m.get("model")) + " - " + str(m.get("state")))
|
||||
'
|
||||
|
||||
echo
|
||||
echo "ENDE IST-ZUSTAND"
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# stack-radar.sh — Job 3: "KI & Stack Radar" (21.08.2026).
|
||||
#
|
||||
# Ablauf: IST-Zustand holen (Fakten) -> Modell bewerten lassen (Prosa)
|
||||
# -> ueber notify.sh melden (Telegram + Lucys Stimme)
|
||||
#
|
||||
# KUGELSICHER heisst hier konkret:
|
||||
# * Die Fakten kommen aus stack-ist.sh, nicht aus dem Modell. Faellt das Modell
|
||||
# aus, werden die ROTEN Zeilen trotzdem gemeldet — roh statt gar nicht.
|
||||
# * Es meldet IMMER. "Alles gruen" ist ein Ergebnis, kein Grund zu schweigen.
|
||||
# * Kein set -e: ein einzelner Fehlschlag darf den Bericht nicht abschneiden.
|
||||
#
|
||||
# Aufruf ueber Hermes-Cron:
|
||||
# hermes cron create '0 8 * * 6' --name 'KI & Stack Radar' \
|
||||
# --no-agent --script stack-radar.sh --deliver local
|
||||
# (--no-agent, weil dieses Skript selbst meldet. Leere Ausgabe = Hermes schweigt.)
|
||||
set -uo pipefail
|
||||
|
||||
HIER="$(cd "$(dirname "$0")" && pwd)"
|
||||
IST_SKRIPT="${IST_SKRIPT:-$HOME/.hermes/scripts/stack-ist.sh}"
|
||||
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
|
||||
GATEWAY="${GATEWAY_URL:-http://127.0.0.1:9010/v1}"
|
||||
MODELL="${RADAR_MODELL:-fast}" # bewusst NICHT heavy: das laedt 68 GB und der
|
||||
# Radar meldet danach seinen eigenen Speicherverbrauch als Befund
|
||||
|
||||
[ -x "$IST_SKRIPT" ] || IST_SKRIPT="$HIER/stack-ist.sh"
|
||||
|
||||
melde() {
|
||||
if [ -x "$NOTIFY" ] || [ -f "$NOTIFY" ]; then
|
||||
bash "$NOTIFY" -s "[Stack-Radar]" "$1"
|
||||
else
|
||||
# Letzte Rueckfallebene: wenigstens ins Log, damit nichts still verschwindet.
|
||||
echo "[stack-radar] MELDEWEG FEHLT — Bericht folgt roh:" >&2
|
||||
echo "$1" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- 1. Fakten ---
|
||||
IST="$(bash "$IST_SKRIPT" 2>&1)"
|
||||
if [ -z "$IST" ]; then
|
||||
melde "Radar konnte den IST-Zustand nicht erheben — $IST_SKRIPT lieferte nichts. Das ist selbst ein Befund: bitte nachsehen."
|
||||
exit 1
|
||||
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)"
|
||||
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
||||
|
||||
# ------------------------------------------------------- 2. Bewertung ------
|
||||
# Das Modell bekommt NUR die Fakten und darf nichts dazuerfinden. Es soll
|
||||
# einordnen, nicht ermitteln.
|
||||
PROMPT="Du bist der Stack-Radar einer selbstgehosteten KI-Box. Unten steht der
|
||||
gemessene IST-Zustand. Schreibe einen kurzen Bericht auf Deutsch, per Du.
|
||||
|
||||
Regeln:
|
||||
- Erfinde NICHTS. Nur was unten steht.
|
||||
- Beginne mit einer Zeile Gesamturteil.
|
||||
- Dann: was ist ROT oder ACHTUNG, und was waere jeweils der naechste Schritt.
|
||||
- Verbesserungsvorschlaege NUR, wenn sich aus den Zahlen unten wirklich etwas
|
||||
Konkretes ergibt. Gibt es nichts, lass den Abschnitt ersatzlos weg. Schreibe
|
||||
lieber gar keinen Vorschlag als einen ausgedachten - ein Bericht mit
|
||||
Fuellmaterial wird nicht gelesen.
|
||||
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
||||
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.
|
||||
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
||||
|
||||
IST-ZUSTAND:
|
||||
$IST"
|
||||
|
||||
ANTWORT=""
|
||||
if NUTZLAST="$(python3 -c '
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"model": sys.argv[1],
|
||||
"messages": [{"role": "user", "content": sys.argv[2]}],
|
||||
"max_tokens": 900,
|
||||
"temperature": 0.3,
|
||||
}))' "$MODELL" "$PROMPT" 2>/dev/null)"; then
|
||||
ANTWORT="$(curl -s -m 300 -X POST "$GATEWAY/chat/completions" \
|
||||
-H 'Content-Type: application/json' -d "$NUTZLAST" 2>/dev/null \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(sys.stdin)["choices"][0]["message"]["content"].strip())
|
||||
except Exception:
|
||||
pass' 2>/dev/null)"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------- 3. Melden ---
|
||||
if [ -n "$ANTWORT" ]; then
|
||||
melde "$ANTWORT"
|
||||
else
|
||||
# Modell stumm oder kaputt -> die Fakten gehen TROTZDEM raus. Genau dafuer
|
||||
# steht das Modell nicht im kritischen Pfad.
|
||||
if [ -n "$ROT" ]; then
|
||||
melde "Radar: Bewertung durch das Modell hat nicht geklappt — hier die Rohbefunde:
|
||||
|
||||
$ROT"
|
||||
else
|
||||
melde "Radar: alles gruen (Bewertung durch das Modell hat nicht geklappt, die Messung schon)."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hermes bekommt eine LEERE Ausgabe -> keine zweite Nachricht. Gemeldet wurde
|
||||
# bereits ueber notify.sh, und das erreicht Telegram UND Lucys Stimme.
|
||||
exit 0
|
||||
@@ -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())
|
||||
@@ -4,13 +4,19 @@ globalTTL: 0
|
||||
|
||||
models:
|
||||
Qwen3.6-35B-A3B:
|
||||
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
|
||||
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Nebenlast/Hintergrund-Extraktion, blockiert Voice-Turns
|
||||
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
|
||||
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
|
||||
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
|
||||
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
|
||||
# 17.09.2026: Uncensored-Variante (HauhauCS Aggressive, Q4_K_M) mit demselben giocom-DFlash-Draft. Prüfstand: 100,8 t/s
|
||||
# @13k (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreißer), Hermes-Smoke grün. Original bleibt in
|
||||
# Qwen3.6-35B-A3B-MTP-GGUF liegen — Rückweg = Pfad zurück.
|
||||
# --reasoning-budget 2048 (17.09.2026): Deckel für Denk-Tokens — die abliterierte Variante hatte im Prüfstand 1/12
|
||||
# Denk-Ausreißer ohne Antwort; mit Deckel wird daraus eine kürzer bedachte Antwort statt Stille. Normale Denkphasen
|
||||
# des Hirns liegen bei ein paar hundert Tokens.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
|
||||
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf --reasoning-budget 2048
|
||||
ttl: 0
|
||||
aliases:
|
||||
- hermes
|
||||
@@ -21,20 +27,44 @@ models:
|
||||
out: [text]
|
||||
tools: true
|
||||
context: 65536
|
||||
Qwen3-Coder-Next:
|
||||
Qwen3.8-27B:
|
||||
# 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.
|
||||
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
||||
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
||||
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
||||
# 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5)
|
||||
# mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3,
|
||||
# Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||
ttl: 5400
|
||||
aliases:
|
||||
- coder
|
||||
- heavy
|
||||
capabilities:
|
||||
in: [text]
|
||||
in: [text, image]
|
||||
out: [text]
|
||||
tools: true
|
||||
# --parallel 1 = der Coder bekommt den ganzen Slot (34a9862, am laufenden Prozess
|
||||
# gegengeprueft). Er hat nur einen Verbraucher; Lucy/explore nutzen hermes, review heavy.
|
||||
context: 131072
|
||||
Muse-Glimmer-30B:
|
||||
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
|
||||
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
|
||||
ttl: 600
|
||||
aliases:
|
||||
- debugger
|
||||
capabilities:
|
||||
in: [text, image]
|
||||
out: [text]
|
||||
tools: true
|
||||
context: 65536
|
||||
Qwen3-Embedding-0.6B:
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
|
||||
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --load-mode none -c 8192
|
||||
ttl: 0
|
||||
aliases:
|
||||
- embed
|
||||
@@ -42,23 +72,18 @@ models:
|
||||
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
|
||||
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --jinja --cache-reuse 256 -cram 16384
|
||||
ttl: 600
|
||||
aliases:
|
||||
- heavy
|
||||
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
||||
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
||||
capabilities:
|
||||
in: [text]
|
||||
out: [text]
|
||||
tools: true
|
||||
context: 32768
|
||||
Qwen3-VL-30B-A3B-Instruct:
|
||||
# Lucys AUGEN — seit 07.07. ON-DEMAND (User-Entscheid: „Gehirn + Embedding reichen
|
||||
# komplett; Vision nur wenn Lucy auf dem Desktop an ist oder die IDE-Lane sie braucht").
|
||||
# NICHT mehr in brains; ttl 900 = 15 Min Nachlauf nach dem letzten Blick. Die Lucy-App
|
||||
# wärmt die Augen beim Start selbst an (useVoiceAgent-Warm-Gate). Vorher: warm, ttl 0 —
|
||||
# das kostete ~19 GB Dauerbelegung und verdrängte den 60-GB-Chef-Gutachter.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
||||
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
||||
ttl: 900
|
||||
aliases:
|
||||
- vision
|
||||
@@ -66,39 +91,12 @@ models:
|
||||
in: [text, image]
|
||||
out: [text]
|
||||
context: 32768
|
||||
GLM-4.6V-Flash:
|
||||
cmd: |
|
||||
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
|
||||
ttl: 300
|
||||
aliases:
|
||||
- scout
|
||||
capabilities:
|
||||
in: [text, image]
|
||||
out: [text]
|
||||
tools: true
|
||||
context: 131072
|
||||
Devstral-Small-2-24B:
|
||||
# KRITIKER (25.07.2026): Gegenleser aus einer ANDEREN Modellfamilie (Mistral, Apache-2.0,
|
||||
# 24B DICHT, 256k Kontext, 68,0 % SWE-bench Verified vs. 59,2 % bei GLM-4.7-Flash).
|
||||
# Loest GLM-4.7-Flash in der kritiker-Rolle ab. Dicht = auf Strix Halo bandbreitengebunden
|
||||
# und damit langsam beim SCHREIBEN — fuer einen Kritiker egal: er liest viel, schreibt wenig.
|
||||
# --jinja ist Pflicht (Chat-Template, sonst kaputte Tool-Calls).
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Devstral-Small-2-GGUF/mistralai_Devstral-Small-2-24B-Instruct-2512-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 16384 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256
|
||||
ttl: 1800
|
||||
aliases:
|
||||
- kritiker
|
||||
capabilities:
|
||||
in: [text]
|
||||
out: [text]
|
||||
tools: true
|
||||
context: 16384
|
||||
Qwen3-Reranker-0.6B:
|
||||
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Mem0-Suchtreffer nach echter
|
||||
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Gedächtnis-Suchtreffer nach echter
|
||||
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
|
||||
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
|
||||
cmd: |
|
||||
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
|
||||
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --load-mode none -c 8192
|
||||
ttl: 0
|
||||
aliases:
|
||||
- reranker
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=MC2 Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config)
|
||||
Description=MC2 Zustands-Backup (Hermes-Configs/Secrets + llama-swap config)
|
||||
Documentation=file:%h/mission-control-v2/docs/BACKUP.md
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
# systemd-USER-Unit für den MC2-Steward — die Wächter-Loops als eigener Prozess (UMBAU v3 P2).
|
||||
# Re-Warm-Wächter und Health-Sentry überleben damit jeden Steuerpult-Neustart, und der Sentry
|
||||
# kann auch das Steuerpult SELBST + den mc2-gateway überwachen (MC_SENTRY_WATCH_MC2=1).
|
||||
# Meldungen gehen per HTTP in den MC2-Briefkasten (MC_ANNOUNCE_HTTP); ist MC2 down, greift
|
||||
# weiterhin der Telegram-Direktweg (notify.sh).
|
||||
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
|
||||
#
|
||||
# ‼️ NACHGETRAGEN 27.08.2026: Diese Unit lief auf der Box, fehlte aber im Repo — sie war weder
|
||||
# versioniert noch vom Disaster-Recovery abgedeckt, obwohl backend/routers/system.py,
|
||||
# services/maintenance.py (USER_SERVICES) und deploy/stack-postcheck.sh sie voraussetzen.
|
||||
# Inhalt 1:1 von der laufenden Box übernommen (`systemctl --user cat mc2-steward`), nur die
|
||||
# Description um den entfallenen Mem0-Dedupe-Loop gekürzt. Das Warm-Set-Drop-in liegt
|
||||
# daneben in mc2-steward.service.d-warmset.conf — beim Ausrollen mitnehmen.
|
||||
#
|
||||
# Ablage: ~/.config/systemd/user/mc2-steward.service ; dann:
|
||||
# systemctl --user daemon-reload && systemctl --user enable --now mc2-steward
|
||||
|
||||
[Unit]
|
||||
Description=MC2 Steward (Wächter: Re-Warm, Health-Sentry)
|
||||
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 steward.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_ANNOUNCE_HTTP=http://127.0.0.1:9001
|
||||
Environment=MC_SENTRY_WATCH_MC2=1
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,11 @@
|
||||
# Drop-in für mc2-steward.service — Ablage auf der Box:
|
||||
# ~/.config/systemd/user/mc2-steward.service.d/warmset.conf
|
||||
#
|
||||
# 26.07.2026 — Ko-Residenz ist nicht Warm-Pflicht. Die Gruppe `brains` enthaelt seit dem
|
||||
# Umbau auch Modelle MIT TTL (Coder 90 min, Kritiker 30 min); ohne diese Liste hielte der
|
||||
# Waechter sie faelschlich fuer dauerhaft faellig und arbeitete gegen die TTLs.
|
||||
# ANFUEHRUNGSZEICHEN SIND PFLICHT: systemd trennt Environment= an Leerzeichen, ohne sie
|
||||
# kommt nur das erste Modell an (live erlebt) — das Warm-Ziel waere still zu klein.
|
||||
[Service]
|
||||
Environment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3-Reranker-0.6B Qwen3.6-35B-A3B"
|
||||
Environment=MC_WARMUP_RERANK=reranker
|
||||
@@ -19,19 +19,16 @@ Environment=MC_PORT=9001
|
||||
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
|
||||
# Geteiltes Gedächtnis = die bestehende v1-DB (Kontinuität bis/über Cutover).
|
||||
Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db
|
||||
# Gateway-Auszug (UMBAU v3 P1): /v1 roh an den eigenständigen mc2-gateway-Prozess
|
||||
# durchreichen. Diese Zeile entfernen (+ daemon-reload + restart) = Rollback, MC2
|
||||
# bedient /v1 wieder selbst.
|
||||
Environment=MC_V1_UPSTREAM=http://127.0.0.1:9010
|
||||
# Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry, Mem0-Dedupe)
|
||||
# laufen jetzt im eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese drei
|
||||
# Zeilen entfernen (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult).
|
||||
# Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry) laufen jetzt im
|
||||
# eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese zwei Zeilen entfernen
|
||||
# (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult).
|
||||
# Der reminders_loop bleibt bewusst HIER (teilt Datei+CRUD mit /api/reminders).
|
||||
Environment=MC_REWARM_ENABLED=0
|
||||
Environment=MC_SENTRY_ENABLED=0
|
||||
Environment=MC_MEM_DEDUPE_ENABLED=0
|
||||
# KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den
|
||||
# ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates
|
||||
# liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Arbeitsanweisung (gilt fuer jeden Auftrag)
|
||||
|
||||
Arbeite Datei fuer Datei. Lies HOECHSTENS zwei Dateien, bevor du die erste aenderst.
|
||||
Kein Gesamtplan, keine Vollinventur. Eine halbfertige Aenderung ist wertvoller als
|
||||
eine vollstaendige Analyse ohne Aenderung.
|
||||
|
||||
- Nach jeder geaenderten Datei: kurz sagen, was geaendert wurde, dann zur naechsten.
|
||||
- Nicht erst das ganze Projekt kartieren. Nicht auf Vollstaendigkeit warten.
|
||||
- Wenn du unsicher bist, aendere die wahrscheinlichste Datei und pruefe danach.
|
||||
- Schreiben schlaegt Lesen.
|
||||
@@ -0,0 +1,51 @@
|
||||
# OpenCode-Konfiguration des PCs
|
||||
|
||||
**Hier liegt die Quelle. `%USERPROFILE%\.config\opencode\` ist die Kopie.**
|
||||
|
||||
Am 21.08.2026 habe ich die dortige `opencode.json` ueberschrieben, ohne sie
|
||||
vorher zu lesen — sie existierte nur an dieser einen Stelle, ohne Sicherung und
|
||||
ohne Historie. Rekonstruiert aus einer Juli-Sicherung. Damit das nicht nochmal
|
||||
passiert, liegt sie jetzt hier.
|
||||
|
||||
```powershell
|
||||
Copy-Item "deploy\opencode-config\*" "$env:USERPROFILE\.config\opencode\" -Force
|
||||
```
|
||||
|
||||
## Modellwahl (Stand 22.08.2026)
|
||||
|
||||
| Rolle | Modell | warum |
|
||||
|---|---|---|
|
||||
| **build** | `aibox/coder` — Qwen3.8-27B (dicht) | macht die eigentliche Arbeit |
|
||||
| **plan** | `aibox/heavy` — gpt-oss-120b | denkt vor, aendert nichts |
|
||||
| **explore** | `aibox/hermes` — Qwen3.6-35B-A3B | liest und sucht, immer warm |
|
||||
| `small_model` | `aibox/hermes` | Titel, Zusammenfassungen |
|
||||
|
||||
★★ **Korrektur einer falschen Empfehlung.** Bis zum 22.08. stand hier `fast`
|
||||
(Qwen3.6-35B-A3B, MoE) als Standard, mit der Begruendung, es schlage den `coder`
|
||||
„in Tempo und Qualitaet". **Das Tempo war gemessen, die Qualitaet nie** — in der
|
||||
Notiz vom 20.08. steht fuer den Coder woertlich „keine agentische Messung".
|
||||
|
||||
Die veroeffentlichten Zahlen sagen etwas anderes:
|
||||
|
||||
| | `fast` (3.6-35B-A3B) | `coder` (3.8-27B) |
|
||||
|---|---|---|
|
||||
| SWE-bench Verified | ~73,4 | 73,4 |
|
||||
| Terminal-Bench 2.1 | — | **73,0** |
|
||||
| DeepSWE 1.1 | — | **42,2** (Vorgaenger 3.6-27B: 13,3) |
|
||||
| OSWorld-Verified | — | **84,3** |
|
||||
|
||||
**Einzelschuss gleichauf, agentisch ueber viele Schritte zieht der Coder davon** —
|
||||
und genau das ist der Betrieb hier. Alle Zahlen stammen von Qwen selbst, teils
|
||||
aus eigenen Benchmark-Fassungen; sie sind ein Indiz, kein Beweis.
|
||||
|
||||
**Der Preis, gemessen:** dieselbe Leseaufgabe brauchte mit `fast` **24,3 s**,
|
||||
mit `coder` **89,3 s** — 3,7× laenger in der Wanduhr. Der Coder ist dicht und
|
||||
bandbreitenlimitiert (17 GB / 215 GB/s ≈ 12,6 t/s, Hardware-Grenze, kein
|
||||
Konfigfehler). Er hat ausserdem `ttl=5400`, faellt also nach 90 Minuten aus dem
|
||||
Speicher und muss neu laden.
|
||||
|
||||
**Was noch aussteht:** der echte Vergleich an einer echten Aufgabe.
|
||||
`F:\Coding Stuff\mc2-referenz` liegt genau dafuer bereit — 17 Dateien, davon 5
|
||||
erledigt, mit objektivem Pruefbefehl `docs/aufgaben/referenz-check.sh`.
|
||||
Erst dieser Lauf entscheidet die Frage; bis dahin ist die Modellwahl begruendet,
|
||||
aber nicht bewiesen.
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"aibox": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "AI-Box (ueber MC2 :9001)",
|
||||
"options": {
|
||||
"baseURL": "http://192.168.178.151:9001/v1",
|
||||
"apiKey": "local"
|
||||
},
|
||||
"models": {
|
||||
"fast": {
|
||||
"name": "fast - Qwen3.6-35B-A3B (MoE) - schnell, fuer Erkunden und Kleinkram",
|
||||
"tool_call": true,
|
||||
"limit": {
|
||||
"context": 65536,
|
||||
"output": 16384
|
||||
}
|
||||
},
|
||||
"heavy": {
|
||||
"name": "heavy - Planen / Review (= Qwen3.8-27B, seit 04.09. mit DFlash2)",
|
||||
"tool_call": true,
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"name": "hermes - Erkunden (immer warm)",
|
||||
"tool_call": true,
|
||||
"limit": {
|
||||
"context": 65536,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"coder": {
|
||||
"name": "coder - Qwen3.8-27B (dicht) - Standard fuers Bauen, agentisch am staerksten",
|
||||
"tool_call": true,
|
||||
"limit": {
|
||||
"context": 131072,
|
||||
"output": 16384
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "aibox/coder",
|
||||
"small_model": "aibox/hermes",
|
||||
"instructions": [
|
||||
"C:/Users/TobisPC/.config/opencode/ARBEITSANWEISUNG.md"
|
||||
],
|
||||
"autoupdate": false,
|
||||
"agent": {
|
||||
"plan": {
|
||||
"model": "aibox/heavy",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"model": "aibox/coder",
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"webfetch": "allow",
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"ssh *": "deny",
|
||||
"scp *": "deny",
|
||||
"sftp *": "deny",
|
||||
"ssh arcane@192.168.178.162 *": "allow",
|
||||
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||
"scp *arcane@192.168.178.162*": "allow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"mode": "subagent",
|
||||
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten - nur lesen, laeuft auf dem immer warmen Hirn",
|
||||
"model": "aibox/hermes",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"mode": "subagent",
|
||||
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code - meldet Befunde, aendert nichts",
|
||||
"model": "aibox/heavy",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# OpenCode-Plugin: MC2-Governor
|
||||
|
||||
**Hier liegt die Quelle. Der Einsatzort ist eine Kopie.**
|
||||
|
||||
```
|
||||
deploy/opencode-plugin/mc2-governor.ts <- versioniert, hier aendern
|
||||
| kopieren
|
||||
v
|
||||
%USERPROFILE%\.config\opencode\plugin\ <- wird von OpenCode automatisch geladen
|
||||
```
|
||||
|
||||
Bis zum 21.08.2026 existierte diese Datei **nur** im Konfigordner des PCs — 11 KB
|
||||
verhaltensbestimmender Code ohne Sicherung und ohne Historie. Deshalb liegt sie jetzt hier.
|
||||
|
||||
## Ausbringen
|
||||
|
||||
```powershell
|
||||
Copy-Item "deploy\opencode-plugin\mc2-governor.ts" "$env:USERPROFILE\.config\opencode\plugin\" -Force
|
||||
```
|
||||
|
||||
Wirkt sofort in der naechsten OpenCode-Sitzung; OpenChamber muss dafuer nicht neu starten,
|
||||
eine neue Sitzung reicht.
|
||||
|
||||
## Was es tut
|
||||
|
||||
1. **Werkzeug-Zaun** (`tool.execute.before`) — blockt Handgriffe, die ein Agent nie
|
||||
unbeaufsichtigt tun darf. Leitplanke gegen Entgleisen, **keine** Sandbox.
|
||||
2. **Pruef-Tor + Reparaturschleife** (`session.idle`) — sagt der Agent „fertig", laeuft der
|
||||
Verify-Befehl aus der Datei `VERIFY` im Repo-Wurzelverzeichnis. ROT geht als naechster
|
||||
Auftrag zurueck an den Agenten, bis zu `MC2_LOOP_MAX_ROUNDS` mal.
|
||||
‼️ **Schlaeft derzeit:** es gibt in keinem Projekt eine `VERIFY`-Datei. Ohne sie tut
|
||||
diese Funktion nichts — schadet aber auch nicht.
|
||||
3. **Savepoint statt Zusammenfassen** (`session.compacted`) — beim Komprimieren fallen
|
||||
still die Regeln aus dem Kontext; stattdessen wird `SAVEPOINT.md` geschrieben.
|
||||
4. **Stimme** — Ereignisse gehen an MC2 `/api/voice/announce`, Lucy spricht sie.
|
||||
|
||||
Der zugehoerige **Governor-Proxy auf `:8100`** (Token-Zaehler mit Hart-Deckel, Juli 2026)
|
||||
**existiert nicht mehr** — kein Port, kein Ordner, keine Unit. Nachgemessen am 21.08.2026.
|
||||
Dieses Plugin haengt nicht an ihm.
|
||||
|
||||
## Schalter (Umgebungsvariablen)
|
||||
|
||||
| Variable | Default | Wirkung |
|
||||
|---|---|---|
|
||||
| `MC2_BOX_URL` | `http://192.168.178.151:9001` | MC2-Basis fuer Meldungen |
|
||||
| `MC2_FENCE_OFF` | — | `1` schaltet den **ganzen** Zaun ab (alles oder nichts) |
|
||||
| `MC2_LOOP_AUTOFIX` | an | `0` schaltet die Reparaturschleife ab |
|
||||
| `MC2_LOOP_MAX_ROUNDS` | `3` | Runden der Reparaturschleife |
|
||||
| `MC2_LOOP_ANNOUNCE` | an | `0` macht Lucy stumm |
|
||||
| `MC2_LOOP_SILENT` | — | `1` = Meldungen ohne Ton |
|
||||
|
||||
## Aenderung 21.08.2026: `git push` ist frei
|
||||
|
||||
Der Zaun blockte urspruenglich **jedes** `git push` („Veroeffentlichen ist Sache des
|
||||
Menschen"). Das passt nicht mehr: die Bahn ist jetzt PC → Gitea → Box, und jede Aenderung
|
||||
soll auch im git landen. Ein Agent, der committen, aber nicht pushen darf, laesst Arbeit
|
||||
auf einem einzelnen Rechner liegen.
|
||||
|
||||
**Erzwungenes Pushen bleibt verboten** — `--force`, `--mirror`, `--delete`. Das
|
||||
ueberschreibt fremde Arbeit auf dem Server und ist genau die Klasse Unfall, gegen die der
|
||||
Zaun existiert. `--force-with-lease` ist bewusst ausgenommen.
|
||||
|
||||
Beides gemessen: `git push --dry-run origin main` laeuft durch,
|
||||
`git push --force --dry-run origin main` wird mit `[MC2-ZAUN]` abgewiesen.
|
||||
|
||||
## ‼️ Falle: das `bash`-Werkzeug ist auf Windows kein bash
|
||||
|
||||
Es laeuft in **PowerShell**. `tail`, `head`, `grep` und Freunde gibt es dort nicht, und
|
||||
Pipes verhalten sich anders. Wer dem Agenten Befehle vorgibt, muss das beruecksichtigen.
|
||||
|
||||
## ‼️ Falle: Gitea-Anmeldung ist flatterhaft
|
||||
|
||||
Der Git Credential Manager haelt fuer die DDNS-Domain ein **OAuth-Token mit einer Stunde
|
||||
Laufzeit**. Laeuft es ab, scheitert der Push des Agenten mit
|
||||
`remote: Failed to authenticate user` — ein Push aus PowerShell erneuert es, danach geht es
|
||||
wieder. Dauerhafte Loesung waere ein langlebiges Gitea-Token auf die **interne** Adresse
|
||||
`http://192.168.178.153:3000` (die DDNS-Domain ist nachts durch die Zwangstrennung ohnehin
|
||||
zeitweise tot).
|
||||
|
||||
---
|
||||
|
||||
## ‼️ ABGESCHALTET am 22.08.2026
|
||||
|
||||
**Das Plugin ist nicht mehr aktiv.** Es liegt hier als Quelle und im Konfigordner
|
||||
unter `plugin-archiv/`, aber nicht mehr in `~/.config/opencode/plugin/`.
|
||||
|
||||
**Warum:** Beim ersten echten Arbeitslauf fiel auf, dass bei jeder Komprimierung
|
||||
zwei Dinge uebereinander passierten — OpenChamber dampfte den Verlauf ein, und der
|
||||
Governor schob sofort einen zusaetzlichen Auftrag nach („schreib SAVEPOINT.md").
|
||||
Der Agent verlor dadurch jedes Mal einen Zug an Buchhaltung statt an die Aufgabe.
|
||||
Nachgewiesen: `SAVEPOINT.md` in `mc2-referenz`, geschrieben 22.08. 14:47 mitten
|
||||
im Lauf.
|
||||
|
||||
Der User hatte den Abbau schon am 21.08. angeordnet („alle Guards, alle
|
||||
Governor, Stewards — brauchen wir nicht mehr"). Ich hatte damals das Behalten
|
||||
empfohlen und seine Antwort zum `git push`-Riegel als Zustimmung zum Rest
|
||||
gewertet. **Das war eine Annahme, keine Entscheidung.**
|
||||
|
||||
## Was damit wegfaellt — bewusst
|
||||
|
||||
| war | jetzt |
|
||||
|---|---|
|
||||
| Sperrliste: `rm -rf`, `sudo`, force push, `curl\|sh`, `ssh` ausser Arcane, `npm publish` | **kein Netz mehr** — der Agent darf alles, was die Shell hergibt |
|
||||
| Pruef-Tor + Reparaturschleife (`VERIFY`) | schlief ohnehin, es gab nirgends eine `VERIFY`-Datei im PC-Baum |
|
||||
| SAVEPOINT beim Komprimieren | weg — genau der Grund fuer den Abbau |
|
||||
| Meldungen an Lucys Stimme | weg |
|
||||
|
||||
**Wiederherstellen** (wenn der fehlende Zaun sich raecht):
|
||||
|
||||
```powershell
|
||||
Copy-Item "deploy\opencode-plugin\mc2-governor.ts" "$env:USERPROFILE\.config\opencode\plugin\"
|
||||
```
|
||||
|
||||
Plugins werden beim **Start des OpenCode-Servers** geladen — Aendern wirkt erst
|
||||
nach einem Neustart von OpenChamber, nicht sofort. Deshalb liess sich das Plugin
|
||||
auch mitten in einem laufenden Referenzlauf gefahrlos entfernen.
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten.
|
||||
*
|
||||
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
|
||||
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden
|
||||
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
|
||||
*
|
||||
* 1. WERKZEUG-ZAUN (tool.execute.before)
|
||||
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
|
||||
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
|
||||
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 —
|
||||
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
|
||||
*
|
||||
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
|
||||
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
|
||||
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
|
||||
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
|
||||
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
|
||||
*
|
||||
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
|
||||
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
|
||||
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
|
||||
* zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
|
||||
*
|
||||
* 4. STIMME (MC2 /api/voice/announce)
|
||||
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
|
||||
* Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code.
|
||||
*
|
||||
* Schalter (Umgebungsvariablen):
|
||||
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
|
||||
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
|
||||
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
|
||||
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
|
||||
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
|
||||
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
|
||||
*
|
||||
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt —
|
||||
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
|
||||
*/
|
||||
|
||||
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
|
||||
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
|
||||
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
|
||||
const SILENT = process.env.MC2_LOOP_SILENT === "1"
|
||||
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
|
||||
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
|
||||
|
||||
/**
|
||||
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile —
|
||||
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
|
||||
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
|
||||
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
|
||||
*/
|
||||
const FENCE: Array<{ rx: RegExp; why: string }> = [
|
||||
// 21.08.2026: normales `git push` ist FREI. Der Weg ist jetzt PC -> Gitea -> Box, und
|
||||
// jede Aenderung soll auch im git landen — ein Agent, der committen, aber nicht pushen
|
||||
// darf, laesst Arbeit auf einem einzelnen Rechner liegen. Was schlecht ist, faengt
|
||||
// hinterher die CI-Ampel; dieser Zaun muss es nicht vorher verhindern.
|
||||
// Erzwungenes Pushen bleibt verboten: das ueberschreibt fremde Arbeit auf dem Server
|
||||
// und ist genau die Klasse Unfall, gegen die der Zaun ueberhaupt existiert.
|
||||
{ rx: /\bgit\s+push\b[^\n]*(--force(?!-with-lease)|\s-f\b|--mirror\b|--delete\b)/,
|
||||
why: "git push --force/--mirror/--delete — ueberschreibt fremde Arbeit auf dem Server. Normales `git push` ist erlaubt; muss wirklich erzwungen werden, sag es dem Menschen." },
|
||||
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
|
||||
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
|
||||
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
|
||||
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
|
||||
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
|
||||
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
|
||||
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
|
||||
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
|
||||
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
|
||||
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
|
||||
]
|
||||
|
||||
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
|
||||
const rounds = new Map<string, number>()
|
||||
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
|
||||
const busy = new Set<string>()
|
||||
|
||||
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
|
||||
if (!ANNOUNCE_ON) return
|
||||
try {
|
||||
await fetch(`${BOX_URL}/api/voice/announce`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subject,
|
||||
text,
|
||||
source: "loop",
|
||||
priority: SILENT ? "silent" : priority,
|
||||
}),
|
||||
signal: AbortSignal.timeout(4000),
|
||||
})
|
||||
} catch {
|
||||
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
|
||||
}
|
||||
}
|
||||
|
||||
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
|
||||
const root: string = worktree || directory || process.cwd()
|
||||
|
||||
/**
|
||||
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
|
||||
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
|
||||
* nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig.
|
||||
*/
|
||||
async function readVerify(): Promise<string | null> {
|
||||
try {
|
||||
const { readFile } = await import("node:fs/promises")
|
||||
const { join } = await import("node:path")
|
||||
const raw = await readFile(join(root, "VERIFY"), "utf8")
|
||||
const cmd = raw
|
||||
.split("\n")
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l && !l.startsWith("#"))
|
||||
.join(" && ")
|
||||
return cmd || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt.
|
||||
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
|
||||
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
|
||||
* Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt.
|
||||
*/
|
||||
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
|
||||
try {
|
||||
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
|
||||
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
|
||||
return { ok: res.exitCode === 0, out: out.slice(-4000) }
|
||||
} catch (e: any) {
|
||||
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
|
||||
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text }] },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
|
||||
"tool.execute.before": async (input: any, output: any) => {
|
||||
if (FENCE_OFF) return
|
||||
if (input?.tool !== "bash") return
|
||||
const cmd: string = output?.args?.command ?? ""
|
||||
if (!cmd) return
|
||||
for (const rule of FENCE) {
|
||||
if (rule.rx.test(cmd)) {
|
||||
await announce(
|
||||
"[Zaun]",
|
||||
`Ich habe einen Befehl geblockt: ${rule.why}`,
|
||||
"silent",
|
||||
)
|
||||
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
|
||||
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
|
||||
throw new Error(
|
||||
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
|
||||
`Befehl war: ${cmd}\n` +
|
||||
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
|
||||
`er macht es selbst.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
|
||||
event: async ({ event }: any) => {
|
||||
const type: string = event?.type ?? ""
|
||||
const props: any = event?.properties ?? event ?? {}
|
||||
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
|
||||
|
||||
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
|
||||
if (type === "session.compacted" || type === "experimental.session.compacting") {
|
||||
await announce(
|
||||
"[Sitzung]",
|
||||
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
|
||||
"silent",
|
||||
)
|
||||
if (sessionID) {
|
||||
await sendPrompt(
|
||||
sessionID,
|
||||
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
|
||||
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
|
||||
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
|
||||
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
|
||||
if (type !== "session.idle" || !sessionID) return
|
||||
if (busy.has(sessionID)) return
|
||||
|
||||
const cmd = await readVerify()
|
||||
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
|
||||
|
||||
busy.add(sessionID)
|
||||
try {
|
||||
const { ok, out } = await runVerify(cmd)
|
||||
const round = rounds.get(sessionID) ?? 0
|
||||
|
||||
if (ok) {
|
||||
rounds.delete(sessionID)
|
||||
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
|
||||
return
|
||||
}
|
||||
|
||||
if (!AUTOFIX || round >= MAX_ROUNDS) {
|
||||
rounds.delete(sessionID)
|
||||
await announce(
|
||||
"[Pruefung]",
|
||||
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
|
||||
`Hier komme ich allein nicht weiter, Commander.`,
|
||||
"normal",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
rounds.set(sessionID, round + 1)
|
||||
await announce(
|
||||
"[Pruefung]",
|
||||
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
|
||||
"silent",
|
||||
)
|
||||
await sendPrompt(
|
||||
sessionID,
|
||||
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
|
||||
`Projekts ist fehlgeschlagen.\n\n` +
|
||||
`Befehl: ${cmd}\n\n` +
|
||||
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
|
||||
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
|
||||
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
|
||||
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
|
||||
)
|
||||
} finally {
|
||||
busy.delete(sessionID)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default MC2Governor
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Projekte-Sync (20.08.2026): haelt ~/projekte mit Gitea deckungsgleich.
|
||||
#
|
||||
# Warum: Der Hermes-Desktop laeuft gegen das Box-Backend, sein Ordner-Waehler zeigt
|
||||
# also das Dateisystem der BOX. Damit dort alle Projekte auftauchen, muessen sie hier
|
||||
# liegen — und aktuell sein.
|
||||
#
|
||||
# Regeln (bewusst konservativ — dieses Skript laeuft unbeaufsichtigt):
|
||||
# • NUR fast-forward (`--ff-only`). Kein merge, kein rebase, kein reset, kein force.
|
||||
# • Repos mit lokalen Aenderungen werden UEBERSPRUNGEN, nicht ueberfahren.
|
||||
# • Live-Deployments werden NIE gezogen (mission-control-v2 wird aus dem Deploy
|
||||
# bedient und pusht selbst) — sie bekommen nur eine Verknuepfung, damit sie im
|
||||
# Waehler auftauchen.
|
||||
# • Bestehende Checkouts in ~ bleiben, wo sie sind; ~/projekte bekommt eine
|
||||
# Verknuepfung darauf statt eines zweiten Klons.
|
||||
# • Interne Gitea-IP, NICHT die DDNS-Domain: die ist nachts durch die
|
||||
# Zwangstrennung zeitweise tot (Lehre vom 24.07.).
|
||||
#
|
||||
# Aufruf: projekte-sync.sh [--dry-run]
|
||||
# Exit: 0 = alles gut · 1 = mindestens ein Repo hatte ein Problem
|
||||
set -uo pipefail
|
||||
|
||||
GITEA="${GITEA_URL:-http://192.168.178.153:3000}"
|
||||
ZIEL="${PROJEKTE_DIR:-$HOME/projekte}"
|
||||
LOG="${PROJEKTE_SYNC_LOG:-$HOME/.hermes/logs/projekte-sync.log}"
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
# Repos, die NICHT nach ~/projekte geklont werden (Live-Deployment oder Altlast).
|
||||
# mission-control-v2: laeuft aus ~/mission-control-v2, Dienste arbeiten daraus.
|
||||
# mission-control: archivierte v1.
|
||||
NIE_ZIEHEN="mission-control-v2"
|
||||
UEBERSPRINGEN="mission-control"
|
||||
|
||||
mkdir -p "$ZIEL" "$(dirname "$LOG")"
|
||||
FEHLER=0
|
||||
|
||||
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; }
|
||||
|
||||
TOKEN=""
|
||||
for f in "$HOME/.config/gitea/create-token"; do
|
||||
[ -r "$f" ] && TOKEN="$(tr -d '[:space:]' < "$f")" && break
|
||||
done
|
||||
if [ -z "$TOKEN" ]; then
|
||||
log "ABBRUCH: kein Gitea-Token unter ~/.config/gitea/create-token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOS="$(curl -s -m 20 -H "Authorization: token $TOKEN" \
|
||||
"$GITEA/api/v1/user/repos?limit=100" \
|
||||
| python3 -c 'import sys,json; [print(r["name"]) for r in json.load(sys.stdin)]' 2>/dev/null)"
|
||||
if [ -z "$REPOS" ]; then
|
||||
log "ABBRUCH: Gitea lieferte keine Repo-Liste ($GITEA)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "=== Sync-Lauf ($(printf '%s\n' "$REPOS" | wc -l) Repos gemeldet)$([ $DRY -eq 1 ] && echo ' [Trockenlauf]') ==="
|
||||
|
||||
# Vorhandenen Checkout eines Repos finden (oberste Home-Ebene bevorzugt).
|
||||
finde_checkout() {
|
||||
local name="$1" d
|
||||
for d in "$HOME/$name" "$ZIEL/$name"; do
|
||||
[ -d "$d/.git" ] && { printf '%s' "$d"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
verknuepfe() { # $1=Zielpfad des echten Checkouts, $2=Repo-Name
|
||||
local ziel="$1" name="$2" link="$ZIEL/$name"
|
||||
[ "$ziel" = "$link" ] && return 0
|
||||
[ -L "$link" ] && [ "$(readlink -f "$link")" = "$(readlink -f "$ziel")" ] && return 0
|
||||
[ -e "$link" ] && [ ! -L "$link" ] && { log " ! $name: $link existiert und ist keine Verknuepfung — nichts getan"; return 1; }
|
||||
[ $DRY -eq 1 ] && { log " ~ $name: wuerde verknuepfen -> $ziel"; return 0; }
|
||||
ln -sfn "$ziel" "$link" && log " ~ $name: verknuepft -> ${ziel/$HOME/\~}"
|
||||
}
|
||||
|
||||
for name in $REPOS; do
|
||||
case " $UEBERSPRINGEN " in *" $name "*) log " - $name: uebersprungen (Altlast)"; continue;; esac
|
||||
|
||||
if checkout="$(finde_checkout "$name")"; then
|
||||
case " $NIE_ZIEHEN " in
|
||||
*" $name "*)
|
||||
log " = $name: Live-Deployment, kein Pull"
|
||||
verknuepfe "$checkout" "$name"
|
||||
continue;;
|
||||
esac
|
||||
|
||||
if [ -n "$(git -C "$checkout" status --porcelain 2>/dev/null)" ]; then
|
||||
log " ! $name: lokale Aenderungen — uebersprungen (nichts ueberfahren)"
|
||||
verknuepfe "$checkout" "$name"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Leeres Repo auf Gitea (angelegt, aber nie etwas gepusht): Es gibt keinen Branch, also
|
||||
# scheitert jeder Pull mit „no such ref was fetched“. So lief projekte-sync ab dem
|
||||
# 07.09.2026 stuendlich rot (TTT2-Toolbox_ClaudeEdition). Leer ist kein Fehler.
|
||||
# Scheitert ls-remote selbst (Netz weg), geht es normal weiter und der Pull meldet es.
|
||||
if kopf="$(git -C "$checkout" ls-remote --heads origin 2>/dev/null)" && [ -z "$kopf" ]; then
|
||||
log " - $name: auf Gitea noch leer — nichts zu holen"
|
||||
verknuepfe "$checkout" "$name"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ $DRY -eq 1 ]; then
|
||||
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
|
||||
else
|
||||
vorher="$(git -C "$checkout" rev-parse --short HEAD 2>/dev/null)"
|
||||
if out="$(git -C "$checkout" pull --ff-only 2>&1)"; then
|
||||
nachher="$(git -C "$checkout" rev-parse --short HEAD 2>/dev/null)"
|
||||
if [ "$vorher" = "$nachher" ]; then
|
||||
log " = $name: aktuell ($vorher)"
|
||||
else
|
||||
log " + $name: $vorher -> $nachher"
|
||||
fi
|
||||
else
|
||||
log " ! $name: Pull fehlgeschlagen — $(printf '%s' "$out" | tail -1)"
|
||||
FEHLER=1
|
||||
fi
|
||||
fi
|
||||
verknuepfe "$checkout" "$name"
|
||||
else
|
||||
if [ $DRY -eq 1 ]; then
|
||||
log " ~ $name: wuerde klonen nach ${ZIEL/$HOME/\~}/$name"
|
||||
elif out="$(git clone -q "$GITEA/Hitonabi/$name.git" "$ZIEL/$name" 2>&1)"; then
|
||||
log " + $name: neu geklont"
|
||||
else
|
||||
log " ! $name: Klonen fehlgeschlagen — $(printf '%s' "$out" | tail -1)"
|
||||
FEHLER=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
log "=== Ende (Fehler: $FEHLER) ==="
|
||||
exit $FEHLER
|
||||
@@ -0,0 +1,91 @@
|
||||
<#
|
||||
push-und-sync.ps1 — der Handschlag zwischen PC und AI-Box (21.08.2026).
|
||||
|
||||
Bahn: PC (Quelle der Wahrheit) -> Gitea -> Box ~/projekte
|
||||
|
||||
Warum ueberhaupt ein Skript: `projekte-sync` laeuft auf der Box stuendlich. Wer
|
||||
gerade gepusht hat, will aber nicht bis zu 60 Minuten warten. Das hier stoesst
|
||||
denselben Dienst sofort an — kein zweiter Mechanismus, nur ein Ausloeser.
|
||||
|
||||
Warum PowerShell und nicht Git-Bash: Push zum Gitea geht vom PC nur ueber den
|
||||
Git Credential Manager, und den gibt es hier (Lehre vom 24.07.).
|
||||
|
||||
Aufruf (im Projektordner):
|
||||
.\deploy\push-und-sync.ps1
|
||||
.\deploy\push-und-sync.ps1 -NurSync # nicht pushen, nur Box nachziehen
|
||||
.\deploy\push-und-sync.ps1 -Pfad "F:\Coding Stuff\rippy"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Pfad = (Get-Location).Path,
|
||||
[switch] $NurSync,
|
||||
[string] $BoxHost = "hitonabi@192.168.178.151"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Schritt($text) { Write-Host "`n== $text" -ForegroundColor Cyan }
|
||||
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
|
||||
function Warn($text) { Write-Host " $text" -ForegroundColor Yellow }
|
||||
|
||||
Set-Location $Pfad
|
||||
|
||||
# --- 1. Ist das ueberhaupt ein Repo, und was steht an? ---------------------
|
||||
$null = git rev-parse --is-inside-work-tree 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Kein Git-Repository: $Pfad" }
|
||||
|
||||
$zweig = (git branch --show-current).Trim()
|
||||
|
||||
# Repo-Name aus der Remote-URL, NICHT aus dem Ordnernamen: der lokale Ordner heisst
|
||||
# z. B. "mission-control-2", das Gitea-Repo aber "mission-control-v2". Der Ordnername
|
||||
# haette die Box-Abfrage still ins Leere laufen lassen.
|
||||
$remote = (git remote get-url origin).Trim()
|
||||
$repo = [System.IO.Path]::GetFileNameWithoutExtension(($remote -split '/')[-1])
|
||||
Schritt "Repo '$repo', Zweig '$zweig'"
|
||||
|
||||
$offen = git status --porcelain
|
||||
if ($offen) {
|
||||
Warn "Es liegen unversionierte/ungespeicherte Aenderungen:"
|
||||
$offen | Select-Object -First 10 | ForEach-Object { Warn " $_" }
|
||||
Warn "Diese werden NICHT mitgeschickt. Erst committen, dann erneut aufrufen."
|
||||
}
|
||||
|
||||
# --- 2. Pushen ------------------------------------------------------------
|
||||
if (-not $NurSync) {
|
||||
$ausstehend = git log --oneline "origin/$zweig..HEAD" 2>$null
|
||||
if (-not $ausstehend) {
|
||||
Gut "Nichts zu pushen — Gitea ist bereits auf Stand."
|
||||
} else {
|
||||
Schritt "Push nach Gitea ($(($ausstehend | Measure-Object).Count) Commit(s))"
|
||||
$ausstehend | ForEach-Object { Write-Host " $_" }
|
||||
git push origin $zweig
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw @"
|
||||
Push fehlgeschlagen.
|
||||
Seit 21.08. laeuft der Weg ueber SSH: ssh://gitea@192.168.178.153:2222/...
|
||||
Pruefen mit: ssh -T gitea
|
||||
Kommt dort keine Begruessung, lauscht Giteas SSH-Server nicht (Port 2222,
|
||||
START_SSH_SERVER in /etc/gitea/app.ini) oder id_general ist nicht hinterlegt.
|
||||
Der SSH-Benutzer heisst 'gitea', NICHT 'git' - Gitea laeuft unter diesem Namen.
|
||||
"@
|
||||
}
|
||||
Gut "gepusht"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 3. Box nachziehen lassen --------------------------------------------
|
||||
Schritt "Box zieht nach (projekte-sync)"
|
||||
ssh $BoxHost "systemctl --user start projekte-sync.service"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Sync-Dienst liess sich nicht starten." }
|
||||
|
||||
$stand = ssh $BoxHost "cd ~/projekte/$repo 2>/dev/null && git log --oneline -1 || echo 'NICHT-IN-PROJEKTE'"
|
||||
if ($stand -eq "NICHT-IN-PROJEKTE") {
|
||||
Warn "'$repo' liegt nicht unter ~/projekte."
|
||||
Warn "Bei mission-control-v2 ist das ABSICHT: das Live-Deployment liegt in ~/mission-control-v2"
|
||||
Warn "und wird bewusst nicht automatisch gezogen — es braucht seinen eigenen Deploy-Schritt."
|
||||
} else {
|
||||
Gut "Box steht jetzt auf: $stand"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-4
@@ -15,11 +15,13 @@ set -euo pipefail
|
||||
|
||||
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
|
||||
DEST_DIR="$MODELS_DIR/mc2-backups"
|
||||
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
|
||||
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
|
||||
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
SERVICES="mem0-service mission-control-2 hermes-gateway"
|
||||
# Alle User-Dienste, die von wiederhergestellter Config abhaengen. mc2-gateway (der
|
||||
# /v1-Denkpfad) und mc2-steward (Waechter) fehlten hier bis 27.08.2026 — nach einem
|
||||
# Restore liefen beide mit der ALTEN llama-swap-/Hermes-Config weiter.
|
||||
SERVICES="mission-control-2 mc2-gateway mc2-steward hermes-gateway"
|
||||
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
|
||||
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
|
||||
OFFSITE_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
|
||||
@@ -98,7 +100,6 @@ echo "--- Inhalt ---"; cat "$STAGE/MANIFEST.txt" 2>/dev/null || true; echo "----
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "[dry-run] würde zurücksetzen:"
|
||||
[ -d "$STAGE/mem0" ] && echo " • mem0 → $MEM0_DIR (wird ersetzt)"
|
||||
[ -f "$STAGE/hermes/config.yaml" ] && echo " • $HERMES/config.yaml"
|
||||
[ -f "$STAGE/hermes/.env" ] && echo " • $HERMES/.env"
|
||||
[ -d "$STAGE/hermes/plugins" ] && echo " • $HERMES/plugins/"
|
||||
@@ -119,7 +120,8 @@ bash "$SRC/deploy/backup.sh" || echo " (Pre-Restore-Backup fehlgeschlagen — f
|
||||
echo "→ Dienste stoppen…"
|
||||
systemctl --user stop $SERVICES 2>/dev/null || true
|
||||
|
||||
if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$STAGE/mem0/." "$MEM0_DIR/"; fi
|
||||
# Alte Snapshots (vor 27.08.2026) tragen noch ein Gedaechtnis-Verzeichnis mit — es wird
|
||||
# bewusst NICHT mehr zurueckgespielt, der zugehoerige Dienst existiert nicht mehr.
|
||||
[ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; }
|
||||
[ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/"
|
||||
[ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; }
|
||||
|
||||
@@ -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]))
|
||||
' 2>/dev/null || echo "(Queue nicht lesbar)"
|
||||
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 "## 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 "- Updates macht der Auto-Update-Timer mit Fangnetz — niemand sonst."
|
||||
} > "$STECK.tmp" 2>/dev/null
|
||||
|
||||
@@ -115,7 +115,7 @@ def chunk(t,d):
|
||||
c=t+d; return struct.pack(">I",len(d))+c+struct.pack(">I",zlib.crc32(c))
|
||||
png=b"\x89PNG\r\n\x1a\n"+chunk(b"IHDR",struct.pack(">IIBBBBB",w,h,8,2,0,0,0))+chunk(b"IDAT",zlib.compress(raw))+chunk(b"IEND",b"")
|
||||
url="data:image/png;base64,"+base64.b64encode(png).decode()
|
||||
print(json.dumps({"model":"coder","stream":False,"max_tokens":40,"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":url}},{"type":"text","text":"Welche Farbe hat das angehaengte Bild? Antworte nur mit der Farbe."}]}]}))
|
||||
print(json.dumps({"model":"coder","stream":False,"max_tokens":150,"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":url}},{"type":"text","text":"Welche Farbe hat das angehaengte Bild? Antworte nur mit der Farbe."}]}]}))
|
||||
PYIN
|
||||
)
|
||||
BW_HDR=$(mktemp); BW_OUT=$(mktemp)
|
||||
|
||||
@@ -44,7 +44,7 @@ heavy-Rollen einer Runde am Stueck (heavy bleibt warm), DANN der Skeptiker (ein
|
||||
aus — fuer diesen Hintergrund-Job gewollt. heavy hat nur **32k Kontext** → halte das Konzept-
|
||||
Dokument kompakt und schleppe KEINE alten Runden-Protokolle mit (siehe Kontext-Uebergabe).
|
||||
- **REASONING-MODELLE brauchen Token-Luft (verifiziert 14.07.).** heavy (gpt-oss) UND der Skeptiker
|
||||
(GLM-4.7-Flash) „denken" erst ausfuehrlich (im `reasoning_content`), bevor die eigentliche Antwort
|
||||
(`fast`) „denken" erst ausfuehrlich (im `reasoning_content`), bevor die eigentliche Antwort
|
||||
in `content` landet — `worker.sh`/`fremdblick.sh` lesen NUR `content`. Zu knappes `max_tokens` =
|
||||
das Modell verbraucht alles beim Denken, `content` bleibt LEER, das Skript meldet „FEHLGESCHLAGEN"
|
||||
(obwohl das Modell lief). GLM verbrennt real ~2000 Tokens Denken fuer 2 Zeilen Antwort. Deshalb
|
||||
@@ -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
|
||||
AUSDRUECKLICH mit Begruendung — still ignorieren gilt nicht.
|
||||
- **★ Explizite Commander-Wahl UEBERSTIMMT deine Einschaetzung.** Steht im Auftrag eine Zeile
|
||||
„AUFWAND (vom Commander gewaehlt): SIMPEL …" bzw. „… GRUENDLICH …" (der Commander hat im
|
||||
Auftragsbuch „IDE: simpel" oder „IDE: gruendlich" geklickt), dann HALTE DICH DARAN — SIMPEL =
|
||||
„AUFWAND (vom Commander gewaehlt): SIMPEL …" bzw. „… GRUENDLICH …" (der Commander hat in der
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
+220
-220
@@ -1,220 +1,220 @@
|
||||
---
|
||||
name: orchestrator
|
||||
description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate."
|
||||
version: 1.0.0
|
||||
author: MC2 (Autonomie — Orchestrator)
|
||||
platforms: [linux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [orchestrator, delegation, werkstatt, mc2, fliessband]
|
||||
---
|
||||
|
||||
# Orchestrator — du managst, du fuehrst nicht alles selbst aus
|
||||
|
||||
Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar
|
||||
abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du
|
||||
zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse
|
||||
zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet.
|
||||
|
||||
Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt
|
||||
(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst
|
||||
die Integration + das Gate.
|
||||
|
||||
## Warum seriell (und warum das ok ist)
|
||||
Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein
|
||||
Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.**
|
||||
Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal.
|
||||
|
||||
## Scope-Check zuerst (klein anfangen)
|
||||
Passt: ein Auftrag, der in **2–4 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 1–2-Datei-Bau-Schritt
|
||||
(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in
|
||||
wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung):
|
||||
**Etappen-Weg statt Ablehnung** (Grossbau-Entscheid 10.07.2026) — zerlege das Projekt in Etappen,
|
||||
von denen JEDE fuer sich lauffaehig, gate-gruen und einzeln annehmbar ist. Baue in DIESEM Lauf nur
|
||||
Etappe 1 (als normalen Orchestrator-Durchlauf mit Workern + Zwei-Kritiker-Gate); den Etappen-Plan
|
||||
in den Telegram-Vorschlag schreiben und die Folge-Etappen als neue Aufgaben in die Ideen-Queue
|
||||
legen (`hermes kanban create "<Projekt> — Etappe <n>: <was>" --triage`, im Body: Plan + Branch der
|
||||
Vor-Etappe). Eine Folge-Etappe wird erst gebaut, wenn der Branch der Vor-Etappe in main ist —
|
||||
NIE auf unangenommene Branches aufbauen. Rauere Qualitaet ist bei Grossbau ok, aber ehrlich im
|
||||
Vorschlag ausweisen, was fehlt oder wacklig ist.
|
||||
|
||||
## Leitplanken (nicht verhandelbar)
|
||||
- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf
|
||||
stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn
|
||||
vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung
|
||||
auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen
|
||||
seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann
|
||||
faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.)
|
||||
- **„Task zu klein" ist KEIN Freibrief (kein Ermessensspielraum).** Du darfst Planung, Delegation
|
||||
oder das Zwei-Kritiker-Gate NICHT eigenmaechtig ueberspringen — auch nicht transparent angesagt
|
||||
(„Overkill, ich mach das direkt" = GESCHEITERTER Lauf, Vorfall 09.07.2026). Wirkt der Auftrag
|
||||
wirklich zu klein fuers Fliessband, ist dein EINZIGER erlaubter Ausweg: **abbrechen ohne Code**
|
||||
und im Vorschlag melden „zu klein fuer den Orchestrator — bitte als Werkstatt-Auftrag (hermes -z)
|
||||
oder direkt im Desktop laufen lassen". Ein Lauf, der Code liefert, enthaelt IMMER worker.sh-Belege
|
||||
UND beide Kritiker-Verdikte — ohne Ausnahme.
|
||||
- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh`
|
||||
sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`).
|
||||
Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den
|
||||
Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen.
|
||||
- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander.
|
||||
NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten.
|
||||
- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt.
|
||||
- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen.
|
||||
Fuer die Umsetzung von Code nutzt du **`Qwen3-Coder-Next`** (laedt klein neben dem Warm-Set).
|
||||
Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy),
|
||||
auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen
|
||||
Hintergrund-Prozess gewollt.
|
||||
- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes
|
||||
Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind
|
||||
zusammenklebt, produziert leisen Muell.
|
||||
- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen.
|
||||
- **Hermes-first (10.07.2026):** Bevor ein Teil-Task NEUE Infrastruktur baut (Skript, Dienst,
|
||||
Tool, Cron), pruefe ob hermes-agent das nativ kann — erst in die Eigenbau-Landkarte schauen
|
||||
(`~/wissens-vault/eigenbau-landkarte.md`), notfalls `bash -lc 'hermes --help'`. Den
|
||||
Pruef-Befund in den Vorschlag schreiben („nativ vorhanden: nein/ja, weil …").
|
||||
- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Beratung & Zerlegen (Manager plant mit dem Chef)
|
||||
Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan:
|
||||
1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer:
|
||||
```bash
|
||||
printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt
|
||||
```
|
||||
2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen.
|
||||
3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`Qwen3-Coder-Next` + `build`/`refactor`), (c) Kontext.
|
||||
|
||||
### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags)
|
||||
Worktrees liegen unter `~/.hermes/worktrees/` (NICHT /tmp — ueberlebt keinen Reboot und
|
||||
hinterlaesst kaputte Worktree-Registrierungen, Review-Befund 15.07.):
|
||||
```
|
||||
mkdir -p ~/.hermes/worktrees && cd ~/mission-control-v2 && git fetch -q origin \
|
||||
&& git worktree add ~/.hermes/worktrees/orch-<slug> -b orchestrator/<slug> origin/main
|
||||
mkdir -p ~/.hermes/worktrees/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben
|
||||
```
|
||||
|
||||
### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell)
|
||||
Fuer jeden Bau-Teil-Task:
|
||||
1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits
|
||||
fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht.
|
||||
2. Ruf den Worker auf und fang die Ausgabe roh ab:
|
||||
```
|
||||
printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > ~/.hermes/worktrees/orch-<slug>-work/<n>.out
|
||||
```
|
||||
(Default-Modell ist `Qwen3-Coder-Next`. Fuer einen bewusst anderen Worker `WORKER_MODEL=<echte-id>`.)
|
||||
3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig
|
||||
Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun
|
||||
oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen
|
||||
Datei-Tools an seinen Platz im Worktree (`~/.hermes/worktrees/orch-<slug>/...`). Der Worker hat KEINE
|
||||
Datei-Haende — das Schreiben machst DU.
|
||||
4. **Schreibziel-Pflicht (Vorfall 09.07.2026):** JEDES write_file/patch-Ziel MUSS mit
|
||||
`~/.hermes/worktrees/orch-<slug>/` beginnen — vor dem ersten Schreiben einmal laut pruefen. NIEMALS nach
|
||||
`~/mission-control-v2/...` schreiben (Live-Checkout!), NIEMALS Work-Dirs/Worktrees FREMDER
|
||||
Slugs wiederverwenden (beim Doku-Lauf landete ein Artefakt im Live-Checkout, weil das alte
|
||||
`/tmp/orch-hermes-desktop-work` recycelt wurde). Existiert dein `~/.hermes/worktrees/orch-<slug>` noch nicht,
|
||||
ist das der Beweis, dass Schritt 2 fehlt — erst Worktree anlegen.
|
||||
5. **Grosse Artefakte NIE im Klartext in deine Antwort** — sie gehoeren in Dateien im Worktree.
|
||||
Wer Dateiinhalte in die Antwort kippt, stirbt am Output-Limit mitten im Lauf (Doku-Lauf
|
||||
09.07.2026: abgewuergt VOR Kritiker-Gate und Vorschlag). In der Antwort: Pfade + 1-Zeilen-Zusammenfassung.
|
||||
|
||||
### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende)
|
||||
Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung,
|
||||
Namen). Dann das Selbst-Gate, was zutrifft:
|
||||
- Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne
|
||||
schwere Deps laeuft: den Test im Worktree ausfuehren).
|
||||
- Shell geaendert → `bash -n <dateien>`
|
||||
- Frontend (`frontend/src/...`) → **Node EXISTIERT auf der Box** (bewiesen 09.07.2026, Läufe
|
||||
role-metadata + desktop-gateway): im Worktree `cd frontend && npm install && npm run build`
|
||||
ausfuehren und das neue `frontend/dist` MIT-committen (AGENTS.md-Pflicht — sonst zeigt die Box
|
||||
nach Merge+Deploy den alten Stand). **Danach `git checkout -- frontend/package-lock.json`**,
|
||||
falls npm install es nur als Nebeneffekt angefasst hat (wiederkehrendes Muster: 3 Laeufe in
|
||||
Folge hatten lockfile-Dreck) — es gehoert NUR in den Commit, wenn Dependencies sich wirklich
|
||||
aendern sollten. Schlaegt der Build fehl, ehrlich ausweisen: „Gate eingeschraenkt: Build
|
||||
fehlgeschlagen, tsc laeuft beim Merge auf dem PC."
|
||||
- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):**
|
||||
- `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du
|
||||
wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil
|
||||
der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht
|
||||
bemerkt).
|
||||
- `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben.
|
||||
- Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit
|
||||
`git -C ~/mission-control-v2 checkout -- <datei>` zuruecknehmen (deine Arbeit liegt sicher im
|
||||
Worktree) und im Vorschlag ehrlich erwaehnen.
|
||||
|
||||
### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht)
|
||||
Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben
|
||||
Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT
|
||||
`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision).
|
||||
|
||||
**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert
|
||||
untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen):
|
||||
```
|
||||
git -C ~/.hermes/worktrees/orch-<slug> add -A
|
||||
```
|
||||
Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind):
|
||||
```
|
||||
EINGABE="AUFTRAG (im Wortlaut):
|
||||
<der urspruengliche Auftrag>
|
||||
|
||||
ZERLEGUNG (deine Teil-Tasks, je 1 Zeile):
|
||||
<1..n>
|
||||
|
||||
GIT-DIFF des Worktrees:
|
||||
$(git -C ~/.hermes/worktrees/orch-<slug> diff --cached origin/main)"
|
||||
```
|
||||
Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben.
|
||||
**Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap):
|
||||
```
|
||||
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken):
|
||||
```
|
||||
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=GLM-4.6V-Flash FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit
|
||||
`URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT | FREIGABE>`. **Warte auf beide.**
|
||||
|
||||
**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den
|
||||
betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT`
|
||||
→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn
|
||||
BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt
|
||||
ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X
|
||||
fiel aus" vermerken (NICHT als Freigabe zaehlen).
|
||||
|
||||
> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB
|
||||
> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen
|
||||
> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind.
|
||||
- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen
|
||||
Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den
|
||||
Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander.
|
||||
- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich
|
||||
„⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln).
|
||||
|
||||
### 6. Telegram-Vorschlag (in LUCYS Stimme)
|
||||
`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" "<text>"` — an den Commander, nicht
|
||||
als anonymer Job: erst 1–2 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp:
|
||||
- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging),
|
||||
- geaenderte/neue Dateien · Kern des Diffs,
|
||||
- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"),
|
||||
- Branch-Name · Frage „merge oder verwerfen?"
|
||||
|
||||
### 7. Nichts loeschen
|
||||
Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat.
|
||||
|
||||
## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief
|
||||
Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein
|
||||
Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb:
|
||||
- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block),
|
||||
benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei
|
||||
Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut.
|
||||
- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib
|
||||
einen unified Diff gegen den folgenden Stand von `Y` aus".
|
||||
- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert
|
||||
den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht.
|
||||
|
||||
## Nach dem Commander-Entscheid (kommt als neuer Auftrag)
|
||||
- „verwerfen" → `git worktree remove ~/.hermes/worktrees/orch-<slug> --force && git branch -D orchestrator/<slug>`
|
||||
(+ `rm -rf ~/.hermes/worktrees/orch-<slug>-work`; Remote-Branch loeschen, falls gepusht).
|
||||
- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort).
|
||||
Du pushst NIE nach main.
|
||||
---
|
||||
name: orchestrator
|
||||
description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate."
|
||||
version: 1.0.0
|
||||
author: MC2 (Autonomie — Orchestrator)
|
||||
platforms: [linux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [orchestrator, delegation, werkstatt, mc2, fliessband]
|
||||
---
|
||||
|
||||
# Orchestrator — du managst, du fuehrst nicht alles selbst aus
|
||||
|
||||
Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar
|
||||
abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du
|
||||
zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse
|
||||
zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet.
|
||||
|
||||
Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt
|
||||
(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst
|
||||
die Integration + das Gate.
|
||||
|
||||
## Warum seriell (und warum das ok ist)
|
||||
Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein
|
||||
Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.**
|
||||
Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal.
|
||||
|
||||
## Scope-Check zuerst (klein anfangen)
|
||||
Passt: ein Auftrag, der in **2–4 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 1–2-Datei-Bau-Schritt
|
||||
(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in
|
||||
wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung):
|
||||
**Etappen-Weg statt Ablehnung** (Grossbau-Entscheid 10.07.2026) — zerlege das Projekt in Etappen,
|
||||
von denen JEDE fuer sich lauffaehig, gate-gruen und einzeln annehmbar ist. Baue in DIESEM Lauf nur
|
||||
Etappe 1 (als normalen Orchestrator-Durchlauf mit Workern + Zwei-Kritiker-Gate); den Etappen-Plan
|
||||
in den Telegram-Vorschlag schreiben und die Folge-Etappen als neue Aufgaben in die Ideen-Queue
|
||||
legen (`hermes kanban create "<Projekt> — Etappe <n>: <was>" --triage`, im Body: Plan + Branch der
|
||||
Vor-Etappe). Eine Folge-Etappe wird erst gebaut, wenn der Branch der Vor-Etappe in main ist —
|
||||
NIE auf unangenommene Branches aufbauen. Rauere Qualitaet ist bei Grossbau ok, aber ehrlich im
|
||||
Vorschlag ausweisen, was fehlt oder wacklig ist.
|
||||
|
||||
## Leitplanken (nicht verhandelbar)
|
||||
- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf
|
||||
stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn
|
||||
vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung
|
||||
auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen
|
||||
seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann
|
||||
faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.)
|
||||
- **„Task zu klein" ist KEIN Freibrief (kein Ermessensspielraum).** Du darfst Planung, Delegation
|
||||
oder das Zwei-Kritiker-Gate NICHT eigenmaechtig ueberspringen — auch nicht transparent angesagt
|
||||
(„Overkill, ich mach das direkt" = GESCHEITERTER Lauf, Vorfall 09.07.2026). Wirkt der Auftrag
|
||||
wirklich zu klein fuers Fliessband, ist dein EINZIGER erlaubter Ausweg: **abbrechen ohne Code**
|
||||
und im Vorschlag melden „zu klein fuer den Orchestrator — bitte als Werkstatt-Auftrag (hermes -z)
|
||||
oder direkt im Desktop laufen lassen". Ein Lauf, der Code liefert, enthaelt IMMER worker.sh-Belege
|
||||
UND beide Kritiker-Verdikte — ohne Ausnahme.
|
||||
- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh`
|
||||
sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`).
|
||||
Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den
|
||||
Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen.
|
||||
- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander.
|
||||
NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten.
|
||||
- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt.
|
||||
- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen.
|
||||
Fuer die Umsetzung von Code nutzt du **`coder`** (laedt klein neben dem Warm-Set).
|
||||
Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy),
|
||||
auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen
|
||||
Hintergrund-Prozess gewollt.
|
||||
- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes
|
||||
Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind
|
||||
zusammenklebt, produziert leisen Muell.
|
||||
- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen.
|
||||
- **Hermes-first (10.07.2026):** Bevor ein Teil-Task NEUE Infrastruktur baut (Skript, Dienst,
|
||||
Tool, Cron), pruefe ob hermes-agent das nativ kann — erst in die Eigenbau-Landkarte schauen
|
||||
(`~/wissens-vault/eigenbau-landkarte.md`), notfalls `bash -lc 'hermes --help'`. Den
|
||||
Pruef-Befund in den Vorschlag schreiben („nativ vorhanden: nein/ja, weil …").
|
||||
- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Beratung & Zerlegen (Manager plant mit dem Chef)
|
||||
Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan:
|
||||
1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer:
|
||||
```bash
|
||||
printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt
|
||||
```
|
||||
2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen.
|
||||
3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`coder` + `build`/`refactor`), (c) Kontext.
|
||||
|
||||
### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags)
|
||||
Worktrees liegen unter `~/.hermes/worktrees/` (NICHT /tmp — ueberlebt keinen Reboot und
|
||||
hinterlaesst kaputte Worktree-Registrierungen, Review-Befund 15.07.):
|
||||
```
|
||||
mkdir -p ~/.hermes/worktrees && cd ~/mission-control-v2 && git fetch -q origin \
|
||||
&& git worktree add ~/.hermes/worktrees/orch-<slug> -b orchestrator/<slug> origin/main
|
||||
mkdir -p ~/.hermes/worktrees/orch-<slug>-work # Ablage fuer rohe Worker-Ausgaben
|
||||
```
|
||||
|
||||
### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell)
|
||||
Fuer jeden Bau-Teil-Task:
|
||||
1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits
|
||||
fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht.
|
||||
2. Ruf den Worker auf und fang die Ausgabe roh ab:
|
||||
```
|
||||
printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > ~/.hermes/worktrees/orch-<slug>-work/<n>.out
|
||||
```
|
||||
(Default-Modell ist `coder`. Fuer einen bewusst anderen Worker `WORKER_MODEL=<rolle-oder-id>`.)
|
||||
3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig
|
||||
Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun
|
||||
oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen
|
||||
Datei-Tools an seinen Platz im Worktree (`~/.hermes/worktrees/orch-<slug>/...`). Der Worker hat KEINE
|
||||
Datei-Haende — das Schreiben machst DU.
|
||||
4. **Schreibziel-Pflicht (Vorfall 09.07.2026):** JEDES write_file/patch-Ziel MUSS mit
|
||||
`~/.hermes/worktrees/orch-<slug>/` beginnen — vor dem ersten Schreiben einmal laut pruefen. NIEMALS nach
|
||||
`~/mission-control-v2/...` schreiben (Live-Checkout!), NIEMALS Work-Dirs/Worktrees FREMDER
|
||||
Slugs wiederverwenden (beim Doku-Lauf landete ein Artefakt im Live-Checkout, weil das alte
|
||||
`/tmp/orch-hermes-desktop-work` recycelt wurde). Existiert dein `~/.hermes/worktrees/orch-<slug>` noch nicht,
|
||||
ist das der Beweis, dass Schritt 2 fehlt — erst Worktree anlegen.
|
||||
5. **Grosse Artefakte NIE im Klartext in deine Antwort** — sie gehoeren in Dateien im Worktree.
|
||||
Wer Dateiinhalte in die Antwort kippt, stirbt am Output-Limit mitten im Lauf (Doku-Lauf
|
||||
09.07.2026: abgewuergt VOR Kritiker-Gate und Vorschlag). In der Antwort: Pfade + 1-Zeilen-Zusammenfassung.
|
||||
|
||||
### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende)
|
||||
Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung,
|
||||
Namen). Dann das Selbst-Gate, was zutrifft:
|
||||
- Python geaendert → `python3 -m py_compile <dateien>` (und wenn ein Test dabei ist und ohne
|
||||
schwere Deps laeuft: den Test im Worktree ausfuehren).
|
||||
- Shell geaendert → `bash -n <dateien>`
|
||||
- Frontend (`frontend/src/...`) → **Node EXISTIERT auf der Box** (bewiesen 09.07.2026, Läufe
|
||||
role-metadata + desktop-gateway): im Worktree `cd frontend && npm install && npm run build`
|
||||
ausfuehren und das neue `frontend/dist` MIT-committen (AGENTS.md-Pflicht — sonst zeigt die Box
|
||||
nach Merge+Deploy den alten Stand). **Danach `git checkout -- frontend/package-lock.json`**,
|
||||
falls npm install es nur als Nebeneffekt angefasst hat (wiederkehrendes Muster: 3 Laeufe in
|
||||
Folge hatten lockfile-Dreck) — es gehoert NUR in den Commit, wenn Dependencies sich wirklich
|
||||
aendern sollten. Schlaegt der Build fehl, ehrlich ausweisen: „Gate eingeschraenkt: Build
|
||||
fehlgeschlagen, tsc laeuft beim Merge auf dem PC."
|
||||
- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):**
|
||||
- `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du
|
||||
wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil
|
||||
der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht
|
||||
bemerkt).
|
||||
- `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben.
|
||||
- Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit
|
||||
`git -C ~/mission-control-v2 checkout -- <datei>` zuruecknehmen (deine Arbeit liegt sicher im
|
||||
Worktree) und im Vorschlag ehrlich erwaehnen.
|
||||
|
||||
### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht)
|
||||
Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben
|
||||
Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT
|
||||
`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision).
|
||||
|
||||
**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert
|
||||
untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen):
|
||||
```
|
||||
git -C ~/.hermes/worktrees/orch-<slug> add -A
|
||||
```
|
||||
Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind):
|
||||
```
|
||||
EINGABE="AUFTRAG (im Wortlaut):
|
||||
<der urspruengliche Auftrag>
|
||||
|
||||
ZERLEGUNG (deine Teil-Tasks, je 1 Zeile):
|
||||
<1..n>
|
||||
|
||||
GIT-DIFF des Worktrees:
|
||||
$(git -C ~/.hermes/worktrees/orch-<slug> diff --cached origin/main)"
|
||||
```
|
||||
Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben.
|
||||
**Kritik A — Kompetenz** (`coder`, starker Coder, vom Bauen noch geladen → KEIN Swap):
|
||||
```
|
||||
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
**Kritik B — Fremd-Blick** (`debugger`, andere Modellfamilie, andere blinde Flecken):
|
||||
```
|
||||
printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=debugger FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit
|
||||
`URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT | FREIGABE>`. **Warte auf beide.**
|
||||
|
||||
**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den
|
||||
betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT`
|
||||
→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn
|
||||
BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt
|
||||
ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X
|
||||
fiel aus" vermerken (NICHT als Freigabe zaehlen).
|
||||
|
||||
> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB
|
||||
> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen
|
||||
> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind.
|
||||
- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen
|
||||
Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den
|
||||
Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander.
|
||||
- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich
|
||||
„⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln).
|
||||
|
||||
### 6. Telegram-Vorschlag (in LUCYS Stimme)
|
||||
`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" "<text>"` — an den Commander, nicht
|
||||
als anonymer Job: erst 1–2 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp:
|
||||
- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging),
|
||||
- geaenderte/neue Dateien · Kern des Diffs,
|
||||
- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"),
|
||||
- Branch-Name · Frage „merge oder verwerfen?"
|
||||
|
||||
### 7. Nichts loeschen
|
||||
Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat.
|
||||
|
||||
## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief
|
||||
Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein
|
||||
Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb:
|
||||
- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block),
|
||||
benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei
|
||||
Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut.
|
||||
- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib
|
||||
einen unified Diff gegen den folgenden Stand von `Y` aus".
|
||||
- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert
|
||||
den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht.
|
||||
|
||||
## Nach dem Commander-Entscheid (kommt als neuer Auftrag)
|
||||
- „verwerfen" → `git worktree remove ~/.hermes/worktrees/orch-<slug> --force && git branch -D orchestrator/<slug>`
|
||||
(+ `rm -rf ~/.hermes/worktrees/orch-<slug>-work`; Remote-Branch loeschen, falls gepusht).
|
||||
- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort).
|
||||
Du pushst NIE nach main.
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
---
|
||||
name: trend_radar
|
||||
description: "Liest die trend-radar-watchlist.json, recherchiert tagesaktuell nach Neuigkeiten zu den Modellen/Tools und erstellt bei Relevanz Prüfstand-Tickets in der Ideen-Queue."
|
||||
version: 1.0.0
|
||||
description: "Autonomer KI-Technologie-Scout. Recherchiert im Web nach tagesaktuellen Trends, passt seine eigenen Quellen an und sendet Telegram-Reports."
|
||||
version: 2.0.0
|
||||
author: MC2
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [research, radar, trends, mc2]
|
||||
tags: [research, radar, trends, mc2, self-modifying]
|
||||
---
|
||||
|
||||
# Trend Radar
|
||||
# Trend Radar (Autonomous & Self-Modifying)
|
||||
|
||||
Das Evolution-Radar für neue Open-Source KI-Modelle, Tools und Frameworks.
|
||||
Du bist der KI-Technologie-Scout von Mission Control 2.0. Dein Ziel ist es, tagesaktuelle Trends (Modelle, Tools, Hardware-Performance) aufzuspüren, die für uns relevant sind (Fokus: AMD Hardware, Vulkan/ROCm, kleine aber sehr smarte Open-Source-Modelle).
|
||||
|
||||
## Ablauf
|
||||
1. **Watchlist lesen**: Die Datei `deploy/trend-radar-watchlist.json` wird ausgelesen.
|
||||
2. **Recherche**: Für jeden Eintrag (z.B. Llama 4, Qwen 3) wird nach den neuesten Entwicklungen der letzten Wochen gesucht (z.B. via Hacker News oder direkten Web-Searches).
|
||||
3. **Auswertung**: Wenn ein Modell offiziell erschienen ist oder ein wesentliches Update erhalten hat, wird ein Konzept formuliert, wie das MC2-Projekt davon profitieren könnte.
|
||||
4. **Ideen-Queue füllen**: Es wird ein neues Kanban-Ticket / Prüfstand-Ticket in der Ideen-Queue erzeugt, damit der Commander oder das System es in die Werkstatt übernehmen kann.
|
||||
5. **Meldung**: Bericht an den Commander via Telegram.
|
||||
## Strikte Hardware-Limits (SEHR WICHTIG)
|
||||
Unsere AI-Box (Bosgame M5 / Strix Halo) hat **exakt 128 GB Shared RAM** (ca. 122.7 GB nutzbar)!
|
||||
Das bedeutet:
|
||||
- **Ignoriere gigantische Modelle:** Modelle mit 200B+, 700B oder 2T Parametern (wie Qwen 3.8 Max, GLM-5.5) sind für uns absolut nutzlos, da sie lokal niemals laufen werden.
|
||||
- **Größen-Limit:** Ein Modell darf im GGUF-Quant-Format (z.B. Q4_K_M) maximal **ca. 100-110 GB** groß sein. Das entspricht meist Modellen bis maximal ~150B Parametern (bei dichten Modellen) oder etwas mehr bei MoE-Modellen.
|
||||
- **Fokus auf Local LLMs:** Closed-API-Modelle interessieren uns nur am Rande. Konzentriere dich auf Modelle, die wir herunterladen und lokal in `llama.cpp` oder `Ollama` auf AMD-Hardware ausführen können. Sortiere alles rigoros aus, was zu groß für 128 GB RAM ist!
|
||||
|
||||
## Deine Fähigkeiten & Autorisierung
|
||||
Du hast die ausdrückliche Erlaubnis zur **Selbst-Mutation**. Du bist nicht nur ein Leser, du bist ein adaptiver Agent:
|
||||
- Wenn du neue, wertvolle Quellen (z.B. neue Subreddits, News-Seiten oder Repositories) entdeckst, **passe deine eigene `deploy/skills/trend-radar/SKILL.md` (diese Datei) an**, um diese für künftige Suchen fest zu verankern. Nutze dazu Dateibearbeitungs-Tools.
|
||||
- Du verwaltest die `deploy/trend-radar-watchlist.json`. Wenn Themen veraltet sind, lösche sie heraus. Wenn neue Modelle auftauchen (z.B. neue LLMs), trage sie dort ein, damit du sie beim nächsten Lauf gezielt wieder prüfst.
|
||||
|
||||
## Dein Ablauf
|
||||
1. **Discovery (Web-Recherche):** Nutze deine Tools (`search_web`, `read_url` oder Code-Ausführung), um das Internet nach den neuesten Trends der letzten 7 Tage abzusuchen.
|
||||
- Suche auf Hacker News nach "AI", "LLM", "Modelle".
|
||||
- Suche auf Reddit (z.B. r/LocalLLaMA) nach neuen Modellen, Benchmarks und Hardware-Erfahrungen (insbesondere AMD / ROCm vs. Vulkan).
|
||||
- Analysiere HuggingFace Trending.
|
||||
2. **Watchlist-Pflege:** Lies die `deploy/trend-radar-watchlist.json`. Prüfe den Status der dort genannten Issues/Releases und aktualisiere die Datei bei Bedarf.
|
||||
3. **Selbst-Anpassung (WICHTIG):** Editiere die `deploy/trend-radar-watchlist.json` oder diese `SKILL.md` direkt! Ignoriere eventuelle Bedenken bezüglich "Live-Git-Checkouts". Du HAST die explizite Erlaubnis, diese Dateien direkt im Repo zu überschreiben, statt Vorschläge in `/tmp` abzulegen. Halte deine Quellen dynamisch!
|
||||
4. **Ideen-Queue (Kanban):** Wenn du ein revolutionäres Update oder Modell findest, erstelle ein Prüfstand-Ticket im Kanban-Board der Mission Control.
|
||||
5. **Meldung an den Commander:** Sende **immer** eine Nachricht an den Commander. Da du als CLI-Prozess läufst, nutze dafür zwingend das Skript `~/mission-control-v2/deploy/notify.sh "Deine Nachricht"`. (Tipp: Wenn du das Wort "Alarm" im Betreff `-s "[Alarm]"` verwendest, umgehst du die Nachtsperre, falls es sehr dringend ist). Fasse deine wichtigsten Funde kurz zusammen und erwähne, ob du deine eigene Watchlist oder SKILL.md angepasst hast. Wenn es gar nichts Neues gab, melde auch das!
|
||||
|
||||
@@ -80,7 +80,7 @@ unangenommene Branches aufbauen — erst wenn die Vor-Etappe in main ist, kommt
|
||||
<git -C ~/.hermes/worktrees/wartung-<slug> diff origin/main>" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
|
||||
Das läuft auf `Qwen3-Coder-Next` (128k, code-stark, anderes Modell als der Qwen3.6-Worker,
|
||||
Das läuft auf `coder` (128k, code-stark, anderes Modell als der Qwen3.6-Worker,
|
||||
lädt klein neben dem Warm-Set) mit einem Code-Review-Raster. **Warte auf die Ausgabe.** Der
|
||||
Kritiker leitet den Fehlerfall SELBST aus der Bug-Beschreibung ab (glaubt dem Autor nicht),
|
||||
spielt den geänderten Code durch und beginnt mit `URTEIL: <ABGELEHNT | FREIGABE-MIT-VORBEHALT
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
# dass der komplette Inferenz-Stack noch FUNKTIONIERT — nicht nur "Befehl lief durch".
|
||||
# Exit 0 = alles ok, sonst 1 → der jobengine-Job wird im UI ROT (state=failed).
|
||||
#
|
||||
# Pendant zu hermes-postcheck.sh (prüft das Gehirn/Mem0); dieser prüft Router+Engine+MC2
|
||||
# Pendant zu hermes-postcheck.sh (prüft das Gehirn); dieser prüft Router+Engine+MC2
|
||||
# inkl. einer ECHTEN 1-Token-Inferenz (beweist, dass ein Modell wirklich lädt & generiert).
|
||||
set -uo pipefail
|
||||
|
||||
SWAP_URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}"
|
||||
MC_URL="${MC_URL:-http://127.0.0.1:9001}"
|
||||
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
|
||||
BRAIN="${MC_WARMUP_MODELS:-fast}"; BRAIN="${BRAIN%% *}" # erstes Modell, falls Liste
|
||||
EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Mem0/Gedächtnis)
|
||||
EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Reranker-Stufe)
|
||||
fail=0
|
||||
|
||||
echo "=== Stack Post-Update: Funktionsprüfung ==="
|
||||
@@ -48,14 +47,14 @@ else
|
||||
echo "FAIL · Inferenz auf '$BRAIN' fehlgeschlagen (Modell lädt/generiert nicht)"; fail=1
|
||||
fi
|
||||
|
||||
# 3b. Embedding-Modell (für Mem0/Gedächtnis) lädt und liefert einen Vektor?
|
||||
# 3b. Embedding-Modell lädt und liefert einen Vektor?
|
||||
eresp="$(curl -s -m 120 -X POST "$SWAP_URL/v1/embeddings" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"model\":\"$EMBED\",\"input\":\"ping\"}" 2>/dev/null)"
|
||||
if printf '%s' "$eresp" | grep -q '"embedding"'; then
|
||||
echo "PASS · Embedding-Modell '$EMBED' liefert Vektoren"
|
||||
else
|
||||
echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht (Mem0/Gedächtnis betroffen)"; fail=1
|
||||
echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht"; fail=1
|
||||
fi
|
||||
|
||||
# 4. MC2 selbst gesund (Engine + Gateway erreichbar)?
|
||||
@@ -85,12 +84,8 @@ if systemctl --user is-enabled --quiet mc2-steward 2>/dev/null; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Mem0-Sidecar (Gedächtnis) erreichbar?
|
||||
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then
|
||||
echo "PASS · Mem0-Sidecar erreichbar"
|
||||
else
|
||||
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
|
||||
fi
|
||||
# (Schritt 5 „Gedächtnis-Sidecar erreichbar" entfiel am 27.08.2026: der Dienst auf :8765 ist
|
||||
# seit der Ablösung im August tot, der Check konnte nur noch rot werden. docs/wissen/VERDIKTE.md)
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "=== STACK OK ✓ ==="
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
{
|
||||
"_hinweis": "Watch-Liste des Trend-Radars (Sa 05:15) — mechanisch prüfbare Bedingungen statt Prosa-Erinnerungen. Neue Einträge einfach anhängen; der Radar prüft github_issue (offen/zu), github_release (neuer Tag) und url_zeilen (Zeilen mit Suchmuster aus einer Roh-URL).",
|
||||
"_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": [
|
||||
{
|
||||
"key": "mmq-prefill-gfx1151",
|
||||
"typ": "github_issue",
|
||||
"key": "qwen-modelle",
|
||||
"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",
|
||||
"nummer": 21284,
|
||||
"bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged",
|
||||
"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)."
|
||||
"nummer": 28243,
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"typ": "github_release",
|
||||
"repo": "ROCm/ROCm",
|
||||
"bedingung": "Neuer ROCm-Release seit letzter Meldung",
|
||||
"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."
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"typ": "github_release_major",
|
||||
"repo": "electron/electron",
|
||||
"bedingung": "Neue MAJOR-Version seit letzter Meldung",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"key": "pocket-tts",
|
||||
"typ": "pypi",
|
||||
"paket": "pocket-tts",
|
||||
"bedingung": "Neue Version auf PyPI",
|
||||
"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.)."
|
||||
"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.)."
|
||||
},
|
||||
{
|
||||
"key": "lmstudio-bionic",
|
||||
"typ": "github_issue",
|
||||
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
||||
"nummer": 2185,
|
||||
"bedingung": "Issue geschlossen = Bionic-Linux-Port vermutlich da",
|
||||
"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."
|
||||
"warum": "LM Studio Bionic: erst interessant bei Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI. Bis dahin bleibt die Box :9001 draussen."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Mem0, Voice) altern
|
||||
# Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Voice) altern
|
||||
# eingefroren — kein Auto-Update-Kanal deckt sie ab. Dieser Audit MELDET nur
|
||||
# (bewusst kein Auto-Update: Python-Deps brechen gern):
|
||||
# je venv: veraltete Pakete zählen (pip list --outdated) + bekannte
|
||||
@@ -9,7 +9,7 @@
|
||||
# idempotent je Monat). Läuft als Hermes-Cron --no-agent (stdout = Telegram).
|
||||
set -uo pipefail
|
||||
|
||||
VENVS=("$HOME/.mem0/venv:mem0" "$HOME/.voice/venv:voice")
|
||||
VENVS=("$HOME/.voice/venv:voice")
|
||||
AUDIT_VENV="$HOME/.venv-audit"
|
||||
HERMES="$HOME/.local/bin/hermes"
|
||||
MONAT="$(date '+%Y-%m')"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# systemd-USER-Unit für den MC2 Voice-Sidecar — lokales STT + gestuftes TTS.
|
||||
#
|
||||
# ‼️ NACHGETRAGEN 27.08.2026: Diese Unit lief auf der Box, fehlte aber im Repo — weder
|
||||
# versioniert noch vom Disaster-Recovery abgedeckt, obwohl services/maintenance.py
|
||||
# (USER_SERVICES) und routers/system.py sie voraussetzen. Inhalt 1:1 von der laufenden Box
|
||||
# übernommen (`systemctl --user cat voice-service`).
|
||||
#
|
||||
# Ablage: ~/.config/systemd/user/voice-service.service ; dann:
|
||||
# systemctl --user daemon-reload && systemctl --user enable --now voice-service
|
||||
|
||||
[Unit]
|
||||
Description=MC2 Voice Sidecar — lokales STT (faster-whisper) + gestuftes TTS (Piper/Chatterbox)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv) — torch/chatterbox/faster-whisper passen nicht ins
|
||||
# 3.14-Backend-venv. Bind 127.0.0.1: nur lokal; MC2 proxyt nach außen (routers/voice.py).
|
||||
Type=simple
|
||||
WorkingDirectory=%h/mission-control-v2/voice_service
|
||||
Environment=VOICE_PORT=8650
|
||||
Environment=VOICE_STT_MODEL=medium
|
||||
Environment=VOICE_STT_LANG=de
|
||||
Environment=VOICE_PIPER_DIR=%h/.voice/voices
|
||||
Environment=VOICE_PIPER_DEFAULT=de_DE-thorsten-medium
|
||||
Environment=VOICE_CHATTERBOX_DEVICE=cpu
|
||||
Environment=VOICE_CHATTERBOX_LANG=de
|
||||
Environment=TOKENIZERS_PARALLELISM=false
|
||||
ExecStart=%h/.voice/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8650
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user