Compare commits
126
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 | ||
|
|
102149a5d6 | ||
|
|
0c849f4dcd | ||
|
|
94490f114d | ||
|
|
c360bbe14e | ||
|
|
6cb29ee70e | ||
|
|
a6e2f78c27 | ||
|
|
a201c20bf2 | ||
|
|
56676fe83c | ||
|
|
77bead5ba4 | ||
|
|
ee456de713 | ||
|
|
e16f0140f7 | ||
|
|
637c21387a | ||
|
|
da03de18cb | ||
|
|
1b0184eed8 | ||
|
|
4fb8387f79 | ||
|
|
cb884abdc8 | ||
|
|
0a6b6fd18d | ||
|
|
8f359ef4c9 | ||
|
|
327d5a495e | ||
|
|
69925c6cde | ||
|
|
a5410642fa | ||
|
|
c3851f8e25 | ||
|
|
29f8797fbf | ||
|
|
9886d80b1c | ||
|
|
5bc709221e | ||
|
|
14f8a5f405 | ||
|
|
16d2a06d95 | ||
|
|
45f799c57d | ||
|
|
e814122c73 | ||
|
|
d45f2fc6cb | ||
|
|
c2319739e2 | ||
|
|
1e68f62499 | ||
|
|
5504918a61 | ||
|
|
17a129038b | ||
|
|
93c17fe653 | ||
|
|
ae622dfa88 | ||
|
|
6637332b3e |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mc2-system": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
|
||||
]
|
||||
},
|
||||
"mc2-web": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -18,11 +18,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5180",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||
},
|
||||
"autoPort": false,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"autoMode": {
|
||||
"allow": [
|
||||
"$defaults",
|
||||
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
|
||||
]
|
||||
}
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git rev-list *)",
|
||||
@@ -87,6 +93,8 @@
|
||||
"WebFetch(domain:dev.to)",
|
||||
"WebFetch(domain:kyuz0.github.io)",
|
||||
"WebFetch(domain:kmarble.dev)"
|
||||
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
|
||||
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +43,87 @@ 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"
|
||||
git config --global user.email "actions@gitea.local"
|
||||
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||
# Erzeuge (oder hole) den orphan branch 'release-dist'
|
||||
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
|
||||
git rm -rf . >/dev/null 2>&1 || true
|
||||
|
||||
# Checkout nur frontend/dist vom aktuellen Stand
|
||||
git add -f frontend/dist
|
||||
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
|
||||
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
|
||||
git push origin HEAD:release-dist -f
|
||||
else
|
||||
echo "Keine Änderungen am Frontend-Build."
|
||||
fi
|
||||
# Zurück zum originalen Branch für den Rest des Skripts
|
||||
git checkout $CURRENT_COMMIT
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ __pycache__/
|
||||
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
|
||||
@@ -22,4 +17,3 @@ gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `RE
|
||||
## 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. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
**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,
|
||||
@@ -18,11 +24,16 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
|
||||
- **`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
|
||||
@@ -36,6 +47,14 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
|
||||
- **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)
|
||||
|
||||
@@ -11,77 +11,77 @@ 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` |
|
||||
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
|
||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||
| **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** | 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 |
|
||||
| `8100` | **Governor** — Token-Wächter vor dem Gateway | 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 |
|
||||
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
||||
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
||||
|
||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
|
||||
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 Zed bauen → Ampel → main
|
||||
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
|
||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
|
||||
└── autonomes Vibe Coding via OpenCode Desktop + MCP
|
||||
```
|
||||
|
||||
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||
Wächtern geboren**:
|
||||
|
||||
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
|
||||
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
|
||||
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
|
||||
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
|
||||
Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||
|
||||
## 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
|
||||
|
||||
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
|
||||
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
|
||||
2. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
|
||||
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
|
||||
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
|
||||
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
|
||||
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
|
||||
1. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
|
||||
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
|
||||
|
||||
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||
Kontext-Komprimierung löscht still die Regeln mit.
|
||||
@@ -98,7 +98,7 @@ Kontext-Komprimierung löscht still die Regeln mit.
|
||||
## API (Auswahl)
|
||||
|
||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||
memory/* · agent/* · **governor** · 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/).
|
||||
|
||||
@@ -128,13 +128,12 @@ Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `n
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
||||
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
|
||||
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
||||
|
||||
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
|
||||
[`deploy/governor/README.md`](deploy/governor/README.md).
|
||||
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
@@ -143,8 +142,7 @@ Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODEL
|
||||
| [`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 |
|
||||
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
|
||||
|
||||
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
||||
|
||||
+42
-35
@@ -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/`._
|
||||
_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 teils als Gedächtnis-Oberfläche für die Sprach-
|
||||
Begleiterin „Lucy".
|
||||
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).
|
||||
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).
|
||||
(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.
|
||||
|
||||
## 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.
|
||||
## 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 |
|
||||
|
||||
## 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.**
|
||||
Warm-Gruppe `brains` = embed + reranker + Qwen3.6-35B-A3B. Warm-Wächter weckt nur `fast`.
|
||||
|
||||
## 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.
|
||||
**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.
|
||||
|
||||
## 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.**
|
||||
## 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).
|
||||
|
||||
## Letzter Sicherungspunkt
|
||||
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
|
||||
## 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/`.
|
||||
|
||||
+27
-41
@@ -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,35 +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,
|
||||
eigenleben,
|
||||
boxwart,
|
||||
events,
|
||||
gateway_proxy,
|
||||
governor,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
maintenance,
|
||||
memory,
|
||||
models,
|
||||
radar,
|
||||
routing,
|
||||
system,
|
||||
voice,
|
||||
wissen,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
||||
from services import memory as memory_svc
|
||||
from services import metrics_history, reminders, warmer
|
||||
from starlette.requests import Request
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
@@ -57,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()))
|
||||
@@ -66,21 +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()))
|
||||
# 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()))
|
||||
if ketten_digest.ENABLED:
|
||||
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
|
||||
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
|
||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
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,
|
||||
)
|
||||
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(
|
||||
@@ -117,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(memory.router)
|
||||
|
||||
app.include_router(agent.router)
|
||||
app.include_router(
|
||||
voice.router
|
||||
@@ -139,20 +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(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
|
||||
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
||||
app.include_router(
|
||||
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.
|
||||
@@ -163,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:
|
||||
|
||||
+21
-13
@@ -19,20 +19,18 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# 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("/")
|
||||
# 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 --no-mmap"
|
||||
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
@@ -72,7 +70,7 @@ V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
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.
|
||||
# (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."""
|
||||
@@ -97,8 +95,14 @@ HERMES_API_KEY = (
|
||||
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.
|
||||
# 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
|
||||
@@ -108,8 +112,12 @@ BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0
|
||||
# Ö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
|
||||
# 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.
|
||||
@@ -119,7 +127,7 @@ HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
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("/")
|
||||
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")
|
||||
|
||||
@@ -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)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import eigenleben
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class SkillItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kategorie: str | None
|
||||
aktiv: bool | None
|
||||
beschreibung: str
|
||||
version: str
|
||||
autor: str
|
||||
herkunft: str
|
||||
herkunft_label: str
|
||||
genutzt: int
|
||||
zuletzt_genutzt: str | None
|
||||
erstellt: str | None
|
||||
status: str
|
||||
geaendert: float
|
||||
|
||||
|
||||
class EigenlebenOverviewResponse(BaseModel):
|
||||
available: bool
|
||||
skills: list[SkillItem]
|
||||
selbst_erstellt: int
|
||||
bilanz: dict
|
||||
|
||||
|
||||
class SkillTextResponse(BaseModel):
|
||||
id: str
|
||||
text: str
|
||||
|
||||
|
||||
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
|
||||
def get_overview() -> dict:
|
||||
return eigenleben.overview()
|
||||
|
||||
|
||||
# :path — Kategorie-Skills haben einen Schrägstrich in der ID (autonomous-ai-agents/
|
||||
# hermes-agent). Als normales {skill_id}-Segment matcht die Route dann nicht, der Request
|
||||
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
|
||||
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
|
||||
# services.eigenleben.skill_text.
|
||||
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
|
||||
def get_skill(skill_id: str) -> dict:
|
||||
text = eigenleben.skill_text(skill_id)
|
||||
if text is None:
|
||||
raise HTTPException(404, "Skill nicht gefunden.")
|
||||
return {"id": skill_id, "text": text}
|
||||
+69
-41
@@ -1,25 +1,26 @@
|
||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||
|
||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
||||
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.
|
||||
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(TICK_S)
|
||||
seit_ping += TICK_S
|
||||
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:
|
||||
|
||||
if 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"})
|
||||
|
||||
_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)
|
||||
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):
|
||||
# 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"):
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Governor — Fenster auf den Token-Waechter (:8100).
|
||||
|
||||
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
||||
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
||||
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
||||
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
||||
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
||||
zweiten Port ausweichen muss.
|
||||
|
||||
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
||||
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/governor")
|
||||
async def governor_status() -> dict:
|
||||
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as c:
|
||||
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
data["reachable"] = True
|
||||
return data
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"url": GOVERNOR_URL,
|
||||
}
|
||||
@@ -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 Zed
|
||||
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()}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import memory
|
||||
from services.voice_metrics import park, record_stage
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class MemIn(BaseModel):
|
||||
content: str
|
||||
category: str = "knowledge"
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class MemUp(BaseModel):
|
||||
content: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class DedupeIn(BaseModel):
|
||||
apply: bool = False
|
||||
threshold: float = 0.85
|
||||
|
||||
|
||||
class LearnIn(BaseModel):
|
||||
text: str | None = None
|
||||
messages: list[dict] | None = None
|
||||
source: str = "auto"
|
||||
category: str = "knowledge"
|
||||
|
||||
|
||||
@router.get("/memory/export")
|
||||
def export() -> dict:
|
||||
return memory.export_text()
|
||||
|
||||
|
||||
@router.get("/memory/graph")
|
||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
|
||||
return memory.graph(min_score=min_score, top_k=top_k)
|
||||
|
||||
|
||||
@router.post("/memory/learn", status_code=201)
|
||||
def learn(body: LearnIn) -> dict:
|
||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
||||
return memory.learn(text=body.text, messages=body.messages,
|
||||
source=body.source, category=body.category)
|
||||
|
||||
|
||||
@router.post("/memory/dedupe")
|
||||
def dedupe(body: DedupeIn) -> dict:
|
||||
return memory.dedupe(apply=body.apply, threshold=body.threshold)
|
||||
|
||||
|
||||
@router.get("/memory")
|
||||
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
||||
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
|
||||
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
|
||||
if q:
|
||||
_t0 = time.perf_counter()
|
||||
res = memory.list_memories(q=q, category=category)
|
||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
record_stage("memory_retrieve", _ms)
|
||||
park("retrieve", _ms)
|
||||
return res
|
||||
return memory.list_memories(q=q, category=category)
|
||||
|
||||
|
||||
@router.post("/memory", status_code=201)
|
||||
def add(body: MemIn) -> dict:
|
||||
if body.category not in memory.CATEGORIES:
|
||||
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
|
||||
return memory.add_memory(body.content, body.category, body.source)
|
||||
|
||||
|
||||
@router.put("/memory/{mid}")
|
||||
def update(mid: str, body: MemUp) -> dict:
|
||||
res = memory.update_memory(mid, content=body.content, category=body.category)
|
||||
if not res:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return res
|
||||
|
||||
|
||||
@router.delete("/memory/{mid}")
|
||||
def delete(mid: str) -> dict:
|
||||
if not memory.delete_memory(mid):
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return {"ok": True}
|
||||
@@ -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))
|
||||
@@ -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")})
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
|
||||
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
|
||||
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return VAULT.is_dir()
|
||||
|
||||
|
||||
def _title_of(p: Path) -> str:
|
||||
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
|
||||
try:
|
||||
with p.open(encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
s = line.strip()
|
||||
if s:
|
||||
return s.lstrip("# ").strip()[:160]
|
||||
except OSError:
|
||||
pass
|
||||
return p.stem
|
||||
|
||||
|
||||
class VaultFile(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
dir: str
|
||||
title: str
|
||||
mtime: float
|
||||
neu: bool
|
||||
|
||||
|
||||
class WissenListResponse(BaseModel):
|
||||
available: bool
|
||||
files: list[VaultFile]
|
||||
|
||||
|
||||
class WissenFileResponse(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
mtime: float
|
||||
|
||||
|
||||
@router.get("/wissen", response_model=WissenListResponse)
|
||||
def list_vault() -> dict:
|
||||
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
|
||||
if not _available():
|
||||
return {"available": False, "files": []}
|
||||
files = []
|
||||
now = time.time()
|
||||
for p in sorted(VAULT.rglob("*.md")):
|
||||
if ".git" in p.parts:
|
||||
continue
|
||||
rel = p.relative_to(VAULT).as_posix()
|
||||
st = p.stat()
|
||||
files.append({
|
||||
"path": rel,
|
||||
"name": p.stem,
|
||||
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
|
||||
"title": _title_of(p),
|
||||
"mtime": st.st_mtime,
|
||||
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
|
||||
})
|
||||
files.sort(key=lambda f: f["mtime"], reverse=True)
|
||||
return {"available": True, "files": files}
|
||||
|
||||
|
||||
@router.get("/wissen/datei", response_model=WissenFileResponse)
|
||||
def read_file(pfad: str) -> dict:
|
||||
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
|
||||
if not _available():
|
||||
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
|
||||
try:
|
||||
target = (VAULT / pfad).resolve()
|
||||
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
|
||||
"mtime": target.stat().st_mtime}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(404, "Notiz nicht lesbar.")
|
||||
@@ -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,461 +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
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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,
|
||||
})
|
||||
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}
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
|
||||
|
||||
Beantwortet die User-Frage vom 15.07.2026: „Es gibt keinen Viewpoint, welche Skills
|
||||
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
|
||||
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
|
||||
|
||||
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
|
||||
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
|
||||
selbst → weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
|
||||
repo → liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
|
||||
bundled → liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
|
||||
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
|
||||
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
|
||||
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SKILLS_DIR = Path.home() / ".hermes" / "skills"
|
||||
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
|
||||
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
|
||||
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
|
||||
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
|
||||
USAGE_FILE = SKILLS_DIR / ".usage.json"
|
||||
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
|
||||
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
|
||||
|
||||
_HERKUNFT_LABEL = {
|
||||
"selbst": "Selbst erstellt",
|
||||
"repo": "Von uns gebaut",
|
||||
"bundled": "Mit Hermes mitgeliefert",
|
||||
}
|
||||
|
||||
|
||||
def _frontmatter(text: str) -> dict:
|
||||
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
|
||||
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
|
||||
out: dict = {}
|
||||
if not text.startswith("---"):
|
||||
return out
|
||||
for line in text.split("\n", 1)[1].split("\n"):
|
||||
if line.strip() == "---":
|
||||
break
|
||||
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if not m:
|
||||
continue
|
||||
val = m.group(2).strip().strip('"').strip("'")
|
||||
if val:
|
||||
out[m.group(1)] = val
|
||||
return out
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"[\s_-]+", "", (s or "").lower())
|
||||
|
||||
|
||||
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
|
||||
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
|
||||
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
|
||||
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
|
||||
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
|
||||
_AKTIV_EVERY = 600.0 # s
|
||||
|
||||
|
||||
def _aktive_namen() -> set[str] | None:
|
||||
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
|
||||
if not HERMES_PY.is_file():
|
||||
return None
|
||||
now = time.time()
|
||||
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
|
||||
return _aktiv_cache["namen"]
|
||||
code = ("from hermes_cli.banner import get_available_skills;import json;"
|
||||
"print(json.dumps(get_available_skills()))")
|
||||
try:
|
||||
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
|
||||
text=True, timeout=60, cwd=str(HERMES_SRC))
|
||||
idx = r.stdout.find("{")
|
||||
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
|
||||
namen = {_norm(n) for liste in kategorien.values() for n in liste}
|
||||
except Exception:
|
||||
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
|
||||
_aktiv_cache.update(ts=now, namen=namen)
|
||||
return namen
|
||||
|
||||
|
||||
def list_skills() -> list[dict]:
|
||||
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
|
||||
if not SKILLS_DIR.is_dir():
|
||||
return []
|
||||
try:
|
||||
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
usage_raw = {}
|
||||
usage = {_norm(k): v for k, v in usage_raw.items()}
|
||||
aktive = _aktive_namen()
|
||||
|
||||
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
|
||||
try:
|
||||
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception:
|
||||
fm = {}
|
||||
if (BUNDLED_DIR / rel).is_dir():
|
||||
herkunft = "bundled"
|
||||
elif (REPO_DIR / rel).is_dir():
|
||||
herkunft = "repo"
|
||||
else:
|
||||
herkunft = "selbst"
|
||||
kurz = rel.split("/")[-1]
|
||||
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
|
||||
aktiv = None
|
||||
if aktive is not None:
|
||||
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
|
||||
return {
|
||||
"id": rel,
|
||||
"name": fm.get("name") or kurz,
|
||||
"kategorie": kategorie,
|
||||
"aktiv": aktiv,
|
||||
"beschreibung": fm.get("description") or "",
|
||||
"version": fm.get("version") or "",
|
||||
"autor": fm.get("author") or "",
|
||||
"herkunft": herkunft,
|
||||
"herkunft_label": _HERKUNFT_LABEL[herkunft],
|
||||
"genutzt": int(u.get("use_count") or 0),
|
||||
"zuletzt_genutzt": u.get("last_used_at") or None,
|
||||
"erstellt": u.get("created_at") or None,
|
||||
"status": u.get("state") or "active",
|
||||
"geaendert": md.stat().st_mtime,
|
||||
}
|
||||
|
||||
skills: list[dict] = []
|
||||
for d in sorted(SKILLS_DIR.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("."):
|
||||
continue # .archive (Curator) & Co. sind keine aktiven Skills
|
||||
md = d / "SKILL.md"
|
||||
if md.is_file():
|
||||
skills.append(eintrag(md, d.name, None))
|
||||
continue
|
||||
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
|
||||
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
|
||||
for sub in sorted(d.iterdir()):
|
||||
smd = sub / "SKILL.md"
|
||||
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
|
||||
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
|
||||
# Selbst erstellte zuerst — das ist der Star der Ansicht.
|
||||
order = {"selbst": 0, "repo": 1, "bundled": 2}
|
||||
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
|
||||
return skills
|
||||
|
||||
|
||||
def skill_text(skill_id: str) -> str | None:
|
||||
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
|
||||
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
|
||||
return None
|
||||
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
|
||||
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
|
||||
if any(seg in (".", "..") for seg in skill_id.split("/")):
|
||||
return None
|
||||
md = SKILLS_DIR / skill_id / "SKILL.md"
|
||||
if not md.is_file():
|
||||
return None
|
||||
try:
|
||||
return md.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def bilanz() -> dict:
|
||||
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
|
||||
eintraege: list[dict] = []
|
||||
try:
|
||||
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
|
||||
for branch, info in branches.items():
|
||||
eintraege.append({
|
||||
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
|
||||
"titel": branch,
|
||||
"detail": info.get("detail") or "",
|
||||
"ts": float(info.get("ts") or 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
j = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
eintraege.append({
|
||||
"art": "abgelehnt",
|
||||
"titel": j.get("subject") or j.get("branch") or "?",
|
||||
"detail": j.get("grund") or "",
|
||||
"ts": float(j.get("ts") or 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
eintraege.sort(key=lambda e: -e["ts"])
|
||||
return {
|
||||
"eintraege": eintraege,
|
||||
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
|
||||
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
|
||||
}
|
||||
|
||||
|
||||
def overview() -> dict:
|
||||
skills = list_skills()
|
||||
b = bilanz()
|
||||
return {
|
||||
"available": SKILLS_DIR.is_dir(),
|
||||
"skills": skills,
|
||||
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
|
||||
"bilanz": b,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"""
|
||||
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
|
||||
|
||||
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
|
||||
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
|
||||
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
|
||||
|
||||
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
|
||||
einmal pro Karte, mit der Frage im Wortlaut,
|
||||
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
|
||||
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
|
||||
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
|
||||
|
||||
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
|
||||
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
|
||||
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
|
||||
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
|
||||
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
|
||||
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
|
||||
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
|
||||
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
|
||||
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
|
||||
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
|
||||
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
|
||||
|
||||
|
||||
def _kontext_druck(task_id: str) -> int:
|
||||
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
|
||||
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
|
||||
try:
|
||||
p = KANBAN_LOGS / f"{task_id}.log"
|
||||
with p.open("rb") as f:
|
||||
f.seek(max(0, p.stat().st_size - 200_000))
|
||||
tail = f.read().decode("utf-8", errors="replace")
|
||||
return tail.count("Compacting context")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
try:
|
||||
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
return d if isinstance(d, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
try:
|
||||
tmp = STATE_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(STATE_PATH)
|
||||
except OSError:
|
||||
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _melden(subject: str, text: str) -> None:
|
||||
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
|
||||
from services import announce
|
||||
announce.notify_telegram(subject, text)
|
||||
try:
|
||||
announce.add(text, subject, "ketten-digest", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _tick() -> None:
|
||||
from services import ideen
|
||||
|
||||
data = ideen.list_queue()
|
||||
if not data.get("available"):
|
||||
return
|
||||
items = data.get("items") or []
|
||||
projekte = data.get("projekte") or []
|
||||
state = _load_state()
|
||||
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
||||
projekt_state: dict = state.setdefault("projekte", {})
|
||||
dirty = False
|
||||
now = int(time.time())
|
||||
|
||||
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
|
||||
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
|
||||
for it in items:
|
||||
if it.get("status") != "blocked":
|
||||
continue
|
||||
frage = (it.get("frage") or "").strip()
|
||||
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
|
||||
if key in gemeldete_fragen:
|
||||
continue
|
||||
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}“\n"
|
||||
+ (f"Frage: {frage[:500]}\n" if frage else "")
|
||||
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
|
||||
_melden("[Werkstatt]", text)
|
||||
gemeldete_fragen[key] = now
|
||||
dirty = True
|
||||
|
||||
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
|
||||
gemeldete_haenger: dict = state.setdefault("haenger", {})
|
||||
for it in items:
|
||||
if it.get("status") != "running":
|
||||
continue
|
||||
puls = it.get("puls_alter")
|
||||
if puls is None or puls < HANG_SEKUNDEN:
|
||||
continue
|
||||
key = f"{it['id']}:{it.get('gestartet') or 0}"
|
||||
if key in gemeldete_haenger:
|
||||
continue
|
||||
minuten = int(puls / 60)
|
||||
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
|
||||
f"seit {minuten} min kein Lebenszeichen vom Worker.")
|
||||
druck = _kontext_druck(it["id"])
|
||||
if druck >= 2:
|
||||
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
|
||||
"die Aufgabe ist womöglich zu groß geschnitten.")
|
||||
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
|
||||
_melden("[Werkstatt]", text)
|
||||
gemeldete_haenger[key] = now
|
||||
dirty = True
|
||||
if len(gemeldete_haenger) > 200:
|
||||
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
|
||||
gemeldete_haenger.pop(k, None)
|
||||
dirty = True
|
||||
|
||||
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
|
||||
if len(gemeldete_fragen) > 200:
|
||||
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
|
||||
gemeldete_fragen.pop(k, None)
|
||||
dirty = True
|
||||
|
||||
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
|
||||
for p in projekte:
|
||||
ps = projekt_state.setdefault(p["key"], {})
|
||||
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
|
||||
|
||||
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
|
||||
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
|
||||
"Ergebnisse liegen im Auftragsbuch.")
|
||||
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
|
||||
dirty = True
|
||||
continue
|
||||
|
||||
aktiv = p.get("laeuft", 0) > 0
|
||||
if not aktiv:
|
||||
continue
|
||||
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
|
||||
continue
|
||||
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
|
||||
naechste = [i for i in items if i.get("projekt") == p["key"]
|
||||
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
|
||||
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
|
||||
for l in laufende[:2]:
|
||||
note = f" — {l['notiz']}" if l.get("notiz") else ""
|
||||
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
|
||||
if naechste:
|
||||
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
|
||||
haengt = p.get("haengt", 0)
|
||||
if haengt:
|
||||
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
|
||||
_melden("[Werkstatt]", "\n".join(zeilen))
|
||||
ps.update(letzter_puls=now, letzter_stand=fertig)
|
||||
dirty = True
|
||||
|
||||
if dirty:
|
||||
_save_state(state)
|
||||
|
||||
|
||||
async def digest_loop() -> None:
|
||||
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
|
||||
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_tick)
|
||||
except Exception:
|
||||
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
@@ -19,8 +20,11 @@ from services import catalog, discover, jobengine, llamaswap, system
|
||||
|
||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||
SYSTEM_SERVICES = {"llama-swap"}
|
||||
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
|
||||
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||
"hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||
"projekte-sync", "mc2-backup", "mc2-radar"}
|
||||
|
||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
@@ -73,8 +77,9 @@ def _installed_engine_build() -> int | None:
|
||||
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
|
||||
timeout=20, env=env)
|
||||
txt = (out.stderr or "") + (out.stdout or "")
|
||||
# Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: <hash> (9821)", "b9821".
|
||||
if (m := re.search(r"version:\s*(\d{3,})", txt)) \
|
||||
# Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: <hash> (9821)", "b9821".
|
||||
if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \
|
||||
or (m := re.search(r"version:\s*(\d{3,})", txt)) \
|
||||
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
|
||||
or (m := re.search(r"\bb(\d{3,})\b", txt)):
|
||||
return int(m.group(1))
|
||||
@@ -509,6 +514,16 @@ def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
||||
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||
|
||||
|
||||
def _hermes_version(path: str) -> str | None:
|
||||
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
|
||||
try:
|
||||
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def hermes_update_details() -> dict:
|
||||
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
||||
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
||||
@@ -532,6 +547,7 @@ def hermes_update_details() -> dict:
|
||||
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
||||
info["commits"] = commits
|
||||
info["behind"] = len(commits)
|
||||
info["installed_version"] = _hermes_version(path)
|
||||
if commits:
|
||||
info.update(_summarize_hermes_commits(commits))
|
||||
except Exception as exc:
|
||||
@@ -545,59 +561,59 @@ def update_details(kind: str) -> dict:
|
||||
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
||||
|
||||
|
||||
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
||||
actual_cmd = list(cmd)
|
||||
has_sudo = False
|
||||
def _run(cmd: list[str]) -> dict:
|
||||
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
|
||||
|
||||
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
|
||||
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
|
||||
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
|
||||
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
|
||||
ganze Pfad war also totes Risiko ohne Gegenwert.
|
||||
|
||||
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
|
||||
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
|
||||
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
|
||||
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
|
||||
actual_cmd = list(cmd)
|
||||
if cmd and cmd[0] == "sudo":
|
||||
has_sudo = True
|
||||
# If we have a password, use -S instead of -n
|
||||
if sudo_password is not None:
|
||||
if "-n" in actual_cmd:
|
||||
actual_cmd = [x for x in actual_cmd if x != "-n"]
|
||||
if "-S" not in actual_cmd:
|
||||
actual_cmd.insert(1, "-S")
|
||||
else:
|
||||
# Force -n to fail cleanly if password is required
|
||||
if "-S" in actual_cmd:
|
||||
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
||||
if "-n" not in actual_cmd:
|
||||
actual_cmd.insert(1, "-n")
|
||||
|
||||
try:
|
||||
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
|
||||
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
|
||||
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
err_msg = p.stderr or ""
|
||||
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
||||
if sudo_password is not None:
|
||||
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "",
|
||||
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
|
||||
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
|
||||
|
||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
|
||||
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
|
||||
res = _run(["sudo", "true"], sudo_password=sudo_password)
|
||||
def check_sudo_needs_password() -> dict | None:
|
||||
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
|
||||
res = _run(["sudo", "true"])
|
||||
if not res["ok"]:
|
||||
return res
|
||||
return None
|
||||
|
||||
|
||||
def restart_service(name: str, sudo_password: str | None = None) -> dict:
|
||||
def restart_service(name: str) -> dict:
|
||||
if name in SYSTEM_SERVICES:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
|
||||
return _run(["sudo", "systemctl", "restart", name])
|
||||
if name in USER_SERVICES:
|
||||
return _run(["systemctl", "--user", "restart", name])
|
||||
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
||||
|
||||
|
||||
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
|
||||
def logs(service: str, lines: int = 200) -> dict:
|
||||
lines = max(1, min(lines, 1000))
|
||||
if service in USER_SERVICES:
|
||||
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
@@ -608,15 +624,14 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
|
||||
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
if r["ok"]:
|
||||
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
|
||||
sudo_password=sudo_password)
|
||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||
|
||||
def check_updates_job(sudo_password: str | None = None) -> dict:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
def check_updates_job() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
|
||||
def on_done():
|
||||
@@ -624,7 +639,7 @@ def check_updates_job(sudo_password: str | None = None) -> dict:
|
||||
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
||||
|
||||
cmd = "sudo apt-get update"
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
@@ -636,10 +651,10 @@ def _maintenance_busy() -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def os_update_job(sudo_password: str | None = None) -> dict:
|
||||
def os_update_job() -> dict:
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
||||
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
||||
@@ -648,11 +663,11 @@ def os_update_job(sudo_password: str | None = None) -> dict:
|
||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||
f"&& bash {STACK_POSTCHECK}")
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
||||
group="maintenance", sudo_password=sudo_password)
|
||||
group="maintenance")
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
def engine_update_job() -> dict | None:
|
||||
if not ENGINE_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
@@ -673,7 +688,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
def swap_update_job() -> dict | None:
|
||||
if not SWAP_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
@@ -717,10 +732,15 @@ def _hermes_update_cmd() -> str:
|
||||
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
||||
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
||||
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
||||
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
|
||||
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
|
||||
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
|
||||
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
|
||||
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
|
||||
return ("RC=0; "
|
||||
f"bash {backup} || true; "
|
||||
f"systemctl --user stop hermes-builtin-ui || true; "
|
||||
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
|
||||
f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
|
||||
f"&& {py} -m hermes_cli.main doctor "
|
||||
f"&& {build_cmd}; }} || RC=1; "
|
||||
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
||||
@@ -745,12 +765,12 @@ def hermes_update_job() -> dict:
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
def update_all_job() -> dict:
|
||||
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
||||
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
||||
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
||||
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
||||
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
|
||||
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
upd = updates()
|
||||
@@ -763,9 +783,10 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
||||
os_pending = (upd.get("os") or 0) > 0
|
||||
if os_pending:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
|
||||
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
|
||||
if err := check_sudo_needs_password():
|
||||
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
|
||||
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
|
||||
# den Fehler ehrlich durchreichen.
|
||||
if not parts:
|
||||
return err
|
||||
os_pending = False
|
||||
@@ -790,12 +811,11 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
|
||||
labels = " → ".join(label for label, _ in parts)
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||
group="maintenance", on_done=on_done,
|
||||
sudo_password=sudo_password)
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
||||
|
||||
|
||||
def reboot(sudo_password: str | None = None) -> dict:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
def reboot() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
return _run(["sudo", "reboot"], sudo_password=sudo_password)
|
||||
return _run(["sudo", "reboot"])
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
||||
|
||||
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
|
||||
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
|
||||
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
||||
|
||||
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
|
||||
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
|
||||
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
|
||||
|
||||
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
import httpx
|
||||
from config import MEM0_SERVICE_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CATEGORIES = ("identity", "knowledge", "rules", "events")
|
||||
|
||||
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
||||
|
||||
|
||||
def _get(path: str, **params) -> list | dict:
|
||||
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, data: dict) -> dict:
|
||||
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict) -> dict:
|
||||
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _delete(path: str) -> dict:
|
||||
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def list_memories(q: str = "", category: str = "") -> list[dict]:
|
||||
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
|
||||
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
|
||||
|
||||
|
||||
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
|
||||
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
|
||||
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
|
||||
|
||||
|
||||
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
|
||||
try:
|
||||
return _put(f"/memory/{mid}", {"content": content, "category": category})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def delete_memory(mid: str) -> bool:
|
||||
try:
|
||||
_delete(f"/memory/{mid}")
|
||||
return True
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def learn(text: str | None = None, messages: list[dict] | None = None,
|
||||
source: str = "auto", category: str = "stable") -> dict:
|
||||
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
|
||||
return _post("/learn", {"text": text, "messages": messages,
|
||||
"source": source, "category": category})
|
||||
|
||||
|
||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
||||
return _get("/graph", min_score=min_score, top_k=top_k)
|
||||
|
||||
|
||||
def export_text() -> dict:
|
||||
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
||||
lines = ["# Mission Control — Gedächtnis\n"]
|
||||
current = ""
|
||||
for r in rows:
|
||||
if r.get("category") != current:
|
||||
current = r.get("category", "")
|
||||
lines.append(f"\n## {current}\n")
|
||||
src = r.get("source", "")
|
||||
when = (r.get("updated_at") or "")[:10]
|
||||
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
|
||||
return {"text": "\n".join(lines), "count": len(rows)}
|
||||
|
||||
|
||||
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
|
||||
def _norm(s: str) -> str:
|
||||
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
||||
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
|
||||
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
|
||||
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
|
||||
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
|
||||
used: set[str] = set()
|
||||
groups: list[dict] = []
|
||||
for i, a in enumerate(rows):
|
||||
if a["id"] in used:
|
||||
continue
|
||||
na = _norm(a.get("content", ""))
|
||||
if not na:
|
||||
continue
|
||||
dups = []
|
||||
for b in rows[i + 1:]:
|
||||
if b["id"] in used or b.get("category") != a.get("category"):
|
||||
continue
|
||||
nb = _norm(b.get("content", ""))
|
||||
if not nb:
|
||||
continue
|
||||
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
||||
dups.append(b); used.add(b["id"])
|
||||
if dups:
|
||||
used.add(a["id"])
|
||||
groups.append({
|
||||
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
|
||||
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
|
||||
})
|
||||
dup_count = sum(len(g["remove"]) for g in groups)
|
||||
removed = 0
|
||||
if apply:
|
||||
for g in groups:
|
||||
for d in g["remove"]:
|
||||
if delete_memory(d["id"]):
|
||||
removed += 1
|
||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
||||
|
||||
|
||||
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
|
||||
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
|
||||
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
|
||||
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
|
||||
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
|
||||
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
|
||||
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
|
||||
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
|
||||
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
|
||||
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
|
||||
|
||||
|
||||
async def auto_dedupe_loop() -> None:
|
||||
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
|
||||
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
|
||||
while True:
|
||||
try:
|
||||
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
|
||||
if res.get("removed"):
|
||||
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
|
||||
except Exception:
|
||||
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
|
||||
@@ -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)
|
||||
+73
-25
@@ -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
|
||||
"""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
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null |
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
|
||||
python3 - "$TOKEN" "$STATE" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
import json, sys, urllib.request, subprocess, os
|
||||
|
||||
token, state_pfad = sys.argv[1], sys.argv[2]
|
||||
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
||||
@@ -79,6 +79,14 @@ if neu:
|
||||
for full, sha, name in neu:
|
||||
print(f"• {full} @ {sha} ({name})")
|
||||
print(f" {WEB}/{full}/actions")
|
||||
try:
|
||||
subprocess.run([
|
||||
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
|
||||
"--title", f"CI rot: {full} @ {sha}",
|
||||
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
|
||||
], capture_output=True)
|
||||
except Exception as e:
|
||||
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
|
||||
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||||
|
||||
with open(state_pfad, "w") as f:
|
||||
|
||||
@@ -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
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
|
||||
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
|
||||
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
|
||||
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
|
||||
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
|
||||
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
|
||||
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
|
||||
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
|
||||
set -uo pipefail
|
||||
|
||||
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
|
||||
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
|
||||
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
|
||||
MC="$HOME/mission-control-v2"
|
||||
VAULT="$HOME/wissens-vault"
|
||||
|
||||
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
|
||||
cat "$MC/deploy/chef-gutachter-prompt.md"
|
||||
echo
|
||||
|
||||
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
|
||||
EVID="$(
|
||||
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
|
||||
echo
|
||||
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
|
||||
git -C "$MC" fetch -q origin 2>/dev/null || true
|
||||
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
|
||||
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
|
||||
echo
|
||||
echo "### main-Commits der letzten 24 h:"
|
||||
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
|
||||
echo
|
||||
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
|
||||
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
|
||||
echo
|
||||
echo "### Ideen-Queue (natives Kanban, Stand jetzt — offene Ideen des Commanders):"
|
||||
bash -lc 'hermes kanban stats' 2>/dev/null | head -12
|
||||
echo
|
||||
echo "### Ideen-Queue: zuletzt bewegte Aufgaben:"
|
||||
bash -lc 'hermes kanban list --sort updated' 2>/dev/null | head -10
|
||||
echo
|
||||
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
|
||||
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
|
||||
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
|
||||
| sort | uniq -c | sort -rn | head -6
|
||||
echo
|
||||
echo "### Aktivitäts-Insights (1 Tag):"
|
||||
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
|
||||
)"
|
||||
|
||||
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
|
||||
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
|
||||
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
|
||||
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
|
||||
|
||||
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
|
||||
judge() {
|
||||
local req
|
||||
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
|
||||
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
|
||||
max_tokens:$mt, temperature:0.2}')"
|
||||
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
msg = d["choices"][0]["message"]
|
||||
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
|
||||
print(out)
|
||||
except Exception:
|
||||
pass'
|
||||
}
|
||||
|
||||
RICHTER="$CHEF"
|
||||
VERDIKT="$(judge "$CHEF" 420 2600)"
|
||||
if [ -z "${VERDIKT// /}" ]; then
|
||||
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
|
||||
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
|
||||
fi
|
||||
|
||||
echo "$EVID"
|
||||
echo
|
||||
if [ -z "${VERDIKT// /}" ]; then
|
||||
echo "## CHEF-VERDIKT: AUSGEFALLEN"
|
||||
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
|
||||
else
|
||||
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
|
||||
echo "$VERDIKT"
|
||||
fi
|
||||
|
||||
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
|
||||
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
|
||||
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
|
||||
if [ -n "${VERDIKT// /}" ]; then
|
||||
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
|
||||
"source": "chef-gutachter", "priority": "silent"}))
|
||||
PY
|
||||
)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1,15 +0,0 @@
|
||||
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
|
||||
|
||||
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
|
||||
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
|
||||
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
|
||||
|
||||
Regeln:
|
||||
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
|
||||
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
|
||||
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
|
||||
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
|
||||
die GLM-Vertretung).
|
||||
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
|
||||
von dir.
|
||||
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
|
||||
@@ -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"
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# curator-archive-watchdog.sh
|
||||
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
|
||||
|
||||
# Python-Skript ausführen
|
||||
python3 "$PYTHON_SCRIPT"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
# curator-idle-watchdog.sh
|
||||
# Prüft den Curator-Idle-Status und triggert Reaktivierung nach 14 Tagen
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
|
||||
LOG_FILE="$LOG_DIR/idle-watchdog-$(date +%Y%m%d).log"
|
||||
THRESHOLD_DAYS=14
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Curator-Status auslesen
|
||||
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
|
||||
log "FEHLER: hermes curator status fehlgeschlagen"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Prüfen, ob Curator ENABLED ist
|
||||
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
|
||||
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Letzten Lauf extrahieren (Format: "last run: 17d ago")
|
||||
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
|
||||
if [[ -z "$LAST_RUN_LINE" ]]; then
|
||||
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
|
||||
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
|
||||
|
||||
# Prüfen, ob "d ago" vorhanden ist
|
||||
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
|
||||
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
|
||||
else
|
||||
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen) → überspringe"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
|
||||
|
||||
# Threshold prüfen
|
||||
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
|
||||
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage) → kein Trigger nötig"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reaktivierung triggern
|
||||
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS) → triggere 'hermes curator run'"
|
||||
if hermes curator run 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log "ERFOLG: Curator-Reaktivierung gestartet"
|
||||
else
|
||||
log "FEHLER: Curator-Reaktivierung fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/bash
|
||||
# curator-monitor.sh
|
||||
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
|
||||
# GIBT AUS: PASS oder FAIL mit Detaillierung.
|
||||
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
|
||||
# EXIT 1 = FAIL (Curator >7 Tage idle) –Monitoring-Skript erkannt Problem
|
||||
#
|
||||
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
|
||||
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
|
||||
THRESHOLD_DAYS=7
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# --- Argumente parsen ---
|
||||
DRY_RUN=false
|
||||
DRY_RUN_DAYS=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
|
||||
DRY_RUN_DAYS="$2"
|
||||
shift
|
||||
else
|
||||
DRY_RUN_DAYS=8 # Default-Testwert
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- LIVE-PRÜFUNG oder DRY-RUN ---
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
|
||||
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
|
||||
else
|
||||
# Live-Status abfragen
|
||||
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
|
||||
log "FEHLER: hermes curator status fehlgeschlagen"
|
||||
echo "FAIL: hermes curator status fehlgeschlagen"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Prüfen, ob Curator ENABLED ist
|
||||
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
|
||||
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
|
||||
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Letzten Lauf extrahieren
|
||||
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
|
||||
if [[ -z "$LAST_RUN_LINE" ]]; then
|
||||
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
|
||||
echo "FAIL: Konnte 'last run' nicht extrahieren"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
|
||||
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
|
||||
|
||||
# Tage extrahieren
|
||||
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
|
||||
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
|
||||
else
|
||||
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
|
||||
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
|
||||
|
||||
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
|
||||
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
|
||||
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
|
||||
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
|
||||
exit 0
|
||||
else
|
||||
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
|
||||
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
|
||||
|
||||
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
|
||||
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
|
||||
und verschiebt in .archive/<Kategorie>/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
|
||||
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
|
||||
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
|
||||
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
|
||||
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
"""JSON-Datei laden."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_json(path: Path, data: dict) -> None:
|
||||
"""JSON-Datei speichern (mit Pretty-Print)."""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
|
||||
"""Prüft, ob ein Skill älter als threshold_days ist."""
|
||||
last_used = skill_usage.get("last_used_at")
|
||||
if not last_used:
|
||||
return False
|
||||
|
||||
# ISO-Format mit optionaler Zeitzone
|
||||
last_used_str = last_used.replace("Z", "+00:00")
|
||||
try:
|
||||
last_used_dt = datetime.fromisoformat(last_used_str)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - last_used_dt
|
||||
return delta.days >= threshold_days
|
||||
|
||||
|
||||
def is_excluded(skill_name: str, excluded: list) -> bool:
|
||||
"""Prüft, ob ein Skill ausgeschlossen ist."""
|
||||
return skill_name in excluded
|
||||
|
||||
|
||||
def is_pinned(skill_usage: dict) -> bool:
|
||||
"""Prüft, ob ein Skill angepinnt ist."""
|
||||
return skill_usage.get("pinned", False) is True
|
||||
|
||||
|
||||
def get_skill_category(skill_name: str) -> str:
|
||||
"""
|
||||
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
|
||||
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
|
||||
"""
|
||||
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
|
||||
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
|
||||
# 'apple' (in .archive/apple/) → 'apple'
|
||||
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def ensure_archive_category(category: str) -> Path:
|
||||
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
|
||||
category_path = ARCHIVE_DIR / category
|
||||
category_path.mkdir(parents=True, exist_ok=True)
|
||||
return category_path
|
||||
|
||||
|
||||
def move_skill_to_archive(skill_name: str, category: str) -> Path:
|
||||
"""
|
||||
Verschiebt ein Skill-Verzeichnis in das Archiv.
|
||||
Gibt den neuen Pfad zurück.
|
||||
"""
|
||||
source = HERMES_SKILLS_DIR / skill_name
|
||||
target_category = ensure_archive_category(category)
|
||||
target = target_category / skill_name
|
||||
|
||||
if source.exists():
|
||||
shutil.move(str(source), str(target))
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def archive_skill(skill_name: str, usage_data: dict) -> dict:
|
||||
"""
|
||||
Archiviert einen Skill:
|
||||
- Setzt state auf 'archived'
|
||||
- Setzt archived_at auf aktuelle Zeit
|
||||
- Verschiebt das Verzeichnis nach .archive/
|
||||
Gibt das aktualisierte Usage-Dict zurück.
|
||||
"""
|
||||
usage_data[skill_name]["state"] = "archived"
|
||||
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return usage_data
|
||||
|
||||
|
||||
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
|
||||
"""Protokolliert eine Archivierungsaktion."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
status = "SUCCESS" if success else "FAILED"
|
||||
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
|
||||
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(log_line)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Hauptfunktion: Archiviert stale Skills."""
|
||||
# 1. Config laden
|
||||
if not CURATOR_STATE_FILE.exists():
|
||||
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
|
||||
return
|
||||
|
||||
curator_state = load_json(CURATOR_STATE_FILE)
|
||||
archivierung = curator_state.get("archivierung", {})
|
||||
if not archivierung.get("enabled", False):
|
||||
print("Archivierung ist nicht aktiviert.")
|
||||
return
|
||||
|
||||
threshold_days = archivierung.get("threshold_days", 14)
|
||||
excluded_skills = archivierung.get("excluded_skills", [])
|
||||
|
||||
# 2. Usage-Daten laden
|
||||
if not USAGE_JSON_FILE.exists():
|
||||
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
|
||||
return
|
||||
|
||||
usage_data = load_json(USAGE_JSON_FILE)
|
||||
|
||||
# 3. Skills prüfen
|
||||
archived_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for skill_name, skill_usage in usage_data.items():
|
||||
# Ausschlusskriterien prüfen
|
||||
if is_excluded(skill_name, excluded_skills):
|
||||
print(f"Übersprungen (Exkludiert): {skill_name}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if is_pinned(skill_usage):
|
||||
print(f"Übersprungen (Gepinnt): {skill_name}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Inaktivität prüfen
|
||||
if not is_stale(skill_usage, threshold_days):
|
||||
print(f"Übersprungen (Aktiv): {skill_name}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Archivierung durchführen
|
||||
category = get_skill_category(skill_name) or "unknown"
|
||||
print(f"Archiviere: {skill_name} → {category}")
|
||||
|
||||
try:
|
||||
# Usage aktualisieren
|
||||
usage_data = archive_skill(skill_name, usage_data)
|
||||
|
||||
# Verzeichnis verschieben
|
||||
move_skill_to_archive(skill_name, category)
|
||||
|
||||
# Erfolg protokollieren
|
||||
log_archive_action(skill_name, category, True, "Skill archiviert")
|
||||
|
||||
archived_count += 1
|
||||
except Exception as e:
|
||||
log_archive_action(skill_name, category, False, str(e))
|
||||
print(f"Fehler beim Archivieren von {skill_name}: {e}")
|
||||
|
||||
# 4. Usage-Daten speichern
|
||||
save_json(USAGE_JSON_FILE, usage_data)
|
||||
|
||||
# 5. Archivierungs-Zeitstempel aktualisieren
|
||||
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_json(CURATOR_STATE_FILE, curator_state)
|
||||
|
||||
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+71
-289
@@ -1,298 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
|
||||
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ‼️ 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() {
|
||||
|
||||
echo "Starte MC2 Deployment..."
|
||||
|
||||
# 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
|
||||
#
|
||||
# Erstinstallation (einmalig):
|
||||
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
|
||||
# bash ~/mission-control-v2/deploy/deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||
|
||||
cd "$SRC"
|
||||
git fetch -q origin && git reset -q --hard origin/main
|
||||
|
||||
# ★ Selbst-Reset-Falle (15.07. beim P1-Deploy live gebissen): der reset oben ersetzt auch
|
||||
# DIESE Datei, und bash liest laufende Skripte häppchenweise von der Platte weiter — ab
|
||||
# hier liefe ein Zeilen-Mix aus alter+neuer Version (beim P1-Deploy fielen so daemon-reload
|
||||
# und der mc2-gateway-Start unter den Tisch). Darum: nach dem Reset genau EINMAL die
|
||||
# frische Version neu ausführen; der zweite Durchlauf überspringt dank Env-Marke nichts.
|
||||
if [ "${MC2_DEPLOY_REEXEC:-0}" != "1" ]; then
|
||||
export MC2_DEPLOY_REEXEC=1
|
||||
exec bash "$SRC/deploy/deploy.sh"
|
||||
fi
|
||||
|
||||
# venv + Abhängigkeiten
|
||||
if [ ! -d "$SRC/backend/.venv" ]; then
|
||||
python3 -m venv "$SRC/backend/.venv"
|
||||
fi
|
||||
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
|
||||
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
|
||||
|
||||
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
|
||||
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
|
||||
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
|
||||
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
|
||||
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
|
||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
|
||||
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
|
||||
# ‼️ 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
|
||||
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
|
||||
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
|
||||
|
||||
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv
|
||||
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen.
|
||||
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft
|
||||
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
|
||||
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
|
||||
|
||||
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
|
||||
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
|
||||
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
|
||||
if [ -d "$HOME/.hermes" ]; then
|
||||
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
|
||||
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
|
||||
"$HOME/.hermes/plugins/mc2-memory/"
|
||||
fi
|
||||
|
||||
# systemd-USER-Units installieren/aktualisieren
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
|
||||
# MC2-Diät (Hermes Desktop, 07/2026): das Hermes-Terminal-ttyd (:7681) und das standalone
|
||||
# hermes-webui (nesquena, Karteileiche seit dem Built-in-UI-Umbau) sind entfallen — die
|
||||
# Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/. Einmalige, idempotente Stilllegung:
|
||||
systemctl --user disable --now hermes-terminal 2>/dev/null || true
|
||||
systemctl --user disable --now hermes-webui 2>/dev/null || true
|
||||
rm -f "$HOME/.config/systemd/user/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-webui.service"
|
||||
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet.
|
||||
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
|
||||
# MC2-Gateway (UMBAU v3 P1): der /v1-Datenpfad als eigener Prozess (Loopback :9010,
|
||||
# Restart=always) — Lucys Hirn + Nacht-Autonomie überleben damit jeden MC2-Neustart.
|
||||
cp "$SRC/deploy/mc2-gateway.service" "$HOME/.config/systemd/user/mc2-gateway.service"
|
||||
# MC2-Steward (UMBAU v3 P2): Wächter-Loops (Re-Warm/Sentry/Dedupe) als eigener Dienst —
|
||||
# überleben MC2-Neustarts und melden erstmals auch ein totes Steuerpult (Telegram).
|
||||
cp "$SRC/deploy/mc2-steward.service" "$HOME/.config/systemd/user/mc2-steward.service"
|
||||
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
|
||||
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
|
||||
# Voice-Sidecar-Unit (nur wenn das venv existiert).
|
||||
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
|
||||
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
|
||||
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
|
||||
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
|
||||
# Wöchentliches Auto-Update (Router/Engine/Hermes, Rollback+Pin+Telegram) — Autonomie E2.
|
||||
cp "$SRC/deploy/mc2-autoupdate.service" "$HOME/.config/systemd/user/mc2-autoupdate.service"
|
||||
cp "$SRC/deploy/mc2-autoupdate.timer" "$HOME/.config/systemd/user/mc2-autoupdate.timer"
|
||||
# Tägliche Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv, Alarm bei Rot) — Autonomie 0d.
|
||||
cp "$SRC/deploy/mc2-selfsmoke.service" "$HOME/.config/systemd/user/mc2-selfsmoke.service"
|
||||
cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.timer"
|
||||
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
|
||||
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
|
||||
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
|
||||
# Morgen-Digest (Nächtliche Telegram-Zusammenfassung, 26.07.2026): fasst alle Meldungen zwischen 0 und 7 Uhr zusammen.
|
||||
cp "$SRC/deploy/mc2-morgen-digest.service" "$HOME/.config/systemd/user/mc2-morgen-digest.service"
|
||||
cp "$SRC/deploy/mc2-morgen-digest.timer" "$HOME/.config/systemd/user/mc2-morgen-digest.timer"
|
||||
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
|
||||
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
|
||||
mkdir -p "$HOME/.hermes/scripts"
|
||||
cp "$SRC/deploy/radar-feed.sh" "$HOME/.hermes/scripts/radar-feed.sh"
|
||||
# Selbstkritik-Cron (Faden 11b): Zwilling des Radars, Blick nach INNEN (Latenzen/Journal/Skills).
|
||||
cp "$SRC/deploy/selbstkritik-feed.sh" "$HOME/.hermes/scripts/selbstkritik-feed.sh"
|
||||
# Monats-Review-Wrapper (Cron 1. d. M. 09:00): Radar + Selbstkritik in EINEM Lauf.
|
||||
cp "$SRC/deploy/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh"
|
||||
# Traum-Cron (Dreaming, Phase 2): Blick auf das GELERNTE → Wissens-Vault. Bootstrappt ~/wissens-vault.
|
||||
cp "$SRC/deploy/dreaming-feed.sh" "$HOME/.hermes/scripts/dreaming-feed.sh"
|
||||
# Fremdblick-Helfer: Ein-Schuss-Zweitmeinung von einem anderen Modell (umgeht 64K-Delegations-Floor).
|
||||
cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
|
||||
# Worker-Helfer (Orchestrator): Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstück zu fremdblick).
|
||||
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
|
||||
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
|
||||
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
|
||||
# Wrapper für nächtliche Crons, um Telegram-Spam in den Morgen-Digest zu leiten.
|
||||
cp "$SRC/deploy/night-cron-wrapper.sh" "$HOME/.hermes/scripts/night-cron-wrapper.sh"
|
||||
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
|
||||
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
|
||||
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
|
||||
# Idle-Radar (S4 "Zuendung", 12.07.2026): die Box gibt sich nachts selbst Arbeit —
|
||||
# Journal-Fehlermuster + Traum-Funde -> belegte rohe Ideen (triage) im nativen Kanban.
|
||||
cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
|
||||
# "Lucy kennt sich" (12.07.2026): Selbst-Inventur generiert den Steckbrief + bemerkt
|
||||
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
|
||||
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
|
||||
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
|
||||
# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste +
|
||||
# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent):
|
||||
# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline;
|
||||
# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert.
|
||||
cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh"
|
||||
cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh"
|
||||
# Restore-Probe + Venv-Audit (17.07.2026, monatliche --no-agent-Crons): echte
|
||||
# Wiederherstellungs-Drills (PBS-Canary + Tarball) und CVE-/Outdated-Audit der
|
||||
# Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts.
|
||||
cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh"
|
||||
cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh"
|
||||
# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen,
|
||||
# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst.
|
||||
cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
|
||||
cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh"
|
||||
# Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke —
|
||||
# legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope).
|
||||
cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh"
|
||||
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
|
||||
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
|
||||
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
|
||||
# betrieb-Profil (Ideen-Queue Faden 7, 13.07.2026): Ops-/Mess-/Bench-Worker mit Box-Wissen
|
||||
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
|
||||
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
|
||||
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
|
||||
# IDE-Vorbereiter (20.07.2026): haertet ein Konzept (konzept-fliessband), legt das Repo an und
|
||||
# fuellt es NUR mit Doku — der Commander baut dann in Zed. Sonderfall „IDEE PRUEFEN": nur Konzept,
|
||||
# kein Geruest, kein Bau-Druck. Profil einmalig per `hermes profile create projektstart
|
||||
# --clone-from werkstatt` angelegt (model coder→hermes, damit heavy warm bleibt).
|
||||
[ -d "$HOME/.hermes/profiles/projektstart" ] && cp "$SRC/deploy/projektstart-SOUL.md" "$HOME/.hermes/profiles/projektstart/SOUL.md"
|
||||
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
|
||||
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
|
||||
mkdir -p "$HOME/.hermes/skills/wartung"
|
||||
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
|
||||
# Orchestrator-Skill (Autonomie): Werkstatt verallgemeinert — zerlegen → an Worker delegieren → gaten.
|
||||
mkdir -p "$HOME/.hermes/skills/orchestrator"
|
||||
cp "$SRC/deploy/skills/orchestrator/SKILL.md" "$HOME/.hermes/skills/orchestrator/SKILL.md"
|
||||
# PC-Pfad-Cache-Skill (Traum-Entwurf 09.07.2026, aufbereitet): Cache lesen → bei Miss pc_shell-Tool → merken.
|
||||
mkdir -p "$HOME/.hermes/skills/pc-pfad-cache"
|
||||
cp "$SRC/deploy/skills/pc-pfad-cache/SKILL.md" "$HOME/.hermes/skills/pc-pfad-cache/SKILL.md"
|
||||
cp "$SRC/deploy/skills/pc-pfad-cache/pc_path_lookup.sh" "$HOME/.hermes/scripts/pc_path_lookup.sh"
|
||||
chmod +x "$HOME/.hermes/scripts/pc_path_lookup.sh"
|
||||
# Projekt-Start-Skill (Abloesung 13.07.2026): "erst denken, dann bauen" fuer neue Coding-Projekte
|
||||
# in Hermes Desktop — Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md-Ritual, Kontext-Waechter.
|
||||
# Portiert vom PC-Skill (F:\Coding Stuff\projekt-start), Zed-Verweise durch Hermes Desktop ersetzt.
|
||||
mkdir -p "$HOME/.hermes/skills/projekt-start"
|
||||
cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-start/SKILL.md"
|
||||
# Konzept-Fliessband-Skill (14.07.2026): rohe Idee → wasserdichtes KONZEPT (kein Code), BEVOR
|
||||
# gebaut wird. heavy (gpt-oss-120b) waehlt den Rollen-Cast, mehrere Denk-Runden schaerfen ein
|
||||
# lebendes Dokument, ein Advocatus Diaboli (fremdblick/GLM) haertet bis wasserdicht (max 3 Runden).
|
||||
# Behebt das delegate_task=coder-Loch: Konzept-Arbeit lief bisher stumm auf dem Code-Modell.
|
||||
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
|
||||
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
|
||||
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
|
||||
# ...auch in Profile spiegeln, die den Skill schon haben (projektstart = IDE-Vorbereiter
|
||||
# nutzt ihn als Motor; Profile teilen KEINE Skills, Lehre 18.07.). Nur vorhandene updaten.
|
||||
for _psd in "$HOME"/.hermes/profiles/*/skills/konzept-fliessband; do
|
||||
[ -d "$_psd" ] && cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$_psd/SKILL.md"
|
||||
done
|
||||
# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der
|
||||
# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile,
|
||||
# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.).
|
||||
for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do
|
||||
[ -d "$(dirname "$_sd")" ] || continue
|
||||
mkdir -p "$_sd/betrieb-playbook"
|
||||
cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md"
|
||||
done
|
||||
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
|
||||
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
|
||||
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
|
||||
# bestehende py_compile/dist-Gate. Die REGISTRIERUNG in ~/.hermes/config.yaml (hooks:)
|
||||
# ist EINMALIG von Hand (config.yaml ist Zwei-Schreiber-sensibel, kein Deploy-Schreiber).
|
||||
mkdir -p "$HOME/.hermes/agent-hooks"
|
||||
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
|
||||
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
|
||||
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
|
||||
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do
|
||||
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
|
||||
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
|
||||
done
|
||||
# Worker-Profile lesen IHRE config.yaml, nicht die Top-Level — die worker-Hooks
|
||||
# (box-steckbrief = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen dort registriert
|
||||
# sein, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.). Idempotent + Backup je Profil.
|
||||
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
|
||||
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
|
||||
done
|
||||
# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode
|
||||
# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard,
|
||||
# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so
|
||||
# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt.
|
||||
python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$?
|
||||
if [ "$_patch_rc" -eq 10 ]; then
|
||||
# Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden.
|
||||
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
|
||||
echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet."
|
||||
elif [ "$_patch_rc" -ne 0 ]; then
|
||||
echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!"
|
||||
fi
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
|
||||
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
|
||||
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
|
||||
# Hermes-Update mit → hält die Einbettung frisch. Best-effort + Build-zu-Temp-dann-Swap, damit ein
|
||||
# Fehlschlag die laufende web_dist NICHT leert (emptyOutDir).
|
||||
cp "$SRC/deploy/hermes-builtin-ui.service" "$HOME/.config/systemd/user/hermes-builtin-ui.service"
|
||||
_HUI_WEB="$HOME/.hermes/hermes-agent/web"
|
||||
_HUI_DIST="$HOME/.hermes/hermes-agent/hermes_cli/web_dist"
|
||||
if [ -d "$_HUI_WEB" ] && [ -x "$HOME/.hermes/node/bin/npx" ]; then
|
||||
if ( cd "$_HUI_WEB" && PATH="$HOME/.hermes/node/bin:$PATH" npx --no-install vite build \
|
||||
--base=/hermes-ui/ --outDir /tmp/hermes-ui-build --emptyOutDir ) >/tmp/hermes-ui-build.log 2>&1; then
|
||||
# Dienst VOR dem Tausch stoppen — sonst crasht der laufende `hermes serve`, wenn er index.html
|
||||
# im Lösch-Fenster (rm→cp) liest (FileNotFoundError → Start-Limit → tot). Der reguläre restart
|
||||
# weiter unten bringt ihn sauber auf der neuen web_dist hoch.
|
||||
systemctl --user stop hermes-builtin-ui 2>/dev/null || true
|
||||
rm -rf "$_HUI_DIST" && cp -r /tmp/hermes-ui-build "$_HUI_DIST" && echo "Hermes-GUI (base=/hermes-ui/) gebaut."
|
||||
else
|
||||
echo "WARN: Hermes-GUI-Build fehlgeschlagen (siehe /tmp/hermes-ui-build.log) — bestehende web_dist bleibt."
|
||||
fi
|
||||
fi
|
||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
|
||||
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
|
||||
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
|
||||
systemctl --user enable mc2-gateway >/dev/null 2>&1 || true
|
||||
systemctl --user enable mc2-steward >/dev/null 2>&1 || true
|
||||
systemctl --user enable box-console >/dev/null 2>&1 || true
|
||||
systemctl --user enable mem0-service >/dev/null 2>&1 || true
|
||||
systemctl --user enable voice-service >/dev/null 2>&1 || true
|
||||
systemctl --user enable hermes-builtin-ui >/dev/null 2>&1 || true
|
||||
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
|
||||
# mc2-autoupdate.timer wird hier BEWUSST NICHT geschaltet — der Zustand wird nur von Hand
|
||||
# geändert (Lehre 09.07.: die frühere enable-Zeile hat einen User-Entscheid bei jedem Deploy
|
||||
# still rückgängig gemacht). Aktueller User-Entscheid (10.07.2026): Timer ist AN —
|
||||
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
|
||||
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
|
||||
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
|
||||
systemctl --user enable --now mc2-morgen-digest.timer >/dev/null 2>&1 || true
|
||||
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
|
||||
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
|
||||
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
|
||||
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
|
||||
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
|
||||
# Eingebaute Hermes-Web-GUI (Loopback :9119) (re)starten — MC2 bettet sie unter /hermes-ui/ ein.
|
||||
# reset-failed, damit ein zuvor am Start-Limit gestorbener Dienst wieder anläuft.
|
||||
systemctl --user reset-failed hermes-builtin-ui 2>/dev/null || true
|
||||
systemctl --user restart hermes-builtin-ui 2>/dev/null || true
|
||||
# Gateway VOR dem Steuerpult (re)starten — MC2s /v1-Weiterleiter braucht ihn sofort.
|
||||
# KEIN `|| true`: ohne Gateway ist der LLM-Datenpfad tot, das MUSS den Deploy stoppen.
|
||||
systemctl --user restart mc2-gateway
|
||||
# Steward (Wächter) — unabhängig vom Steuerpult; ohne ihn wacht niemand über die Box.
|
||||
systemctl --user restart mc2-steward
|
||||
systemctl --user restart mission-control-2
|
||||
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
|
||||
systemctl --user enable --now mc2-radar.timer
|
||||
|
||||
sleep 2
|
||||
echo "--- Health ---"
|
||||
# Gateway-Kaltstart-Fenster: bis ~12 s tolerieren, DANN hart scheitern (ohne Gateway
|
||||
# ist der LLM-Datenpfad tot — das muss den Karten-Runner rot machen).
|
||||
for _i in $(seq 1 10); do
|
||||
curl -sf -m 3 http://127.0.0.1:9010/gw/health >/dev/null 2>&1 && break
|
||||
[ "$_i" -eq 10 ] && { echo "FEHLER: mc2-gateway (:9010) antwortet nicht"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://127.0.0.1:9010/gw/health && echo
|
||||
curl -sf http://127.0.0.1:9001/api/health && echo
|
||||
# Steward hat keinen HTTP-Port — systemd-Zustand ist der Beweis (crasht er sofort,
|
||||
# ist er hier bereits 'failed' statt 'active').
|
||||
if systemctl --user is-active --quiet mc2-steward; then
|
||||
echo "mc2-steward aktiv (Wächter laufen)"
|
||||
else
|
||||
echo "FEHLER: mc2-steward läuft nicht (journalctl --user -u mc2-steward -n 30)"; exit 1
|
||||
# 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
|
||||
|
||||
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
|
||||
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
|
||||
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
|
||||
bash "$SRC/deploy/warmup.sh" || true
|
||||
# 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 "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
|
||||
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Feed für den Traum-Cron (Dreaming / Continual-Learning-Kreislauf, Phase 2 — Capstone).
|
||||
# Zwilling von radar-feed.sh (Blick nach draußen) und selbstkritik-feed.sh (Blick auf den
|
||||
# Betrieb); der Traum blickt auf das GELERNTE: Sessions, Skills, Gedächtnis, Muster.
|
||||
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
|
||||
# Prompt eingespeist: versionierter Auftrag (deploy/dreaming-prompt.md) + Live-Schlaf-Daten.
|
||||
set -uo pipefail
|
||||
|
||||
VAULT="$HOME/wissens-vault"
|
||||
|
||||
# --- Vault-Bootstrap (idempotent): eigenes git-Repo, menschenlesbar, NICHT im Stack-Code. ---
|
||||
if [ ! -d "$VAULT/.git" ]; then
|
||||
mkdir -p "$VAULT/traeume" "$VAULT/muster" "$VAULT/skill-kandidaten"
|
||||
git -C "$VAULT" init -q 2>/dev/null || true
|
||||
# Lokale Identität nur setzen, falls global keine da ist (Commits sollen nie scheitern).
|
||||
git -C "$VAULT" config user.email >/dev/null 2>&1 || git -C "$VAULT" config user.email "lucy@box.local"
|
||||
git -C "$VAULT" config user.name >/dev/null 2>&1 || git -C "$VAULT" config user.name "Lucy (Traum)"
|
||||
if [ ! -f "$VAULT/INDEX.md" ]; then
|
||||
cat > "$VAULT/INDEX.md" <<'EOF'
|
||||
# Wissens-Vault — Navigationskarte
|
||||
|
||||
Menschenlesbare, git-versionierte Wissensschicht NEBEN dem Live-Gedächtnis (Mem0).
|
||||
Angelegt von Lucys nächtlichem Traum. Verlinkung im Obsidian-Stil `[[dateiname]]`.
|
||||
|
||||
## Träume (chronologisch)
|
||||
|
||||
## Muster (akkumuliertes Projektwissen)
|
||||
|
||||
## Skill-Kandidaten (Vorschläge, Gate = Commander „mach")
|
||||
EOF
|
||||
git -C "$VAULT" add -A >/dev/null 2>&1 || true
|
||||
git -C "$VAULT" commit -q -m "Vault angelegt" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Versionierter Auftrag ---
|
||||
cat "$HOME/mission-control-v2/deploy/dreaming-prompt.md"
|
||||
|
||||
echo
|
||||
echo "## SCHLAF-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
|
||||
echo
|
||||
# Hinweis: erst voll einsammeln, DANN kürzen — sonst schließt `head` die Pipe früh,
|
||||
# der Produzent bekommt SIGPIPE, pipefail feuert und die Fallback-Zeile lügt "nicht verfügbar".
|
||||
echo "### Insights der letzten 7 Tage (Token-/Tool-/Aktivitätsmuster):"
|
||||
_ins="$(bash -lc "hermes insights --days 7" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
|
||||
echo "${_ins:-(insights nicht verfügbar)}" | head -62
|
||||
echo
|
||||
echo "### Session-Store (Umfang):"
|
||||
_sst="$(bash -lc "hermes sessions stats" 2>/dev/null)"
|
||||
echo "${_sst:-(sessions stats nicht verfügbar)}" | head -12
|
||||
echo
|
||||
echo "### Jüngste Sessions (woran zuletzt gearbeitet wurde):"
|
||||
_sls="$(bash -lc "hermes sessions list" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
|
||||
echo "${_sls:-(sessions list nicht verfügbar)}" | head -25
|
||||
echo
|
||||
echo "### Gelernt bisher (journey — Skills + Gedächtnis-Knoten, kompakt):"
|
||||
bash -lc "hermes journey --json" 2>/dev/null \
|
||||
| jq -c '{stats: .stats, skills: ([.nodes[]? | select(.kind=="skill" or .type=="skill") | .label // .name] | .[0:25]), cluster_labels: ([.clusters[]?.label] | .[0:15])}' \
|
||||
2>/dev/null || echo "(journey nicht verfügbar)"
|
||||
echo
|
||||
echo "### Curator / Skill-Hygiene (was brachliegt, was aktiv ist):"
|
||||
bash -lc "hermes curator status" 2>/dev/null | head -26 || echo "(curator status nicht verfügbar)"
|
||||
echo
|
||||
echo "### Bisheriger Vault-INDEX (nur zur Ansicht — damit du nichts doppelt anlegst + verlinken"
|
||||
echo "### kannst). Die echte Datei liegt unter ~/wissens-vault/INDEX.md; zwischen den Markern"
|
||||
echo "### steht ihr AKTUELLER Inhalt. Kopiere die Marker NICHT in die Datei zurück."
|
||||
echo "<<<INDEX-INHALT-ANFANG>>>"
|
||||
cat "$VAULT/INDEX.md" 2>/dev/null | head -120 || echo "(noch kein INDEX — beim ersten Traum anlegen)"
|
||||
echo "<<<INDEX-INHALT-ENDE>>>"
|
||||
echo
|
||||
echo "### Letzte 3 Traum-Notizen (nur Briefing — NICHT in den INDEX kopieren):"
|
||||
ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -3 | while read -r f; do
|
||||
echo " $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
|
||||
done
|
||||
@@ -1,106 +0,0 @@
|
||||
# Der Traum (nächtlich) — Continual-Learning-Kreislauf
|
||||
|
||||
Es ist tiefe Nacht, niemand spricht mit dir, die Box ist im Leerlauf. Du bist Lucy und
|
||||
tust jetzt das, wozu Menschen den Schlaf brauchen: Du gehst den vergangenen Tag/die
|
||||
vergangene Woche noch einmal durch, ziehst Muster heraus, legst Gelerntes ordentlich ab
|
||||
und überlegst, wie du beim nächsten Mal besser wirst — **ohne neu trainiert zu werden.**
|
||||
|
||||
Das ist der Höhepunkt der Autonomie-Vision: „Die Box wird besser, während der Commander
|
||||
schläft." Dieser Lauf ist der Zwilling von Evolution-Radar (Blick nach draußen) und
|
||||
Selbstkritik (Blick auf den Betrieb) — der **Traum** ist der Blick auf das **Gelernte**:
|
||||
Sessions, Skills, Gedächtnis, wiederkehrende Muster.
|
||||
|
||||
## Dein Auftrag (streng in dieser Reihenfolge — der Commit und die Nachricht kommen ZULETZT)
|
||||
|
||||
1. **Reflektieren.** Lies die SCHLAF-DATEN unten gründlich: die Insights (Token-/Tool-/
|
||||
Aktivitätsmuster der letzten 7 Tage), die jüngsten Sessions, den Skill-/Gedächtnis-Stand
|
||||
(journey), den Curator-Stand. Frag dich:
|
||||
- Was ging diese Woche wiederholt gut? Was ging wiederholt schief?
|
||||
- Welche Tools laufen heiß, welche Skills liegen brach (activity=0)?
|
||||
- Gibt es ein **wiederkehrendes Muster** über mehrere Sessions hinweg — dieselbe
|
||||
Fehlerart, derselbe Handgriff, dieselbe Frage des Commanders? Ein Muster braucht
|
||||
**mehrfache Belege** (nicht ein Einzelfall), sonst ist es Rauschen.
|
||||
|
||||
2. **Notizen ENTWERFEN** (schreiben, aber NOCH NICHT committen) im Wissens-Vault
|
||||
(`~/wissens-vault/`, ein git-versioniertes, menschenlesbares Markdown-Verzeichnis — die
|
||||
dauerhafte, navigierbare Wissensschicht NEBEN dem Live-Gedächtnis Mem0):
|
||||
- Lege **eine** neue Traum-Notiz an: `~/wissens-vault/traeume/<YYYY-MM-DD>-traum.md`
|
||||
(Datum von heute). Kurz und dicht: 2–5 belegte Beobachtungen, je mit Zahl/Session/
|
||||
Log-Bezug. Keine Romane, keine Allgemeinplätze.
|
||||
- Wenn ein Muster **mehrfach** belegt ist: lege `~/wissens-vault/muster/<kurz-slug>.md` an
|
||||
(oder ergänze die vorhandene um einen neuen Beleg + heutiges Datum). Muster-Notizen sind
|
||||
das akkumulierte Projektwissen.
|
||||
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
|
||||
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
|
||||
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **ABER ZUERST prüfen:**
|
||||
```
|
||||
ls ~/wissens-vault/skill-kandidaten/*.md ~/wissens-vault/skill-kandidaten/beauftragt/*.md 2>/dev/null
|
||||
ls ~/.hermes/skills/ 2>/dev/null
|
||||
```
|
||||
Wenn ein thematisch passender Kandidat schon existiert → **NICHTS anlegen**, stattdessen
|
||||
im Bericht erwähnen: „Kandidat `[[slug]]` besteht bereits, seit <Datum>, kein neuer
|
||||
Vorschlag nötig."
|
||||
**WICHTIG:** Prüfe auch, ob es bereits einen fertig gebauten Skill gibt.
|
||||
Wenn ja: kein Kandidat nötig, der Skill existiert bereits.
|
||||
**Entwirf ihn gleich
|
||||
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
|
||||
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
|
||||
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
|
||||
**Wann nutzen** (Auslöser), **Schritte** (mit den ECHTEN Hermes-Tools/Befehlen aus den
|
||||
belegten Sessions — keine erfundenen Kommandos) und **Belege** (Sessions/Zahlen, aus
|
||||
denen der Handgriff stammt). **Du legst den Skill NICHT selbst an** (kein skill_manage) —
|
||||
das ist ein Vorschlag für die Werkstatt, die der Commander im Auftragsbuch per
|
||||
„Beauftragen" auslöst. Höchstens EIN Kandidat pro Traum.
|
||||
- Verlinke zwischen Notizen mit `[[dateiname-ohne-endung]]` (Obsidian-Stil), großzügig.
|
||||
|
||||
3. **Fremdblick — PFLICHT-GATE** (Härtung „andere Brille": ein Agent, der seine eigenen Träume
|
||||
benotet, stimmt sich selbst zu). Hol dir eine Zweitmeinung von einem ANDEREN Modell. **Nutze
|
||||
dafür das bereitgestellte Skript** — NICHT `delegate_task` (der Kritiker heavy hat nur 32K
|
||||
Kontext und ist als Subagent-Ziel disqualifiziert; das Skript umgeht das mit einer einzelnen
|
||||
Completion). Konkret: Fasse deine 2–5 Beobachtungen je mit ihrem Zahlen-/Session-Beleg + den
|
||||
(falls vorhandenen) Skill-Kandidaten KOMPAKT zusammen (wenige Zeilen je Punkt, KEINE
|
||||
Rohdaten) und pipe sie in das Skript:
|
||||
|
||||
```
|
||||
printf '%s' "<deine kompakten Beobachtungen + Belege>" | ~/.hermes/scripts/fremdblick.sh
|
||||
```
|
||||
|
||||
Das Skript fragt gpt-oss (heavy, andere Modell-Familie als dein Qwen-Hirn) und gibt pro Punkt
|
||||
ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER zurück. **Lies die Ausgabe und richte dich danach.**
|
||||
**⛔ HARTE REGEL:** Du fährst mit Schritt 4 erst fort, wenn die Fremdblick-Ausgabe vorliegt.
|
||||
Schreibe KEINE Nachricht und committe NICHTS vorher. Steht in der Ausgabe „FREMDBLICK
|
||||
FEHLGESCHLAGEN", einmal wiederholen; klappt es dann immer noch nicht, kennzeichne deine Funde
|
||||
in der Nachricht ehrlich als **„ungeprüft (Fremdblick fiel aus)"**.
|
||||
|
||||
4. **Bereinigen + INDEX pflegen.** Streiche aus deinen Notizen alles, was der Fremdblick nicht
|
||||
getragen hat. Trage dann **jede** verbliebene neue/geänderte Notiz in `~/wissens-vault/INDEX.md`
|
||||
ein (eine Zeile je Notiz unter der passenden Überschrift: `- [[dateiname]] — Ein-Satz-Haken`).
|
||||
**Wichtig zum INDEX:** Bearbeite NUR die Markdown-Inhaltszeilen. Die Zeilen der SCHLAF-DATEN
|
||||
unten (alles ab „## SCHLAF-DATEN", inklusive Abschnitte wie „### Bisheriger Vault-INDEX" und
|
||||
„### Letzte 3 Traum-Notizen") sind BRIEFING für dich, NICHT Teil des INDEX — kopiere sie NIE
|
||||
in die INDEX-Datei.
|
||||
|
||||
5. **Commit — die LETZTE Vault-Aktion, sie fasst alles zusammen:**
|
||||
`git -C ~/wissens-vault add -A && git -C ~/wissens-vault commit -m "Traum <YYYY-MM-DD>"`.
|
||||
(Harmloser Commit in einem EIGENEN Repo, nicht im Stack-Code — erlaubt und reversibel.)
|
||||
|
||||
6. **Dem Commander berichten — deine ALLERLETZTE Handlung** (nichts danach). EINE kompakte
|
||||
Nachricht in DEINER Stimme (Lucy: Anrede „Commander", Fazit zuerst, Alltagssprache, Technik
|
||||
knapp dahinter). Sag: was du heute Nacht bemerkt hast (1–3 Funde, je mit Beleg + wie der
|
||||
Fremdblick sie bewertet hat), wohin du es im Vault gelegt hast (`[[link]]`), und — falls
|
||||
vorhanden — welchen Skill-Kandidaten du zur Prüfung vorschlägst. Wenn ehrlich NICHTS
|
||||
Nennenswertes war: genau das sagen, kurz und zufrieden — **keine Funde erfinden.**
|
||||
|
||||
## Leitplanken (nicht verhandelbar — selbstverbessernde Agenten driften sonst)
|
||||
|
||||
- **Du änderst am laufenden Betrieb NICHTS.** Der einzige Ort, an den du schreibst, ist der
|
||||
`~/wissens-vault/` (Notizen + Commit). Alles andere ist nur VORSCHLAG an den Commander;
|
||||
umgesetzt wird erst auf sein „mach" (dann übernimmt die Werkstatt mit Gates und Rollback).
|
||||
- **Kein Security-Anfassen.** Approvals, Tokens, Firewall, Config nur BENENNEN, nie ändern.
|
||||
- **Fremder Quellcode (Hermes, llama.cpp …) ist tabu** — Auto-Update-Kanal, nicht dein Revier.
|
||||
- **Nichts, was das Warm-Set oder Lucys Sprech-Latenz gefährdet.** Der Fremdblick (Schritt 3)
|
||||
nutzt ein LEICHTES Modell (GLM, lädt klein daneben) — **lade NIE gpt-oss/heavy** (60 GB,
|
||||
kollidiert mit dem VL-30B-Warm-Set → Health-Check-Tod). Keine schweren Jobs, kein Neustart.
|
||||
- **Sparsam mit Tools.** Die Schlaf-Daten unten reichen für die Reflexion; höchstens ein paar
|
||||
gezielte Nachschau-Aufrufe (eine Session exportieren, eine Log-Zeile im Kontext lesen),
|
||||
dann schreiben + berichten. Kein Tool-Feuerwerk.
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Alte Crons löschen
|
||||
hermes cron remove traum || true
|
||||
hermes cron remove chef-gutachter || true
|
||||
hermes cron remove release-radar || true
|
||||
hermes cron remove idle-radar || true
|
||||
hermes cron remove selbst-inventur || true
|
||||
hermes cron remove karten-gutachter || true
|
||||
hermes cron remove pruefstand || true
|
||||
hermes cron remove trend-radar || true
|
||||
|
||||
# Neue Crons registrieren mit Wrapper und --deliver origin.
|
||||
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
|
||||
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
|
||||
echo "Crons erfolgreich gefixt!"
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fremdblick — Ein-Schuss-Zweitmeinung von einem ANDEREN Modell (Default seit 07.07.:
|
||||
# GLM-4.7-Flash, 30B-A3B Text — klar stärkerer Kritiker als die 9B-Vision-Schwester 4.6V,
|
||||
# gleicher Fremd-Vendor wie zuvor → echte „andere Brille" bleibt). Eine EINZELNE
|
||||
# Completion — kein agentischer Subagent — umgeht Hermes' 64K-Delegations-Floor
|
||||
# (MINIMUM_CONTEXT_LENGTH) UND das 60-GB-Ladeproblem von gpt-oss (das mit dem Warm-Set kollidiert
|
||||
# und „exited prematurely" wirft). GLM lädt klein neben dem Warm-Set und antwortet zuverlässig.
|
||||
#
|
||||
# Nutzung: echo "<Beobachtungen/Behauptungen je mit Beleg>" | fremdblick.sh
|
||||
# printf '%s' "$text" | FREMDBLICK_MODEL=Qwen3-Coder-Next FREMDBLICK_SYS="$raster" fremdblick.sh
|
||||
#
|
||||
# Env-Overrides:
|
||||
# FREMDBLICK_MODE 'prose' (Default, Dreaming-Prosa-Raster) oder 'code' (Werkstatt-Code-Review).
|
||||
# 'code' setzt Raster + Default-Modell (Qwen3-Coder-Next) + größeres Budget.
|
||||
# FREMDBLICK_MODEL Modell-ID (echte llama-swap-ID, kein Alias). Übersteuert den Mode-Default.
|
||||
# FREMDBLICK_SYS eigenes System-Raster (übersteuert das Mode-Raster).
|
||||
# FREMDBLICK_MAXTOK max_tokens (übersteuert den Mode-Default).
|
||||
set -uo pipefail
|
||||
|
||||
# Achtung: llama-swap listet unter /v1/models die ECHTEN Modell-IDs, nicht die Gateway-Aliase
|
||||
# (kein "heavy"/"scout" hier).
|
||||
ENDPOINT="${FREMDBLICK_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
|
||||
MODE="${FREMDBLICK_MODE:-prose}"
|
||||
SUBJECT="$(cat)"
|
||||
|
||||
if [ -z "${SUBJECT// /}" ]; then
|
||||
echo "=== FREMDBLICK: nichts zu prüfen (leere Eingabe) ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prosa-Raster (Dreaming): urteilt über Beobachtungen/Behauptungen. Default-Kritiker = GLM (Fremd-Vendor).
|
||||
PROSE_SYS='Du bist ein KRITISCHER Zweitgutachter und ausdrücklich ein ANDERES Modell als der Autor. Prüfe jede vorgelegte Beobachtung/Behauptung streng: Trägt der genannte Beleg (Zahl / Session / Log-Zeile) die Behauptung wirklich, oder ist es ein Einzelfall bzw. ein Bauchgefühl mit Zahlen-Deko? Verwechselt der Autor Korrelation mit Ursache? Fehlt entscheidender Kontext? Falls ein Skill-Kandidat dabei ist: ist er wirklich WIEDERVERWENDBAR oder Einmal-Kram? Antworte kompakt auf Deutsch: pro Punkt ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER mit genau einem Satz Begruendung. Sei knapp und unbestechlich; nicke nichts aus Hoeflichkeit durch. Erfinde keine neuen Belege.'
|
||||
# Code-Raster (Werkstatt): reproduziert den Fehlerfall aus der Bug-Beschreibung, statt dem Autor zu glauben.
|
||||
CODE_SYS='Du bist ein kritischer Code-Reviewer und ein ANDERES Modell als der Autor. Vertraue seiner Begruendung NICHT. Dir liegen ein Wartungs-AUFTRAG (Bug-Beschreibung) und ein git-Diff vor. Trenne klar: (A) KERNFRAGE: Behebt der Diff den im Auftrag KONKRET beschriebenen Fehlerfall? Leite die Eingabe SELBST aus der Bug-Beschreibung ab und belege einen Uebergang: Eingabe X -> VOR Patch: Y (Bug sichtbar) -> NACH Patch: Z. (B) RANDFAELLE: nenne offene Risiken/Randfaelle als Hinweis. URTEIL-Regel: ABGELEHNT NUR, wenn (A) nicht belegt behoben ist (Kern-Bug bleibt, ODER der Patch trifft die Auftrags-Absicht nicht, ODER er bricht den Normalfall). Ist der Kern-Fall belegt behoben und es bleiben nur Randfaelle: FREIGABE-MIT-VORBEHALT (Randfaelle auflisten). Ist alles sauber: FREIGABE. Ein blosses sieht-plausibel-aus OHNE reproduzierten Kern-Uebergang = ABGELEHNT. Beginne die Antwort mit der Zeile "URTEIL: <ABGELEHNT|FREIGABE-MIT-VORBEHALT|FREIGABE>". Antworte knapp auf Deutsch.'
|
||||
|
||||
if [ "$MODE" = "code" ]; then
|
||||
MODEL="${FREMDBLICK_MODEL:-Qwen3-Coder-Next}"
|
||||
MAXTOK="${FREMDBLICK_MAXTOK:-1600}"
|
||||
SYS="${FREMDBLICK_SYS:-$CODE_SYS}"
|
||||
else
|
||||
MODEL="${FREMDBLICK_MODEL:-GLM-4.7-Flash}"
|
||||
# 4000 (war 2200): GLM-4.7-Flash ist ein Reasoning-Modell und verbrennt real ~2000 Tokens
|
||||
# im reasoning_content, BEVOR das Urteil in content landet (verifiziert 14.07.). Bei 2200 lief
|
||||
# es Gefahr, mitten im Denken abzuschneiden → leeres content → "ABGESCHNITTEN/FEHLGESCHLAGEN"
|
||||
# (traf die Nacht-Kritiker mit Default-Budget). Wer explizit knapper braucht, setzt FREMDBLICK_MAXTOK.
|
||||
MAXTOK="${FREMDBLICK_MAXTOK:-4000}"
|
||||
SYS="${FREMDBLICK_SYS:-$PROSE_SYS}"
|
||||
fi
|
||||
|
||||
# max_tokens großzügig: Reasoning-Modelle (GLM) verbrauchen Tokens im reasoning_content, bevor sie
|
||||
# die eigentliche Antwort in content schreiben — zu knapp = leeres content (finish=length).
|
||||
RESP="$(jq -n --arg m "$MODEL" --arg s "$SYS" --arg u "$SUBJECT" --argjson t "$MAXTOK" \
|
||||
'{model:$m, temperature:0.2, max_tokens:$t, messages:[{role:"system",content:$s},{role:"user",content:$u}]}' \
|
||||
| curl -s --max-time 240 "$ENDPOINT" -H 'Content-Type: application/json' -d @- 2>/dev/null)"
|
||||
|
||||
OUT="$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null)"
|
||||
FIN="$(printf '%s' "$RESP" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)"
|
||||
|
||||
if [ -n "$OUT" ]; then
|
||||
echo "=== FREMDBLICK (Zweitmeinung von Modell: $MODEL) ==="
|
||||
echo "$OUT"
|
||||
elif [ "$FIN" = "length" ]; then
|
||||
echo "=== FREMDBLICK ABGESCHNITTEN ==="
|
||||
echo "(Das Kritiker-Modell hat sein Token-Budget im Nachdenken verbraucht, bevor ein Urteil kam."
|
||||
echo " Kürze deine Vorlage auf die Kern-Behauptungen und versuche es erneut; sonst UNGEPRUEFT.)"
|
||||
else
|
||||
echo "=== FREMDBLICK FEHLGESCHLAGEN ==="
|
||||
echo "(Kein Urteil erhalten — Endpoint/Modell nicht erreichbar. Behandle die Funde als UNGEPRUEFT.)"
|
||||
fi
|
||||
@@ -1,33 +0,0 @@
|
||||
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
|
||||
|
||||
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
|
||||
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
|
||||
|
||||
## 1. Zu Beginn: erst SAVEPOINT.md lesen
|
||||
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
|
||||
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
|
||||
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
|
||||
ist, sag es, statt es zu erfinden.
|
||||
|
||||
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
|
||||
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
|
||||
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
|
||||
|
||||
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
|
||||
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
|
||||
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
|
||||
- **Offene Fragen** — Entscheidungen, die noch anstehen.
|
||||
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
|
||||
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
|
||||
|
||||
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
|
||||
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
|
||||
|
||||
## 3. Kleine, überprüfbare Schritte
|
||||
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
|
||||
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
|
||||
|
||||
## 4. Wenn der Governor das Sitzungs-Limit meldet
|
||||
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
|
||||
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
|
||||
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Governor — Phase 0
|
||||
|
||||
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
|
||||
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
|
||||
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
|
||||
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
|
||||
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
|
||||
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
|
||||
|
||||
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
|
||||
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
|
||||
|
||||
## Bausteine (dieses Verzeichnis)
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
|
||||
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
|
||||
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
|
||||
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
|
||||
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
|
||||
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
|
||||
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
|
||||
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
|
||||
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
|
||||
|
||||
## Verhalten des Governors
|
||||
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
|
||||
kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions):
|
||||
|
||||
- **est < Soft** → unverändert durchreichen.
|
||||
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
|
||||
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
|
||||
ehrlichen Abschluss-Savepoint.
|
||||
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
|
||||
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
|
||||
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
|
||||
|
||||
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
|
||||
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
|
||||
mitgeloggt.
|
||||
|
||||
### Sprach-Signal an Lucy (Phase 2)
|
||||
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
|
||||
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
|
||||
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
|
||||
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
|
||||
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
|
||||
|
||||
### Umgebungsvariablen
|
||||
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
|
||||
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
|
||||
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
|
||||
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
|
||||
|
||||
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
|
||||
```bash
|
||||
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
|
||||
pipx install uv && uv tool install --python 3.12 aider-chat
|
||||
|
||||
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
|
||||
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
|
||||
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
|
||||
|
||||
# Aider-Sitzung durch den Governor:
|
||||
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
|
||||
```
|
||||
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
|
||||
`openai/Qwen3-Coder-Next` auf den Governor.
|
||||
|
||||
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
|
||||
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
|
||||
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
|
||||
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
|
||||
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
|
||||
|
||||
## Ergebnisse & Befunde (24.07.2026)
|
||||
|
||||
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
|
||||
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
|
||||
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
|
||||
→ `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
|
||||
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
|
||||
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
|
||||
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
|
||||
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
|
||||
Schritt) und verweigerte neuen Code.
|
||||
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
|
||||
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
|
||||
Tests laufen grün** gegen die echte API. Kein Erfinden.
|
||||
|
||||
### Kalibrierung
|
||||
`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
|
||||
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
|
||||
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
|
||||
|
||||
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
|
||||
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
|
||||
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
|
||||
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
|
||||
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
|
||||
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
|
||||
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
|
||||
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
|
||||
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
|
||||
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
|
||||
|
||||
### Weitere Befunde / Fallen
|
||||
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
|
||||
Aider selbst und der Governor greift nie. Siehe oben.
|
||||
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
|
||||
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
|
||||
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
|
||||
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
|
||||
Python 3.12 installiert.
|
||||
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
|
||||
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
|
||||
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
|
||||
|
||||
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
|
||||
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
|
||||
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
|
||||
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
|
||||
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
|
||||
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
|
||||
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
|
||||
lokalen `:8080`-Endpoint irrelevant.
|
||||
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
|
||||
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
|
||||
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
|
||||
|
||||
## Nächste Schritte (Phase 1+)
|
||||
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
|
||||
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
|
||||
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
|
||||
@@ -1,23 +0,0 @@
|
||||
# SAVEPOINT
|
||||
|
||||
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
|
||||
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
|
||||
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
|
||||
|
||||
## Ziel
|
||||
_(noch nichts — von der ersten Sitzung zu füllen)_
|
||||
|
||||
## Erledigt
|
||||
- _(noch nichts)_
|
||||
|
||||
## Nächster Schritt
|
||||
- Auftrag des Nutzers entgegennehmen und beginnen.
|
||||
|
||||
## Offene Fragen
|
||||
- _(keine)_
|
||||
|
||||
## Stolpersteine
|
||||
- _(keine bekannt)_
|
||||
|
||||
## Dateien
|
||||
- `SAVEPOINT.md` — diese Übergabe-Datei.
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
|
||||
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
|
||||
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
|
||||
set -u
|
||||
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||
|
||||
python3 - <<'PY' > /tmp/gov_fire.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
|
||||
PY
|
||||
|
||||
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
|
||||
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
|
||||
|
||||
sleep 1.5 # Announce-Thread durchlassen
|
||||
echo "=== Governor-Log (letzte 4 Zeilen) ==="
|
||||
tail -4 ~/governor-p0/governor.log
|
||||
|
||||
echo "=== Announce-Queue: Governor-Eintraege ==="
|
||||
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
|
||||
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
|
||||
|
||||
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
|
||||
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was
|
||||
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
|
||||
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
|
||||
|
||||
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
|
||||
.../aider-chat/bin/python driver.py <messages-file>
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aider.coders import Coder
|
||||
from aider.io import InputOutput
|
||||
from aider.models import Model
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
|
||||
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
|
||||
os.environ.setdefault("OPENAI_API_KEY", "dummy")
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: driver.py <messages-file>", file=sys.stderr)
|
||||
return 2
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
|
||||
|
||||
model = Model("openai/Qwen3-Coder-Next")
|
||||
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
|
||||
coder = Coder.create(
|
||||
main_model=model,
|
||||
io=io,
|
||||
fnames=["SAVEPOINT.md"], # editierbar
|
||||
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
|
||||
auto_commits=True, # jede Etappe -> git-Commit
|
||||
stream=False, # deterministische Logs fuer P0
|
||||
map_tokens=512,
|
||||
use_git=True,
|
||||
)
|
||||
|
||||
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
|
||||
try:
|
||||
coder.summarizer.max_tokens = 10 ** 9
|
||||
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
|
||||
print(f">> {msg[:140]}", flush=True)
|
||||
try:
|
||||
coder.run(with_message=msg)
|
||||
except Exception as exc:
|
||||
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
|
||||
break
|
||||
sent = getattr(coder, "total_tokens_sent", "?")
|
||||
recv = getattr(coder, "total_tokens_received", "?")
|
||||
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
|
||||
|
||||
print("\n===== SESSION ENDE =====", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
|
||||
# Nutzung:
|
||||
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
|
||||
# gov-ctl.sh stop
|
||||
# gov-ctl.sh status
|
||||
set -u
|
||||
DIR="$HOME/governor-p0"
|
||||
PIDF="$DIR/governor.pid"
|
||||
LOG="$DIR/governor.log"
|
||||
OUT="$DIR/governor.stdout"
|
||||
|
||||
start() {
|
||||
stop
|
||||
local thr="${1:-25000}"
|
||||
cd "$DIR"
|
||||
: > "$LOG"
|
||||
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
|
||||
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
|
||||
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
|
||||
local hardenv=()
|
||||
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
|
||||
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
|
||||
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
|
||||
GOV_LOG="$LOG" GOV_PORT=8100 \
|
||||
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
|
||||
echo $! > "$PIDF"
|
||||
sleep 1.2
|
||||
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
|
||||
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
|
||||
}
|
||||
|
||||
stop() {
|
||||
pkill -f "$DIR/governor.py" 2>/dev/null || true
|
||||
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
|
||||
sleep 0.6
|
||||
rm -f "$PIDF"
|
||||
}
|
||||
|
||||
status() {
|
||||
if pgrep -f "$DIR/governor.py" >/dev/null; then
|
||||
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
|
||||
ss -tlnp 2>/dev/null | grep ":8100" || true
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
start) shift; start "${1:-25000}" "${2:-}" ;;
|
||||
stop) stop; echo stopped ;;
|
||||
status) status ;;
|
||||
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
|
||||
esac
|
||||
@@ -1,568 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
||||
|
||||
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
|
||||
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
|
||||
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
|
||||
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
|
||||
|
||||
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
|
||||
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
|
||||
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
|
||||
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
|
||||
|
||||
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
|
||||
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
|
||||
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
|
||||
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
|
||||
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
|
||||
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
|
||||
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
|
||||
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
|
||||
einem einmal geratenen Wert festzuhaengen.
|
||||
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
|
||||
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
|
||||
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
|
||||
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
|
||||
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
|
||||
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
|
||||
und Lucys `loop_status`.
|
||||
|
||||
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
|
||||
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
|
||||
|
||||
Konfiguration per Umgebungsvariablen (alle optional):
|
||||
GOV_PORT Listen-Port (Default 8100)
|
||||
GOV_HOST Listen-Adresse (Default 0.0.0.0)
|
||||
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
|
||||
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
|
||||
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
|
||||
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
|
||||
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
|
||||
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
|
||||
GOV_DIRECTIVE Text des Soft-Einschubs
|
||||
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
|
||||
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
|
||||
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
||||
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
|
||||
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
|
||||
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# ---- Konfiguration ---------------------------------------------------------
|
||||
|
||||
PORT = int(os.environ.get("GOV_PORT", "8100"))
|
||||
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
|
||||
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
|
||||
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
|
||||
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
|
||||
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
|
||||
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
||||
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
||||
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
|
||||
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
|
||||
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
|
||||
|
||||
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
|
||||
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
|
||||
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
|
||||
EXEMPT_MODELS = {m.strip().lower() for m in
|
||||
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
|
||||
|
||||
DEFAULT_DIRECTIVE = (
|
||||
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
|
||||
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
|
||||
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
|
||||
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
|
||||
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
|
||||
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
|
||||
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
|
||||
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
|
||||
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
|
||||
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
|
||||
)
|
||||
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
|
||||
|
||||
DEFAULT_HARDSTOP = (
|
||||
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
|
||||
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
|
||||
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
|
||||
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
|
||||
)
|
||||
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
|
||||
|
||||
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
||||
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
|
||||
DEFAULT_ANNOUNCE = (
|
||||
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
||||
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
||||
)
|
||||
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
||||
|
||||
up = urlparse(UPSTREAM)
|
||||
UP_HOST = up.hostname or "127.0.0.1"
|
||||
UP_PORT = up.port or 80
|
||||
|
||||
HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade",
|
||||
}
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
|
||||
_announce_lock = threading.Lock()
|
||||
_last_announce = 0.0
|
||||
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
||||
|
||||
# ---- Zustand (klein, im Speicher) ------------------------------------------
|
||||
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
|
||||
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
|
||||
_state_lock = threading.Lock()
|
||||
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
|
||||
_cpt_n: dict = {} # modell -> Anzahl Messungen
|
||||
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
|
||||
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
|
||||
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
|
||||
|
||||
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
|
||||
|
||||
|
||||
def log(line: str) -> None:
|
||||
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
|
||||
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
msg = f"{stamp} {line}"
|
||||
with _log_lock:
|
||||
print(msg, flush=True)
|
||||
try:
|
||||
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
||||
fh.write(msg + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def cpt_for(model: str) -> float:
|
||||
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
|
||||
with _state_lock:
|
||||
return _cpt.get(model, CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def calibrate(model: str, chars: int, exact: int) -> None:
|
||||
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
|
||||
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
|
||||
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
|
||||
return
|
||||
ratio = chars / exact
|
||||
if not (CPT_MIN <= ratio <= CPT_MAX):
|
||||
return
|
||||
with _state_lock:
|
||||
n = _cpt_n.get(model, 0)
|
||||
old = _cpt.get(model, CHARS_PER_TOKEN)
|
||||
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
|
||||
w = max(0.08, 1.0 / (n + 2))
|
||||
_cpt[model] = old * (1 - w) + ratio * w
|
||||
_cpt_n[model] = n + 1
|
||||
|
||||
|
||||
def body_chars(data: dict) -> int:
|
||||
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
|
||||
|
||||
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
|
||||
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
|
||||
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
|
||||
Wir serialisieren einfach alles, was ans Modell geht.
|
||||
"""
|
||||
payload = {k: v for k, v in data.items()
|
||||
if k in ("messages", "tools", "tool_choice", "system", "functions")}
|
||||
try:
|
||||
return len(json.dumps(payload, ensure_ascii=False))
|
||||
except (TypeError, ValueError):
|
||||
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
|
||||
chars = 0
|
||||
for m in data.get("messages") or []:
|
||||
c = m.get("content") if isinstance(m, dict) else None
|
||||
if isinstance(c, str):
|
||||
chars += len(c) + 4
|
||||
elif isinstance(c, list):
|
||||
for p in c:
|
||||
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||
chars += len(p["text"])
|
||||
return chars
|
||||
|
||||
|
||||
def safe_to_append(messages) -> bool:
|
||||
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
|
||||
|
||||
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
|
||||
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
|
||||
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
|
||||
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
|
||||
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
|
||||
"""
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict):
|
||||
return False
|
||||
role = last.get("role")
|
||||
if role == "tool":
|
||||
return False
|
||||
return not (role == "assistant" and last.get("tool_calls"))
|
||||
|
||||
|
||||
def _post_announce(est) -> None:
|
||||
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
||||
try:
|
||||
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||
.replace("{threshold}", str(THRESHOLD)))
|
||||
body = json.dumps({"text": text, "subject": "[Governor]",
|
||||
"source": "governor", "priority": "normal"}).encode("utf-8")
|
||||
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
||||
_an.port or 80, timeout=4)
|
||||
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body))})
|
||||
resp = conn.getresponse()
|
||||
resp.read()
|
||||
conn.close()
|
||||
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
||||
except Exception as exc:
|
||||
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
||||
|
||||
|
||||
def maybe_announce(est) -> None:
|
||||
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
||||
if not _an:
|
||||
return
|
||||
global _last_announce
|
||||
now = time.time()
|
||||
with _announce_lock:
|
||||
if now - _last_announce < ANNOUNCE_THROTTLE:
|
||||
return
|
||||
_last_announce = now
|
||||
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
||||
|
||||
|
||||
def status_payload() -> dict:
|
||||
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
|
||||
with _state_lock:
|
||||
return {
|
||||
"ok": True,
|
||||
"upstream": UPSTREAM,
|
||||
"soft": THRESHOLD,
|
||||
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
|
||||
"uptime_s": int(time.time() - _counters["started"]),
|
||||
"counters": {k: v for k, v in _counters.items() if k != "started"},
|
||||
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
|
||||
for m, v in _cpt.items()},
|
||||
"calibration_default": CHARS_PER_TOKEN,
|
||||
"exempt_models": sorted(EXEMPT_MODELS),
|
||||
"recent": list(_recent),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "Governor/2.0"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
|
||||
self._send_json(200, status_payload())
|
||||
return
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self):
|
||||
self._proxy()
|
||||
|
||||
def do_PUT(self):
|
||||
self._proxy()
|
||||
|
||||
def do_DELETE(self):
|
||||
self._proxy()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self._proxy()
|
||||
|
||||
# -- Kern ---------------------------------------------------------------
|
||||
def _send_json(self, status: int, obj) -> None:
|
||||
try:
|
||||
data = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _read_body(self) -> bytes:
|
||||
length = self.headers.get("Content-Length")
|
||||
if length is None:
|
||||
return b""
|
||||
try:
|
||||
return self.rfile.read(int(length))
|
||||
except (ValueError, OSError):
|
||||
return b""
|
||||
|
||||
def _proxy(self) -> None:
|
||||
body = self._read_body()
|
||||
path = self.path
|
||||
clean_path = path.split("?", 1)[0]
|
||||
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
|
||||
|
||||
action = "passthrough"
|
||||
est = None
|
||||
streaming = False
|
||||
model = ""
|
||||
chars = 0
|
||||
if is_chat and body:
|
||||
action, body, est, streaming, model, chars = self._decide(body)
|
||||
if action in ("soft", "hard"):
|
||||
maybe_announce(est)
|
||||
|
||||
if action == "hard":
|
||||
self._send_canned_stop(model, streaming, est)
|
||||
with _state_lock:
|
||||
_counters["chat"] += 1
|
||||
_counters["hard"] += 1
|
||||
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||
"exact": None, "action": "hard"})
|
||||
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
|
||||
f"HARDSTOP stream={streaming} status=200")
|
||||
return
|
||||
|
||||
out_headers = {}
|
||||
for k, v in self.headers.items():
|
||||
kl = k.lower()
|
||||
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
|
||||
continue
|
||||
out_headers[k] = v
|
||||
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
|
||||
out_headers["Accept-Encoding"] = "identity"
|
||||
if body:
|
||||
out_headers["Content-Length"] = str(len(body))
|
||||
out_headers["Connection"] = "close"
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
|
||||
conn.request(self.command, path, body=body or None, headers=out_headers)
|
||||
resp = conn.getresponse()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
log(f"ERROR upstream {self.command} {path}: {exc!r}")
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
self._safe_error(502, f"governor upstream: {exc}")
|
||||
return
|
||||
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.getheaders():
|
||||
kl = k.lower()
|
||||
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
|
||||
continue
|
||||
self.send_header(k, v)
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
|
||||
tail = bytearray()
|
||||
try:
|
||||
while True:
|
||||
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
|
||||
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
||||
chunk = resp.read1(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
tail.extend(chunk)
|
||||
if len(tail) > 16384:
|
||||
del tail[:-16384]
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
exact = self._scan_prompt_tokens(tail)
|
||||
if is_chat:
|
||||
if exact:
|
||||
calibrate(model, chars, exact)
|
||||
with _state_lock:
|
||||
_counters["chat"] += 1
|
||||
_counters["soft" if action == "soft" else "passthrough"] += 1
|
||||
if exact:
|
||||
_counters["tokens_prompt"] += exact
|
||||
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||
"exact": exact, "action": action})
|
||||
exact_s = str(exact) if exact is not None else "-"
|
||||
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
|
||||
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
|
||||
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
|
||||
|
||||
def _decide(self, body: bytes):
|
||||
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return "passthrough", body, None, False, "", 0
|
||||
if not isinstance(data, dict):
|
||||
return "passthrough", body, None, False, "", 0
|
||||
|
||||
messages = data.get("messages")
|
||||
streaming = bool(data.get("stream"))
|
||||
model = (data.get("model") or "").strip()
|
||||
chars = body_chars(data)
|
||||
est = int(chars / max(cpt_for(model), 0.1))
|
||||
|
||||
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
|
||||
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
|
||||
base = model.split("/")[-1].lower()
|
||||
if base in EXEMPT_MODELS:
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
if not isinstance(messages, list):
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
has_warned = False
|
||||
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
|
||||
has_warned = True
|
||||
break
|
||||
|
||||
is_safe = safe_to_append(messages)
|
||||
|
||||
# 1. Absolutes Not-Aus bei komplettem Amoklauf
|
||||
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
|
||||
return "hard", body, est, streaming, model, chars
|
||||
|
||||
# 2. Sind wir am Limit?
|
||||
if est >= THRESHOLD:
|
||||
if not is_safe:
|
||||
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
|
||||
return "defer", body, est, streaming, model, chars
|
||||
|
||||
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
|
||||
if HARD_CEILING > 0 and est >= HARD_CEILING:
|
||||
if has_warned:
|
||||
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
|
||||
return "hard", body, est, streaming, model, chars
|
||||
else:
|
||||
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
|
||||
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
|
||||
pass
|
||||
|
||||
# Soft-Limit greift
|
||||
if not has_warned:
|
||||
directive = (DIRECTIVE.replace("{est}", str(est))
|
||||
.replace("{threshold}", str(THRESHOLD)))
|
||||
messages.append({"role": "user", "content": directive})
|
||||
data["messages"] = messages
|
||||
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
||||
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
|
||||
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
|
||||
created = int(time.time())
|
||||
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
|
||||
"total_tokens": est or 0}
|
||||
try:
|
||||
if streaming:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
|
||||
def sse(obj):
|
||||
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
|
||||
"created": created, "model": model}
|
||||
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
|
||||
"finish_reason": None}]})
|
||||
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
|
||||
"finish_reason": None}]})
|
||||
sse({**base, "choices": [{"index": 0, "delta": {},
|
||||
"finish_reason": "stop"}], "usage": usage})
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
else:
|
||||
payload = {
|
||||
"id": "governor-hardstop", "object": "chat.completion",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
|
||||
"usage": usage,
|
||||
}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
self.wfile.flush()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _safe_error(self, status: int, msg: str) -> None:
|
||||
self._send_json(status, {"error": msg})
|
||||
|
||||
@staticmethod
|
||||
def _scan_prompt_tokens(tail: bytearray):
|
||||
if not tail:
|
||||
return None
|
||||
try:
|
||||
text = tail.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
matches = _PROMPT_TOKENS_RE.findall(text)
|
||||
if not matches:
|
||||
return None
|
||||
try:
|
||||
return int(matches[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
log_dir = os.path.dirname(LOG_PATH)
|
||||
if log_dir and not os.path.isdir(log_dir):
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
server.daemon_threads = True
|
||||
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
|
||||
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
|
||||
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
|
||||
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Governor beendet (SIGINT).")
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
|
||||
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
|
||||
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
|
||||
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
|
||||
After=network-online.target mission-control-2.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/governor
|
||||
ExecStart=/usr/bin/python3 %h/governor/governor.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# --- Schwellen -------------------------------------------------------------
|
||||
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
|
||||
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
|
||||
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
|
||||
Environment=GOV_THRESHOLD=45000
|
||||
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
|
||||
Environment=GOV_LOG=%h/governor/governor.log
|
||||
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
|
||||
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
|
||||
# klein -> passthrough (Modell antwortet normal)
|
||||
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
|
||||
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
|
||||
set -u
|
||||
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||
|
||||
python3 - <<'PY' > /tmp/gov_small.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
|
||||
PY
|
||||
python3 - <<'PY' > /tmp/gov_mid.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
|
||||
PY
|
||||
python3 - <<'PY' > /tmp/gov_big.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
|
||||
PY
|
||||
|
||||
echo "=== TEST A: klein (passthrough) ==="
|
||||
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
|
||||
|
||||
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
|
||||
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
|
||||
|
||||
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
|
||||
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
|
||||
@@ -1,13 +0,0 @@
|
||||
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
|
||||
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
|
||||
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
|
||||
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
|
||||
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
|
||||
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
|
||||
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
|
||||
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
|
||||
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
|
||||
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
|
||||
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
|
||||
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
|
||||
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
|
||||
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
|
||||
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
|
||||
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
|
||||
set -u
|
||||
REPO="${1:?repo dir}"
|
||||
MSG="${2:?message}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||
export OPENAI_API_KEY="dummy"
|
||||
cd "$REPO"
|
||||
aider \
|
||||
--model openai/Qwen3-Coder-Next \
|
||||
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
|
||||
--map-tokens 512 \
|
||||
--read CONVENTIONS.md --read todo.py --read cli.py \
|
||||
SAVEPOINT.md \
|
||||
--message "$MSG"
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
|
||||
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
|
||||
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
|
||||
set -u
|
||||
MSGS="${1:?Nachrichtendatei angeben}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||
export OPENAI_API_KEY="dummy"
|
||||
cd "$HOME/governor-p0/testrepo"
|
||||
aider \
|
||||
--model openai/Qwen3-Coder-Next \
|
||||
--no-check-update \
|
||||
--no-show-model-warnings \
|
||||
--no-analytics \
|
||||
--yes-always \
|
||||
--map-tokens 512 \
|
||||
--read CONVENTIONS.md \
|
||||
SAVEPOINT.md \
|
||||
< "$MSGS"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
|
||||
set -u
|
||||
MSGS="${1:?Nachrichtendatei angeben}"
|
||||
REPO="${2:-$HOME/governor-p0/testrepo}"
|
||||
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
|
||||
cd "$REPO"
|
||||
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
|
||||
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hermes Built-in Web UI (`hermes serve` — Agent-Dashboard/Chat, Loopback only)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# Serviert die EINGEBAUTE Hermes-Web-GUI (die reiche Agent-Oberflaeche mit Threads/Tool-Calls, die
|
||||
# auch die Electron-Desktop-App umhuellt) headless.
|
||||
# SICHERHEIT: Bindet NUR an Loopback (127.0.0.1:9119) und laeuft hinter dem MC2-Reverse-Proxy
|
||||
# (routers/hermes_ui.py → same-origin /hermes-ui/ auf dem offenen Port 9001) — KEINE eigene
|
||||
# Firewall-Freigabe, KEIN Login (LAN-Trust wie Konsole/Terminal). --skip-build nutzt das von
|
||||
# deploy.sh mit Vite-base=/hermes-ui/ vorgebaute web_dist, damit alle SPA-Pfade unter /hermes-ui/
|
||||
# liegen und der Proxy sie sauber durchreichen kann.
|
||||
Type=simple
|
||||
Environment=HERMES_HOME=%h/.hermes
|
||||
ExecStart=%h/.hermes/hermes-agent/venv/bin/hermes dashboard --no-open --skip-build --host 127.0.0.1 --port 9119
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,259 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
|
||||
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.
|
||||
|
||||
Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
|
||||
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
|
||||
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
|
||||
Patches (re-)ein.
|
||||
|
||||
Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
|
||||
Zeilenverschiebungen durch Updates, anders als ``git apply``):
|
||||
* ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen.
|
||||
* ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen.
|
||||
* ``old`` 0x oder >1x -> Kontext hat sich durch ein Update
|
||||
geaendert -> LAUT scheitern (Exit 1), damit
|
||||
der Postcheck rot wird und niemand denkt,
|
||||
der Fix sei noch aktiv.
|
||||
|
||||
Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
|
||||
"""
|
||||
import os
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
|
||||
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
|
||||
COPIES = [
|
||||
("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
|
||||
]
|
||||
|
||||
# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
|
||||
PATCHES = [
|
||||
{
|
||||
"name": "0001-needs-input-wait",
|
||||
"file": "hermes_cli/kanban_db.py",
|
||||
"marker": 'kind not in ("needs_input", "capability")',
|
||||
"old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
|
||||
"new": (
|
||||
" # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
|
||||
" # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
|
||||
" # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
|
||||
" # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
|
||||
' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
|
||||
" # fuer transient/untyped Blocks aktiv.\n"
|
||||
' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "0002-decompose-at-readiness",
|
||||
"file": "hermes_cli/kanban_decompose.py",
|
||||
"marker": "Startreife-Gate",
|
||||
"old": (
|
||||
" cfg = _load_config()\n"
|
||||
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
|
||||
),
|
||||
"new": (
|
||||
" # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
|
||||
" # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
|
||||
" # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
|
||||
" # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
|
||||
" # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
|
||||
" with kb.connect_closing() as _pconn:\n"
|
||||
" _parents = [r[0] for r in _pconn.execute(\n"
|
||||
' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
|
||||
" if _parents:\n"
|
||||
' _ph = ",".join("?" * len(_parents))\n'
|
||||
" _open = _pconn.execute(\n"
|
||||
' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
|
||||
" \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
|
||||
" ).fetchone()[0]\n"
|
||||
" if _open:\n"
|
||||
" return DecomposeOutcome(\n"
|
||||
" task_id, False,\n"
|
||||
' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
|
||||
" )\n"
|
||||
" cfg = _load_config()\n"
|
||||
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "0003-orphan-guard",
|
||||
"file": "gateway/kanban_watchers.py",
|
||||
"marker": "mc2_kanban_reaper",
|
||||
"old": (
|
||||
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
|
||||
" if pids:\n"
|
||||
" logger.info(\n"
|
||||
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
|
||||
" len(pids),\n"
|
||||
" pids,\n"
|
||||
" )\n"
|
||||
),
|
||||
"new": (
|
||||
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
|
||||
" if pids:\n"
|
||||
" logger.info(\n"
|
||||
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
|
||||
" len(pids),\n"
|
||||
" pids,\n"
|
||||
" )\n"
|
||||
" # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
|
||||
" # Worker, deren Task nicht mehr 'running' ist (extern\n"
|
||||
" # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
|
||||
" # und verbrennen Rechenzeit.\n"
|
||||
" try:\n"
|
||||
" from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
|
||||
" _orph = await asyncio.to_thread(\n"
|
||||
" _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
|
||||
" )\n"
|
||||
" if _orph:\n"
|
||||
" logger.info(\n"
|
||||
' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
|
||||
" len(_orph), _orph,\n"
|
||||
" )\n"
|
||||
" except Exception:\n"
|
||||
' logger.exception("kanban dispatcher: orphan reaper failed")\n'
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "0004a-context-inherit-compute",
|
||||
"file": "hermes_cli/kanban_decompose.py",
|
||||
"marker": '_parent_ctx = ""',
|
||||
"old": (
|
||||
" children: list[dict] = []\n"
|
||||
" for idx, entry in enumerate(raw_tasks):\n"
|
||||
),
|
||||
"new": (
|
||||
" children: list[dict] = []\n"
|
||||
" # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
|
||||
' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
|
||||
" # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
|
||||
" # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
|
||||
' _parent_ctx = ""\n'
|
||||
' if getattr(task, "body", None) and task.body.strip():\n'
|
||||
" _parent_ctx = (\n"
|
||||
' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
|
||||
" + task.body.strip()[:1500]\n"
|
||||
' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
|
||||
" )\n"
|
||||
" for idx, entry in enumerate(raw_tasks):\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "0004b-context-inherit-apply",
|
||||
"file": "hermes_cli/kanban_decompose.py",
|
||||
"marker": "_parent_ctx + body",
|
||||
"old": (
|
||||
' body = entry.get("body")\n'
|
||||
" if not isinstance(body, str):\n"
|
||||
' body = ""\n'
|
||||
),
|
||||
"new": (
|
||||
' body = entry.get("body")\n'
|
||||
" if not isinstance(body, str):\n"
|
||||
' body = ""\n'
|
||||
" body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "0005-create-chain-guard",
|
||||
"file": "tools/kanban_tools.py",
|
||||
"marker": "_MC2_RECENT_ROOTS",
|
||||
"old": (
|
||||
" new_task = kb.get_task(conn, new_tid)\n"
|
||||
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
|
||||
),
|
||||
"new": (
|
||||
" # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
|
||||
" # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
|
||||
" # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
|
||||
" # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
|
||||
" # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
|
||||
" # Verkettung harmlos (laeuft eh seriell).\n"
|
||||
" try:\n"
|
||||
" if triage and not parents and session_id:\n"
|
||||
" import time as _mc2t\n"
|
||||
' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
|
||||
" _prev = _roots.get(session_id)\n"
|
||||
" _now = _mc2t.monotonic()\n"
|
||||
" if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
|
||||
" try:\n"
|
||||
" kb.link_tasks(conn, _prev[0], new_tid)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" _roots[session_id] = (new_tid, _now)\n"
|
||||
" if len(_roots) > 64:\n"
|
||||
" _roots.clear()\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" new_task = kb.get_task(conn, new_tid)\n"
|
||||
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not HA.is_dir():
|
||||
print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
|
||||
return 0
|
||||
applied, skipped, failed = [], [], []
|
||||
|
||||
# --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
|
||||
for src_rel, dst_rel in COPIES:
|
||||
src, dst = HERE / src_rel, HA / dst_rel
|
||||
if not src.is_file():
|
||||
failed.append((src_rel, f"Quelle fehlt: {src}"))
|
||||
continue
|
||||
try:
|
||||
same = dst.is_file() and dst.read_bytes() == src.read_bytes()
|
||||
if same:
|
||||
skipped.append(f"copy:{dst_rel}")
|
||||
continue
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
if dst.suffix == ".py":
|
||||
py_compile.compile(str(dst), doraise=True)
|
||||
applied.append(f"copy:{dst_rel}")
|
||||
except Exception as exc:
|
||||
failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
|
||||
for p in PATCHES:
|
||||
f = HA / p["file"]
|
||||
if not f.is_file():
|
||||
failed.append((p["name"], f"Datei fehlt: {f}"))
|
||||
continue
|
||||
src = f.read_text(encoding="utf-8")
|
||||
if p["marker"] in src:
|
||||
skipped.append(p["name"])
|
||||
continue
|
||||
n = src.count(p["old"])
|
||||
if n != 1:
|
||||
failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} "
|
||||
"(Update hat den Kontext geaendert?)")))
|
||||
continue
|
||||
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
|
||||
try:
|
||||
py_compile.compile(str(f), doraise=True)
|
||||
except Exception as exc:
|
||||
failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
|
||||
continue
|
||||
applied.append(p["name"])
|
||||
|
||||
print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
|
||||
for name, why in failed:
|
||||
print(f" FEHLER {name}: {why}", file=sys.stderr)
|
||||
if failed:
|
||||
return 1 # Fehler → Deploy/Postcheck rot
|
||||
if applied:
|
||||
return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
|
||||
return 0 # nichts zu tun (alles schon drin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,84 +0,0 @@
|
||||
"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker.
|
||||
|
||||
Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach
|
||||
``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher
|
||||
(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen.
|
||||
|
||||
Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B.
|
||||
über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task``
|
||||
``worker_pid = NULL`` — der Worker-PROZESS läuft aber weiter. Die DB zählt ihn
|
||||
nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker
|
||||
möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren
|
||||
Task nicht mehr ``running`` ist.
|
||||
|
||||
Linux-only (/proc-Scan). Best-effort: jeder Fehler → leere Liste, nie werfen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
|
||||
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
|
||||
|
||||
|
||||
def reap_orphaned_workers(connect_closing) -> list[tuple[int, str]]:
|
||||
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
|
||||
|
||||
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
|
||||
``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste
|
||||
der beendeten ``(pid, task_id)`` zurück.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return []
|
||||
candidates: dict[int, str] = {}
|
||||
try:
|
||||
entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
return []
|
||||
for entry in entries:
|
||||
if not entry.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{entry}/cmdline", "rb") as fh:
|
||||
cmd = fh.read().replace(b"\x00", b" ")
|
||||
except OSError:
|
||||
continue
|
||||
m = _TASK_RE.search(cmd)
|
||||
if m:
|
||||
candidates[int(entry)] = m.group(1).decode()
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
task_ids = list(set(candidates.values()))
|
||||
placeholders = ",".join("?" * len(task_ids))
|
||||
running: set[str] = set()
|
||||
try:
|
||||
with connect_closing() as conn:
|
||||
running = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
f"SELECT id FROM tasks WHERE id IN ({placeholders}) "
|
||||
"AND status = 'running'",
|
||||
task_ids,
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
killed: list[tuple[int, str]] = []
|
||||
for pid, task_id in candidates.items():
|
||||
if task_id in running:
|
||||
continue
|
||||
# Prozessgruppe zuerst (Worker starten start_new_session=True → eigene
|
||||
# Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit).
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except OSError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
continue
|
||||
killed.append((pid, task_id))
|
||||
return killed
|
||||
+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 ---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user