Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8d088fac | ||
|
|
b570e9410d | ||
|
|
e9c2599542 | ||
|
|
a8a6ce3b29 | ||
|
|
0944b1d93e | ||
|
|
1c8f285151 | ||
|
|
041ede7f8d | ||
|
|
33032f8fb0 | ||
|
|
3249c8e942 | ||
|
|
24a0694b80 | ||
|
|
a494d320d7 | ||
|
|
7957cda438 | ||
|
|
dcee0096fe | ||
|
|
b74e98ffbb | ||
|
|
5aa5a484a7 | ||
|
|
464b0d20b8 | ||
|
|
ae69c5ce31 | ||
|
|
8aa22e6591 | ||
|
|
3d1881f4bc | ||
|
|
2935752613 | ||
|
|
a969898a16 | ||
|
|
57492d6759 |
+22
-4
@@ -18,18 +18,36 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"dev",
|
||||||
|
"--",
|
||||||
|
"--port",
|
||||||
|
"5180",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
"env": {
|
||||||
|
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||||
|
},
|
||||||
"autoPort": false,
|
"autoPort": false,
|
||||||
"port": 5180
|
"port": 5180
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "frontend-mock",
|
"name": "frontend-mock",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"dev",
|
||||||
|
"--",
|
||||||
|
"--port",
|
||||||
|
"5181",
|
||||||
|
"--strictPort"
|
||||||
|
],
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
"env": {
|
||||||
|
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||||
|
},
|
||||||
"autoPort": false,
|
"autoPort": false,
|
||||||
"port": 5181
|
"port": 5181
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
#
|
#
|
||||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
|
||||||
|
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
|
||||||
name: Ampel
|
name: Ampel
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
@@ -43,6 +44,64 @@ jobs:
|
|||||||
else
|
else
|
||||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||||
|
|
||||||
|
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
|
||||||
|
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
|
||||||
|
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
|
||||||
|
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
|
||||||
|
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
|
||||||
|
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
|
||||||
|
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
|
||||||
|
if [ $rot -eq 0 ]; then
|
||||||
|
echo "-- Vitest"
|
||||||
|
( cd frontend && npm test --silent ) || rot=1
|
||||||
|
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
|
||||||
|
( cd frontend && npm run lint --silent ) || rot=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
|
||||||
|
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
|
||||||
|
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
|
||||||
|
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
|
||||||
|
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
|
||||||
|
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
|
||||||
|
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
|
||||||
|
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
|
||||||
|
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
|
||||||
|
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
|
||||||
|
# bewusst und einmalig, nicht weil es im Weg stand:
|
||||||
|
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
|
||||||
|
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
|
||||||
|
# hinter lazy() wanderten und das Budget scharf blieb.
|
||||||
|
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
|
||||||
|
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
|
||||||
|
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
|
||||||
|
# ueberlegten Plattform-Schritt.
|
||||||
|
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
|
||||||
|
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
|
||||||
|
budget_dist=3145728 # Stand nach P0: 1 477 852 B
|
||||||
|
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
|
||||||
|
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
|
||||||
|
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
|
||||||
|
# Hash den falschen erwischt — und das Budget waere still immer gruen.
|
||||||
|
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
|
||||||
|
haupt="frontend/dist/$einstieg"
|
||||||
|
if [ -n "$haupt" ]; then
|
||||||
|
gz=$(gzip -c "$haupt" | wc -c)
|
||||||
|
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
|
||||||
|
if [ "$gz" -gt "$budget_gz" ]; then
|
||||||
|
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
|
||||||
|
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
gesamt=$(du -sb frontend/dist | cut -f1)
|
||||||
|
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
|
||||||
|
if [ "$gesamt" -gt "$budget_dist" ]; then
|
||||||
|
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $rot -eq 0 ]; then
|
if [ $rot -eq 0 ]; then
|
||||||
echo "== Frontend: Push nach release-dist =="
|
echo "== Frontend: Push nach release-dist =="
|
||||||
git config --global user.name "Gitea Actions"
|
git config --global user.name "Gitea Actions"
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ __pycache__/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||||
|
|
||||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
|
||||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
|
||||||
frontend/public/avatar.vrm
|
|
||||||
frontend/dist/avatar.vrm
|
|
||||||
|
|
||||||
# Env / local
|
# Env / local
|
||||||
*.env
|
*.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
|||||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
|
||||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||||
@@ -48,6 +47,14 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
|||||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
- **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 =
|
- 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.
|
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.
|
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||||
|
|
||||||
## Grenzen (Verdikt, eingehalten)
|
## Grenzen (Verdikt, eingehalten)
|
||||||
|
|||||||
@@ -49,29 +49,33 @@ Wächtern geboren**:
|
|||||||
|
|
||||||
## Modelle & Rollen
|
## Modelle & Rollen
|
||||||
|
|
||||||
Gemessen auf der Box (Stand: August 2026, Vulkan b10502):
|
Gemessen auf der Box (Stand: 27.08.2026, Vulkan b10653):
|
||||||
|
|
||||||
| Rolle | Modell | Tempo | Aufgabe |
|
| 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 (131k Kontext) |
|
| `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 |
|
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6–90 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm, 65k/Slot |
|
||||||
| `debugger` / `doctor` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (Multimodal) |
|
| `debugger` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (multimodal) |
|
||||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen, 16k Deckel) |
|
|
||||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
|
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
|
||||||
| `scout` | GLM-4.6V-Flash | auf Abruf | Schneller Vision- und Tool-Allrounder |
|
|
||||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
|
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
|
||||||
| `dense-planer` | Qwen3.8-27B | **12,7 t/s** (On-Demand) | Dichtes 27B-Modell für tiefes Reasoning & 262k Kontext |
|
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Vektorsuche + Feinsortierung |
|
||||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
|
||||||
|
|
||||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
> Sieben Rollen, mehr nicht. Frühere Fassungen listeten hier auch `kritiker`, `scout` und
|
||||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
> `dense-planer` — die Modell-Konsolidierung vom 19.08. hat sie entfernt (ein Coder, ein
|
||||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
> Agent-Hirn). Der dichte 27B ist seither nicht mehr „Planer" neben dem Coder, sondern **ist**
|
||||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
> der Coder. Details: `docs/wissen/STACK.md`.
|
||||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
|
||||||
|
‼️ **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
|
‼️ **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
|
(`groups.brains`) steht auf **`persistent: true`** (Verdrängungsschutz; live gegengeprüft
|
||||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
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).
|
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||||
|
|
||||||
## Qualitäts-Tore
|
## Qualitäts-Tore
|
||||||
|
|||||||
+11
-2
@@ -96,6 +96,11 @@ HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
|||||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||||
# passen. MC2 proxyt nach außen.
|
# passen. MC2 proxyt nach außen.
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||||
|
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||||
|
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||||
|
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||||
|
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||||
|
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||||
@@ -105,8 +110,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).
|
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||||
BOX_CONSOLE_PATH = "/console/"
|
BOX_CONSOLE_PATH = "/console/"
|
||||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
# 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
|
# /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
|
# 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.
|
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from services import agent_aktivitaet
|
||||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
|
|||||||
def set_brain_model(body: BrainReq) -> dict:
|
def set_brain_model(body: BrainReq) -> dict:
|
||||||
ok = update_brain_model(body.model)
|
ok = update_brain_model(body.model)
|
||||||
return {"ok": ok}
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent/aktivitaet")
|
||||||
|
def aktivitaet(limit: int = 60) -> dict:
|
||||||
|
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||||
|
|
||||||
|
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
|
||||||
|
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
|
||||||
|
"""
|
||||||
|
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
|
||||||
|
|||||||
+85
-37
@@ -1,25 +1,30 @@
|
|||||||
"""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
|
Zwei Endpunkte, ein Sammler:
|
||||||
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.
|
|
||||||
|
|
||||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
|
||||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
|
||||||
invalidate-Event nur Lärm).
|
|
||||||
|
|
||||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
|
||||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
|
||||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
nur `/api/events` kennt. Danach entfernen.
|
||||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
|
||||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
|
||||||
|
|
||||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||||
selbst und bis dahin pollt die UI wie bisher).
|
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()`.
|
||||||
|
|
||||||
|
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
|
import asyncio
|
||||||
@@ -35,7 +40,8 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
METRIK_S = 1.0 # Takt der Messpunkte
|
||||||
|
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
|
||||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +72,13 @@ def _fingerprints() -> dict[str, object]:
|
|||||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
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
|
||||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
||||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
||||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
||||||
@@ -73,28 +86,63 @@ def _fingerprints() -> dict[str, object]:
|
|||||||
return fp
|
return fp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
async def _strom(request: Request, mit_metrik: bool):
|
||||||
async def events(request: Request) -> StreamingResponse:
|
"""Gemeinsamer Kern beider Endpunkte.
|
||||||
async def strom():
|
|
||||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||||
alt = _fingerprints()
|
"""
|
||||||
yield ": verbunden\n\n"
|
alt = _fingerprints()
|
||||||
seit_ping = 0.0
|
yield ": verbunden\n\n"
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
seit_abdruck = 0.0
|
||||||
return
|
seit_ping = 0.0
|
||||||
await asyncio.sleep(TICK_S)
|
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||||
seit_ping += TICK_S
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
return
|
||||||
|
await asyncio.sleep(takt)
|
||||||
|
seit_abdruck += takt
|
||||||
|
seit_ping += takt
|
||||||
|
|
||||||
|
if mit_metrik:
|
||||||
|
try:
|
||||||
|
from services.system import metrik_punkt
|
||||||
|
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
|
||||||
|
seit_ping = 0.0
|
||||||
|
except Exception:
|
||||||
|
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||||
|
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
|
||||||
|
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
|
||||||
|
|
||||||
|
if seit_abdruck >= ABDRUCK_S:
|
||||||
|
seit_abdruck = 0.0
|
||||||
neu = _fingerprints()
|
neu = _fingerprints()
|
||||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||||
alt.update(neu)
|
alt.update(neu)
|
||||||
if keys:
|
if keys:
|
||||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
||||||
seit_ping = 0.0
|
seit_ping = 0.0
|
||||||
elif seit_ping >= KEEPALIVE_S:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
|
|
||||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
if seit_ping >= KEEPALIVE_S:
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
yield ": ping\n\n"
|
||||||
|
seit_ping = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def stream(request: Request) -> StreamingResponse:
|
||||||
|
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
|
||||||
|
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||||
|
media_type="text/event-stream", headers=_KOPF)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def events(request: Request) -> StreamingResponse:
|
||||||
|
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
|
||||||
|
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
|
||||||
|
return StreamingResponse(_strom(request, mit_metrik=False),
|
||||||
|
media_type="text/event-stream", headers=_KOPF)
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||||
|
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
||||||
|
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
||||||
|
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
||||||
|
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
||||||
|
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
||||||
|
mit einem im Browser geparkten Geheimnis übertüncht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import maintenance
|
from services import geheimnisse, maintenance
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
class SudoReq(BaseModel):
|
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RestartReq(BaseModel):
|
class RestartReq(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
class GeheimnisReq(BaseModel):
|
||||||
|
schluessel: str
|
||||||
|
wert: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/updates")
|
@router.get("/maintenance/updates")
|
||||||
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
|
|||||||
return maintenance.update_details(kind)
|
return maintenance.update_details(kind)
|
||||||
|
|
||||||
@router.post("/maintenance/check-updates")
|
@router.post("/maintenance/check-updates")
|
||||||
def check_updates(body: SudoReq) -> dict:
|
def check_updates() -> dict:
|
||||||
res = maintenance.check_updates_job(body.sudo_password)
|
res = maintenance.check_updates_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/os-update")
|
@router.post("/maintenance/os-update")
|
||||||
def os_update(body: SudoReq) -> dict:
|
def os_update() -> dict:
|
||||||
res = maintenance.os_update_job(body.sudo_password)
|
res = maintenance.os_update_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/engine-update")
|
@router.post("/maintenance/engine-update")
|
||||||
def engine_update(body: SudoReq) -> dict:
|
def engine_update() -> dict:
|
||||||
res = maintenance.engine_update_job(body.sudo_password)
|
res = maintenance.engine_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/swap-update")
|
@router.post("/maintenance/swap-update")
|
||||||
def swap_update(body: SudoReq) -> dict:
|
def swap_update() -> dict:
|
||||||
res = maintenance.swap_update_job(body.sudo_password)
|
res = maintenance.swap_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/update-all")
|
@router.post("/maintenance/update-all")
|
||||||
def update_all(body: SudoReq) -> dict:
|
def update_all() -> dict:
|
||||||
return maintenance.update_all_job(body.sudo_password)
|
return maintenance.update_all_job()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/reboot")
|
@router.post("/maintenance/reboot")
|
||||||
def reboot(body: SudoReq) -> dict:
|
def reboot() -> dict:
|
||||||
return maintenance.reboot(body.sudo_password)
|
return maintenance.reboot()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/restart")
|
@router.post("/maintenance/restart")
|
||||||
def restart(body: RestartReq) -> dict:
|
def restart(body: RestartReq) -> dict:
|
||||||
return maintenance.restart_service(body.service, body.sudo_password)
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/logs")
|
@router.get("/maintenance/logs")
|
||||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
return maintenance.logs(service, lines, x_sudo_password)
|
return maintenance.logs(service, lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_status() -> dict:
|
||||||
|
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||||
|
return geheimnisse.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maintenance/geheimnisse")
|
||||||
|
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||||
|
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||||
|
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||||
|
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||||
|
return {"ok": True, **geheimnisse.status()}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import psutil
|
|||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
from services.fit import evaluate_fit, max_ctx_for
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
|
|||||||
quant: str = "Q4_K_M"
|
quant: str = "Q4_K_M"
|
||||||
ctx: int | None = None
|
ctx: int | None = None
|
||||||
jinja: bool = False
|
jinja: bool = False
|
||||||
hf_token: str | None = None
|
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||||
|
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||||
|
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hf/search")
|
@router.get("/hf/search")
|
||||||
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
|
|||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
env = dict(HF_DOWNLOAD_ENV)
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if token := geheimnisse.hf_token():
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = token
|
||||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||||
on_done=_apply_role if role else None)
|
on_done=_apply_role if role else None)
|
||||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import sys as _sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
|||||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||||
|
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||||
|
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||||
|
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||||
|
# LAN-only wie alle MC2-Endpoints.
|
||||||
|
|
||||||
|
class LucyTtsIn(BaseModel):
|
||||||
|
text: str
|
||||||
|
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lucy/stimme/health")
|
||||||
|
def lucy_stimme_health() -> dict:
|
||||||
|
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lucy/stimme/tts")
|
||||||
|
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||||
|
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||||
|
with Timer("lucy_tts"):
|
||||||
|
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||||
|
r.raise_for_status()
|
||||||
|
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||||
|
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lucy/stimme/tts/stream")
|
||||||
|
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||||
|
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||||
|
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||||
|
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||||
|
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||||
|
try:
|
||||||
|
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||||
|
upstream = await client.send(req, stream=True)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
await client.aclose()
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
if upstream.status_code != 200:
|
||||||
|
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||||
|
await upstream.aclose(); await client.aclose()
|
||||||
|
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
try:
|
||||||
|
async for chunk in upstream.aiter_raw():
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await upstream.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||||
|
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -57,39 +57,29 @@ def _pump_output(job: dict, stream) -> None:
|
|||||||
commit()
|
commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||||
|
"""Job-Prozess starten und mitschreiben.
|
||||||
|
|
||||||
|
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
||||||
|
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
||||||
|
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
||||||
|
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
||||||
|
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
||||||
job = JOBS[job_id]
|
job = JOBS[job_id]
|
||||||
job["state"] = "running"
|
job["state"] = "running"
|
||||||
try:
|
try:
|
||||||
actual_args = list(args)
|
|
||||||
if sudo_password is not None:
|
|
||||||
for i, arg in enumerate(actual_args):
|
|
||||||
if isinstance(arg, str):
|
|
||||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
stdin=subprocess.DEVNULL,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
env={**os.environ, **(env or {})},
|
env={**os.environ, **(env or {})},
|
||||||
)
|
)
|
||||||
_PROCS[job_id] = proc
|
_PROCS[job_id] = proc
|
||||||
|
|
||||||
if sudo_password is not None and proc.stdin:
|
|
||||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
|
||||||
proc.stdin.flush()
|
|
||||||
proc.stdin.close()
|
|
||||||
|
|
||||||
_pump_output(job, proc.stdout)
|
_pump_output(job, proc.stdout)
|
||||||
proc.wait()
|
proc.wait()
|
||||||
job["returncode"] = proc.returncode
|
job["returncode"] = proc.returncode
|
||||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||||
|
|
||||||
# Check if failed due to sudo authorization failure
|
|
||||||
if proc.returncode != 0 and job["log"]:
|
|
||||||
log_str = "\n".join(job["log"])
|
|
||||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
|
||||||
job["sudo_failed"] = True
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_append_log(job, f"[mc] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
group: str | None = None) -> str:
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
# Mask password in log if present in args
|
|
||||||
log_args = list(args)
|
log_args = list(args)
|
||||||
JOBS[job_id] = {
|
JOBS[job_id] = {
|
||||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||||
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
|
|||||||
}
|
}
|
||||||
if on_done:
|
if on_done:
|
||||||
JOBS[job_id]["_on_done"] = on_done
|
JOBS[job_id]["_on_done"] = on_done
|
||||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -546,59 +546,59 @@ def update_details(kind: str) -> dict:
|
|||||||
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
def _run(cmd: list[str]) -> dict:
|
||||||
actual_cmd = list(cmd)
|
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
|
||||||
has_sudo = False
|
|
||||||
|
|
||||||
|
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
|
||||||
|
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
|
||||||
|
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
|
||||||
|
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
|
||||||
|
ganze Pfad war also totes Risiko ohne Gegenwert.
|
||||||
|
|
||||||
|
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
|
||||||
|
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
|
||||||
|
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
|
||||||
|
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
|
||||||
|
actual_cmd = list(cmd)
|
||||||
if cmd and cmd[0] == "sudo":
|
if cmd and cmd[0] == "sudo":
|
||||||
has_sudo = True
|
if "-S" in actual_cmd:
|
||||||
# If we have a password, use -S instead of -n
|
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
||||||
if sudo_password is not None:
|
if "-n" not in actual_cmd:
|
||||||
if "-n" in actual_cmd:
|
actual_cmd.insert(1, "-n")
|
||||||
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:
|
try:
|
||||||
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
|
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
|
||||||
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
|
|
||||||
|
|
||||||
err_msg = p.stderr or ""
|
err_msg = p.stderr or ""
|
||||||
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
||||||
if sudo_password is not None:
|
return {"ok": False, "status": "password_required", "out": p.stdout or "",
|
||||||
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
|
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
|
||||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
|
||||||
|
|
||||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "out": "", "err": str(exc)}
|
return {"ok": False, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
|
def check_sudo_needs_password() -> dict | None:
|
||||||
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
|
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
|
||||||
res = _run(["sudo", "true"], sudo_password=sudo_password)
|
res = _run(["sudo", "true"])
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
return res
|
return res
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def restart_service(name: str, sudo_password: str | None = None) -> dict:
|
def restart_service(name: str) -> dict:
|
||||||
if name in SYSTEM_SERVICES:
|
if name in SYSTEM_SERVICES:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
|
return _run(["sudo", "systemctl", "restart", name])
|
||||||
if name in USER_SERVICES:
|
if name in USER_SERVICES:
|
||||||
return _run(["systemctl", "--user", "restart", name])
|
return _run(["systemctl", "--user", "restart", name])
|
||||||
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
||||||
|
|
||||||
|
|
||||||
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
lines = max(1, min(lines, 1000))
|
lines = max(1, min(lines, 1000))
|
||||||
if service in USER_SERVICES:
|
if service in USER_SERVICES:
|
||||||
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
@@ -609,15 +609,14 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
|
|||||||
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
if r["ok"]:
|
if r["ok"]:
|
||||||
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
|
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||||
sudo_password=sudo_password)
|
|
||||||
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||||
|
|
||||||
def check_updates_job(sudo_password: str | None = None) -> dict:
|
def check_updates_job() -> dict:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
|
|
||||||
def on_done():
|
def on_done():
|
||||||
@@ -625,7 +624,7 @@ def check_updates_job(sudo_password: str | None = None) -> dict:
|
|||||||
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
||||||
|
|
||||||
cmd = "sudo apt-get update"
|
cmd = "sudo apt-get update"
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
|
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -637,10 +636,10 @@ def _maintenance_busy() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def os_update_job(sudo_password: str | None = None) -> dict:
|
def os_update_job() -> dict:
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
return busy
|
return busy
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
||||||
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
||||||
@@ -649,11 +648,11 @@ def os_update_job(sudo_password: str | None = None) -> dict:
|
|||||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||||
f"&& bash {STACK_POSTCHECK}")
|
f"&& bash {STACK_POSTCHECK}")
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
||||||
group="maintenance", sudo_password=sudo_password)
|
group="maintenance")
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
def engine_update_job() -> dict | None:
|
||||||
if not ENGINE_UPDATE_CMD:
|
if not ENGINE_UPDATE_CMD:
|
||||||
return None
|
return None
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
@@ -674,7 +673,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
|||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
def swap_update_job() -> dict | None:
|
||||||
if not SWAP_UPDATE_CMD:
|
if not SWAP_UPDATE_CMD:
|
||||||
return None
|
return None
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
@@ -746,12 +745,12 @@ def hermes_update_job() -> dict:
|
|||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
def update_all_job(sudo_password: str | None = None) -> dict:
|
def update_all_job() -> dict:
|
||||||
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
||||||
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
||||||
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
||||||
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
||||||
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
|
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
|
||||||
if busy := _maintenance_busy():
|
if busy := _maintenance_busy():
|
||||||
return busy
|
return busy
|
||||||
upd = updates()
|
upd = updates()
|
||||||
@@ -764,9 +763,10 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
|||||||
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
||||||
os_pending = (upd.get("os") or 0) > 0
|
os_pending = (upd.get("os") or 0) > 0
|
||||||
if os_pending:
|
if os_pending:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
|
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
|
||||||
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
|
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
|
||||||
|
# den Fehler ehrlich durchreichen.
|
||||||
if not parts:
|
if not parts:
|
||||||
return err
|
return err
|
||||||
os_pending = False
|
os_pending = False
|
||||||
@@ -791,12 +791,11 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
|||||||
|
|
||||||
labels = " → ".join(label for label, _ in parts)
|
labels = " → ".join(label for label, _ in parts)
|
||||||
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||||
group="maintenance", on_done=on_done,
|
group="maintenance", on_done=on_done)
|
||||||
sudo_password=sudo_password)
|
|
||||||
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
||||||
|
|
||||||
|
|
||||||
def reboot(sudo_password: str | None = None) -> dict:
|
def reboot() -> dict:
|
||||||
if err := check_sudo_needs_password(sudo_password):
|
if err := check_sudo_needs_password():
|
||||||
return err
|
return err
|
||||||
return _run(["sudo", "reboot"], sudo_password=sudo_password)
|
return _run(["sudo", "reboot"])
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
from config import HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||||
|
|
||||||
from services import announce, llamaswap
|
from services import announce, llamaswap
|
||||||
|
|
||||||
@@ -34,14 +34,26 @@ REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung
|
|||||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
||||||
|
|
||||||
|
|
||||||
def _reach(url: str, path: str = "/health") -> bool:
|
def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
|
||||||
|
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
|
||||||
|
|
||||||
|
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
|
||||||
|
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
|
||||||
|
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
|
||||||
|
und er haette einen echten Auth-Fehler unter sich begraben."""
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=5.0) as c:
|
with httpx.Client(timeout=5.0) as c:
|
||||||
return c.get(f"{url}{path}").status_code < 500
|
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_kopf() -> dict[str, str]:
|
||||||
|
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
|
||||||
|
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||||
|
|
||||||
|
|
||||||
def _check_engine() -> bool:
|
def _check_engine() -> bool:
|
||||||
return llamaswap.engine_reachable()
|
return llamaswap.engine_reachable()
|
||||||
|
|
||||||
@@ -70,7 +82,7 @@ CHECKS: dict[str, tuple] = {
|
|||||||
"brain": (_check_brain,
|
"brain": (_check_brain,
|
||||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
"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."),
|
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
||||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"),
|
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()),
|
||||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||||||
"Der Agent-Dienst läuft wieder."),
|
"Der Agent-Dienst läuft wieder."),
|
||||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import glob
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
@@ -197,5 +198,59 @@ def system_status() -> dict:
|
|||||||
"gpu": _gpu_sysfs(),
|
"gpu": _gpu_sysfs(),
|
||||||
"temp": _temps(),
|
"temp": _temps(),
|
||||||
"disk": disk,
|
"disk": disk,
|
||||||
|
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
|
||||||
|
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
|
||||||
|
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
|
||||||
|
"uptime_s": _uptime_s(),
|
||||||
"versions": check_versions_cached(),
|
"versions": check_versions_cached(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def metrik_punkt() -> dict:
|
||||||
|
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
|
||||||
|
|
||||||
|
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
|
||||||
|
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
|
||||||
|
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
|
||||||
|
|
||||||
|
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
|
||||||
|
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
|
||||||
|
|
||||||
|
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
|
||||||
|
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
|
||||||
|
verfaelscht nichts."""
|
||||||
|
vm = psutil.virtual_memory()
|
||||||
|
temp = _temps() or {}
|
||||||
|
gpu = _gpu_sysfs() or {}
|
||||||
|
try:
|
||||||
|
from services.token_stats import get_stats
|
||||||
|
tok = get_stats()
|
||||||
|
except Exception:
|
||||||
|
tok = {}
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||||
|
disk = du.percent
|
||||||
|
except Exception:
|
||||||
|
disk = None
|
||||||
|
return {
|
||||||
|
"cpu": psutil.cpu_percent(interval=None),
|
||||||
|
"ram": vm.percent,
|
||||||
|
"ram_used": vm.used,
|
||||||
|
"ram_total": vm.total,
|
||||||
|
"gpu": gpu.get("busy_percent"),
|
||||||
|
"disk": disk,
|
||||||
|
"temp_cpu": temp.get("cpu"),
|
||||||
|
"temp_gpu": temp.get("gpu"),
|
||||||
|
"uptime_s": _uptime_s(),
|
||||||
|
"tok_p": tok.get("prompt_tokens", 0),
|
||||||
|
"tok_c": tok.get("completion_tokens", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _uptime_s() -> int | None:
|
||||||
|
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
|
||||||
|
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
|
||||||
|
try:
|
||||||
|
return int(time.time() - psutil.boot_time())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ jobs:
|
|||||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||||
|
|
||||||
|
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
|
||||||
|
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
|
||||||
|
# reicht). Gemessen wird der frische Build, nicht das committete dist.
|
||||||
|
if [ -d "$d/dist/assets" ]; then
|
||||||
|
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
|
||||||
|
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
|
||||||
|
# falschen — das Budget waere still immer gruen.
|
||||||
|
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
|
||||||
|
haupt="$d/$einstieg"
|
||||||
|
if [ -n "$haupt" ]; then
|
||||||
|
gz=$(gzip -c "$haupt" | wc -c)
|
||||||
|
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
|
||||||
|
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
done <<< "$pkg_dateien"
|
done <<< "$pkg_dateien"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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.)"
|
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 ─────────────────────────────────────────────────────────────────────
|
# ── Lauf ─────────────────────────────────────────────────────────────────────
|
||||||
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
|
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
|
||||||
pins_init
|
pins_init
|
||||||
@@ -180,3 +237,7 @@ check_hermes
|
|||||||
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
$(printf '• %s\n' "${SUMMARY[@]}")"
|
$(printf '• %s\n' "${SUMMARY[@]}")"
|
||||||
say "Fertig."
|
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
|
||||||
|
|||||||
+66
-54
@@ -1,64 +1,76 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "Starte MC2 Deployment..."
|
# ‼️ Das ganze Skript liegt in EINER Funktion — das ist Absicht, kein Stil.
|
||||||
|
# Bash liest ein Skript haeppchenweise WAEHREND es laeuft. `git pull` unten ersetzt
|
||||||
|
# genau diese Datei mitten im Lauf; danach liest bash am selben Byte-Offset im NEUEN
|
||||||
|
# Text weiter und fuehrt zerschnittene Zeilen aus. Live erlebt am 27.08.2026: der Pull
|
||||||
|
# brachte eine neue Fassung dieses Schrittes mit, ausgefuehrt wurde trotzdem die alte.
|
||||||
|
# Eine Funktion wird komplett geparst, bevor sie startet -> der Lauf bleibt in sich
|
||||||
|
# stimmig, und Aenderungen greifen ab dem naechsten Aufruf statt mittendrin.
|
||||||
|
main() {
|
||||||
|
|
||||||
# 1. Neuesten Code holen
|
echo "Starte MC2 Deployment..."
|
||||||
git pull origin main
|
|
||||||
|
# 1. Neuesten Code holen
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
|
||||||
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
||||||
echo "Aktualisiere Python Abhängigkeiten..."
|
echo "Aktualisiere Python Abhängigkeiten..."
|
||||||
backend/.venv/bin/pip install -r backend/requirements.txt
|
backend/.venv/bin/pip install -r backend/requirements.txt
|
||||||
|
|
||||||
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
||||||
#
|
#
|
||||||
# ‼️ ACHTUNG (27.08.2026): /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit — MC2
|
# ‼️ 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
|
# 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
|
# 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 (aktuell fehlt ihm z. B. der
|
# ist nur ein Abzug und hinkt regelmäßig hinterher (erlebt: der Coder-Vollkontext aus 34a9862
|
||||||
# `kritiker`/Devstral-Eintrag, den STACK.md und der Modell-Katalog voraussetzen).
|
# 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
|
# 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
|
# 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.
|
# 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 ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
|
||||||
if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
|
if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
|
||||||
echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
|
echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
|
||||||
else
|
else
|
||||||
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
|
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
|
||||||
echo "Llama-Swap Konfiguration hat sich geändert."
|
echo "Llama-Swap Konfiguration hat sich geändert."
|
||||||
echo " Abweichung (live -> Repo-Abzug):"
|
echo " Abweichung (live -> Repo-Abzug):"
|
||||||
diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
|
diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
|
||||||
echo " Sichere die laufende Datei nach $SWAP_BAK"
|
echo " Sichere die laufende Datei nach $SWAP_BAK"
|
||||||
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
||||||
sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
|
sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
|
||||||
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
||||||
sudo systemctl restart llama-swap
|
sudo systemctl restart llama-swap
|
||||||
echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
|
echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
# 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
|
fi
|
||||||
done
|
fi
|
||||||
|
|
||||||
# 5. Dienste neu starten
|
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||||
# ‼️ mc2-steward gehoert hier dazu: er laeuft aus DEMSELBEN Checkout (backend/steward.py).
|
systemctl --user daemon-reload
|
||||||
# Ohne ihn lief der Waechter-Prozess nach jedem Deploy mit dem alten Code weiter — der
|
|
||||||
# Neustart wurde 27.08.2026 nachgetragen. `--user restart` auf einer nicht installierten
|
|
||||||
# Unit ist folgenlos, darum ohne Vorabpruefung.
|
|
||||||
echo "Starte Backend neu..."
|
|
||||||
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
|
|
||||||
|
|
||||||
echo "✅ Deployment erfolgreich abgeschlossen!"
|
# 4.5 Hermes Skills synchronisieren
|
||||||
|
echo "Synchronisiere Hermes Skills..."
|
||||||
|
mkdir -p ~/.hermes/skills
|
||||||
|
# Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
|
||||||
|
for skill_dir in deploy/skills/*; do
|
||||||
|
if [ -d "$skill_dir" ]; then
|
||||||
|
skill_name=$(basename "$skill_dir" | tr '-' '_')
|
||||||
|
mkdir -p ~/.hermes/skills/"$skill_name"
|
||||||
|
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5. Dienste neu starten
|
||||||
|
# ‼️ mc2-steward gehoert hier dazu: er laeuft aus DEMSELBEN Checkout (backend/steward.py).
|
||||||
|
# Ohne ihn lief der Waechter-Prozess nach jedem Deploy mit dem alten Code weiter — der
|
||||||
|
# Neustart wurde 27.08.2026 nachgetragen. `--user restart` auf einer nicht installierten
|
||||||
|
# Unit ist folgenlos, darum ohne Vorabpruefung.
|
||||||
|
echo "Starte Backend neu..."
|
||||||
|
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
|
||||||
|
|
||||||
|
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ if [ -f "$PATCH_APPLY" ]; then
|
|||||||
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
|
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
|
# 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
|
fi
|
||||||
|
|
||||||
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
|
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ for t in $(systemctl --user list-timers --no-legend --no-pager 2>/dev/null | awk
|
|||||||
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
|
||||||
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
|
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
|
||||||
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
|
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
|
||||||
if [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
# Nach einem Neustart ist der "letzte Lauf" leer, obwohl nichts kaputt ist —
|
||||||
|
# am 04.09. machte der Radar daraus "hohes Risiko, sofort in Betrieb nehmen".
|
||||||
|
# Junge Box (< 1 Tag) + Timer hat einen naechsten Termin = wartet, kein Befund.
|
||||||
|
naechster=$(systemctl --user show -p NextElapseUSecRealtime --value "${dienst%.service}.timer" 2>/dev/null | cut -d' ' -f2,3)
|
||||||
|
if [ -z "$wann" ] && [ "$(cut -d. -f1 /proc/uptime)" -lt 86400 ] && [ -n "$naechster" ]; then
|
||||||
|
ok "$dienst wartet auf den ersten Lauf seit dem Neustart (naechster: $naechster)"
|
||||||
|
elif [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
|
||||||
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
|
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
|
||||||
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ if [ -z "$IST" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Der Blick nach DRAUSSEN (04.09.2026): Releases, PRs, neue Entwurfsmodelle —
|
||||||
|
# als Skript-Fakten, gegen den letzten Lauf verglichen. Ohne das lag DFlash2 fuer
|
||||||
|
# den Coder zwei Wochen ungenutzt auf der Platte. Faellt es aus, bleibt der
|
||||||
|
# Innen-Bericht vollstaendig; das Ausbleiben steht dann selbst als ACHTUNG drin.
|
||||||
|
UPSTREAM_SKRIPT="${UPSTREAM_SKRIPT:-$HIER/stack-upstream.py}"
|
||||||
|
if [ -f "$UPSTREAM_SKRIPT" ]; then
|
||||||
|
UPSTREAM="$(timeout 240 python3 "$UPSTREAM_SKRIPT" 2>&1)" \
|
||||||
|
|| UPSTREAM="UPSTREAM (Watchlist)
|
||||||
|
ACHTUNG Upstream-Pruefung abgebrochen (exit $?) — $UPSTREAM_SKRIPT nachsehen"
|
||||||
|
# Nur Aenderungen ans Modell. Beim ersten Lauf (04.09.) bekam es auch die
|
||||||
|
# "unveraendert"-Zeilen und erfand zu jeder einen "naechsten Schritt" — der
|
||||||
|
# Prompt-Hinweis half nicht. Fakten filtert das Skript, nicht die Prosa.
|
||||||
|
ANZ_QUELLEN="$(printf '%s\n' "$UPSTREAM" | grep -cE '^\s+(OK|ACHTUNG)' || true)"
|
||||||
|
UPSTREAM_NEU="$(printf '%s\n' "$UPSTREAM" | grep -A1 -E '^\s+ACHTUNG' | grep -vE '^--$' || true)"
|
||||||
|
[ -z "$UPSTREAM_NEU" ] && UPSTREAM_NEU=" OK nichts Neues draussen ($ANZ_QUELLEN Quellen geprueft, alle unveraendert)"
|
||||||
|
IST="$IST
|
||||||
|
|
||||||
|
UPSTREAM (was hat sich draussen getan?)
|
||||||
|
$UPSTREAM_NEU"
|
||||||
|
fi
|
||||||
|
|
||||||
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
|
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
|
||||||
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
|
||||||
|
|
||||||
@@ -61,6 +82,9 @@ Regeln:
|
|||||||
Fuellmaterial wird nicht gelesen.
|
Fuellmaterial wird nicht gelesen.
|
||||||
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
|
||||||
nicht unter Verbesserungen.
|
nicht unter Verbesserungen.
|
||||||
|
- Zeilen mit NEU im Abschnitt UPSTREAM sind Neuigkeiten von draussen. Nenne
|
||||||
|
jede kurz und gib den Hinweis dahinter (der Pfeil-Satz) als naechsten Schritt
|
||||||
|
wieder. 'erstmals erfasst' und 'unveraendert' sind KEINE Neuigkeiten.
|
||||||
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
|
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
|
||||||
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""stack-upstream.py — der Blick nach draussen, in Fakten (04.09.2026).
|
||||||
|
|
||||||
|
Ergaenzt stack-ist.sh: das prueft, ob die Box STIMMT. Dieses Skript prueft, ob
|
||||||
|
DRAUSSEN etwas passiert ist, das uns betrifft — neue Releases, gemergte PRs,
|
||||||
|
neue Entwurfsmodelle, neue Modelle der Familien, die wir fahren.
|
||||||
|
|
||||||
|
Warum es das braucht: Am 19.08. lagen die DFlash2-Entwuerfe fuer den Coder auf
|
||||||
|
der Platte, am 27.08. konnte llama.cpp sie, und bis zum 04.09. hat es niemand
|
||||||
|
gemerkt — der Coder lief zwei Wochen mit 12,6 statt 31 t/s. Der KISS-Radar sah
|
||||||
|
nur nach innen. Jetzt sieht er auch nach draussen, aber ohne Modell-Fantasie:
|
||||||
|
jede Zeile hier ist ein abgerufener Wert, verglichen mit dem Wert vom letzten Lauf.
|
||||||
|
|
||||||
|
Quelle: deploy/trend-radar-watchlist.json (Eintraege mit `typ`, siehe unten).
|
||||||
|
Zustand: ~/.hermes/state/stack-upstream.json (Wert je Eintrag vom letzten Lauf).
|
||||||
|
Ausgabe: im Format von stack-ist.sh — `OK` / `ACHTUNG NEU:` / `ACHTUNG ... nicht abrufbar`.
|
||||||
|
Beim ERSTEN Lauf wird nur erfasst (OK), nicht gemeldet — sonst waere jeder
|
||||||
|
Eintrag sofort "neu".
|
||||||
|
|
||||||
|
Typen:
|
||||||
|
github_issue repo, nummer → offen/geschlossen
|
||||||
|
github_pr repo, nummer → offen/gemergt/geschlossen
|
||||||
|
github_release repo → juengster Release-Tag
|
||||||
|
github_release_major repo → nur Major-Sprung meldet
|
||||||
|
github_branch repo, branch → juengster Commit (Datum)
|
||||||
|
pypi paket → Version
|
||||||
|
hf_author author → neue Modell-Repos des Autors
|
||||||
|
url_zeilen url, muster, max_zeilen → Zeilen mit Muster, Aenderung = neu
|
||||||
|
|
||||||
|
Kein set -e-Aequivalent: jeder Eintrag wird einzeln behandelt, ein Fehler
|
||||||
|
nimmt die anderen nicht mit.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WATCHLIST = Path(sys.argv[1] if len(sys.argv) > 1 else
|
||||||
|
os.path.expanduser("~/mission-control-v2/deploy/trend-radar-watchlist.json"))
|
||||||
|
STATE = Path(os.environ.get("STACK_UPSTREAM_STATE",
|
||||||
|
os.path.expanduser("~/.hermes/state/stack-upstream.json")))
|
||||||
|
TIMEOUT = 20
|
||||||
|
UA = "mc2-stack-upstream/1.0 (+https://github.com/Hitonabi)"
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
print(f" OK {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def neu(msg: str) -> None:
|
||||||
|
print(f" ACHTUNG NEU: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
print(f" ACHTUNG {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def hole(url: str, json_erwartet: bool = True):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
|
||||||
|
tok = os.environ.get("GITHUB_TOKEN")
|
||||||
|
if tok and "api.github.com" in url:
|
||||||
|
req.add_header("Authorization", f"Bearer {tok}")
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||||
|
roh = r.read().decode("utf-8", "replace")
|
||||||
|
return json.loads(roh) if json_erwartet else roh
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Pruefer ---
|
||||||
|
# Jeder Pruefer liefert (wert, anzeige). `wert` wird mit dem letzten Lauf
|
||||||
|
# verglichen, `anzeige` ist die menschenlesbare Fassung.
|
||||||
|
|
||||||
|
def p_github_issue(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/issues/{e['nummer']}")
|
||||||
|
st = "geschlossen" if d.get("state") == "closed" else "offen"
|
||||||
|
return st, f"#{e['nummer']} {e['repo']} ist {st} — {d.get('title', '')[:70]}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_pr(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/pulls/{e['nummer']}")
|
||||||
|
if d.get("merged_at"):
|
||||||
|
st = f"gemergt am {d['merged_at'][:10]}"
|
||||||
|
elif d.get("state") == "closed":
|
||||||
|
st = "geschlossen ohne Merge"
|
||||||
|
else:
|
||||||
|
st = "offen" + (" (Entwurf)" if d.get("draft") else "")
|
||||||
|
return st, f"PR #{e['nummer']} {e['repo']} {st} — {d.get('title', '')[:70]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _release(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/releases/latest")
|
||||||
|
return d.get("tag_name", "?"), (d.get("published_at") or "")[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_release(e):
|
||||||
|
tag, wann = _release(e)
|
||||||
|
return tag, f"{e['repo']} juengster Release {tag} ({wann})"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_release_major(e):
|
||||||
|
tag, wann = _release(e)
|
||||||
|
m = re.search(r"(\d+)", tag)
|
||||||
|
major = m.group(1) if m else tag
|
||||||
|
return major, f"{e['repo']} Major {major} (Release {tag}, {wann})"
|
||||||
|
|
||||||
|
|
||||||
|
def p_github_branch(e):
|
||||||
|
d = hole(f"https://api.github.com/repos/{e['repo']}/commits?sha={e['branch']}&per_page=1")
|
||||||
|
c = d[0]
|
||||||
|
wann = c["commit"]["committer"]["date"][:10]
|
||||||
|
return c["sha"][:10], f"{e['repo']}@{e['branch']} letzter Commit {wann} — {c['commit']['message'].splitlines()[0][:60]}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_pypi(e):
|
||||||
|
d = hole(f"https://pypi.org/pypi/{e['paket']}/json")
|
||||||
|
v = d["info"]["version"]
|
||||||
|
return v, f"PyPI {e['paket']} = {v}"
|
||||||
|
|
||||||
|
|
||||||
|
def p_hf_author(e):
|
||||||
|
d = hole(f"https://huggingface.co/api/models?author={e['author']}&sort=lastModified&direction=-1&limit={e.get('limit', 15)}")
|
||||||
|
ids = sorted(m["id"] for m in d)
|
||||||
|
return "\n".join(ids), f"{len(ids)} juengste Repos von {e['author']} auf Hugging Face"
|
||||||
|
|
||||||
|
|
||||||
|
def p_url_zeilen(e):
|
||||||
|
roh = hole(e["url"], json_erwartet=False)
|
||||||
|
zeilen = [z.strip() for z in roh.splitlines() if e["muster"] in z][: int(e.get("max_zeilen", 30))]
|
||||||
|
h = hashlib.sha1("\n".join(zeilen).encode()).hexdigest()[:10]
|
||||||
|
return h, f"{len(zeilen)} Zeilen mit '{e['muster']}' in {e['url'].split('/')[2]} (Stand {h})"
|
||||||
|
|
||||||
|
|
||||||
|
PRUEFER = {
|
||||||
|
"github_issue": p_github_issue,
|
||||||
|
"github_pr": p_github_pr,
|
||||||
|
"github_release": p_github_release,
|
||||||
|
"github_release_major": p_github_release_major,
|
||||||
|
"github_branch": p_github_branch,
|
||||||
|
"pypi": p_pypi,
|
||||||
|
"hf_author": p_hf_author,
|
||||||
|
"url_zeilen": p_url_zeilen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("UPSTREAM (Watchlist — was hat sich draussen getan?)")
|
||||||
|
try:
|
||||||
|
eintraege = json.loads(WATCHLIST.read_text(encoding="utf-8")).get("eintraege", [])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"Watchlist {WATCHLIST} nicht lesbar: {exc}")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
for e in eintraege:
|
||||||
|
key, typ = e.get("key", "?"), e.get("typ", "?")
|
||||||
|
pruefer = PRUEFER.get(typ)
|
||||||
|
if not pruefer:
|
||||||
|
warn(f"{key}: unbekannter Typ '{typ}' — Eintrag wird ignoriert")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wert, anzeige = pruefer(e)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
warn(f"{key}: nicht abrufbar (HTTP {exc.code})")
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"{key}: nicht abrufbar ({type(exc).__name__}: {str(exc)[:60]})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
alt = state.get(key, {}).get("wert")
|
||||||
|
if alt is None:
|
||||||
|
ok(f"{key}: erstmals erfasst — {anzeige}")
|
||||||
|
elif alt == wert:
|
||||||
|
ok(f"{key}: unveraendert — {anzeige}")
|
||||||
|
else:
|
||||||
|
if typ == "hf_author":
|
||||||
|
neue = sorted(set(wert.split("\n")) - set(alt.split("\n")))
|
||||||
|
anzeige = f"neue Repos von {e['author']}: " + ", ".join(neue[:8]) if neue else anzeige
|
||||||
|
hinweis = e.get("warum", "")
|
||||||
|
neu(f"{key}: {anzeige}" + (f"\n → {hinweis[:220]}" if hinweis else ""))
|
||||||
|
state[key] = {"wert": wert, "anzeige": anzeige}
|
||||||
|
|
||||||
|
try:
|
||||||
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, STATE)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
warn(f"Zustand nicht speicherbar ({exc}) — naechster Lauf meldet alles erneut")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -25,11 +25,14 @@ models:
|
|||||||
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
|
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
|
||||||
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
|
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
|
||||||
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
||||||
|
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
||||||
|
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0
|
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
aliases:
|
aliases:
|
||||||
- coder
|
- coder
|
||||||
|
- heavy
|
||||||
capabilities:
|
capabilities:
|
||||||
in: [text, image]
|
in: [text, image]
|
||||||
out: [text]
|
out: [text]
|
||||||
@@ -62,8 +65,8 @@ models:
|
|||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
||||||
- heavy
|
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
||||||
capabilities:
|
capabilities:
|
||||||
in: [text]
|
in: [text]
|
||||||
out: [text]
|
out: [text]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heavy": {
|
"heavy": {
|
||||||
"name": "heavy - Planen / Review",
|
"name": "heavy - Planen / Review (= Qwen3.8-27B, seit 04.09. mit DFlash2)",
|
||||||
"tool_call": true,
|
"tool_call": true,
|
||||||
"limit": {
|
"limit": {
|
||||||
"context": 32768,
|
"context": 32768,
|
||||||
|
|||||||
@@ -1,51 +1,73 @@
|
|||||||
{
|
{
|
||||||
"_hinweis": "Living Watchlist des Trend-Radars. Diese Datei MUSS autonom vom Trend-Radar Agenten umgeschrieben werden, wenn er neue Quellen findet oder alte obsolet werden.",
|
"_hinweis": "Watchlist des Stack-Radars. Wird von deploy/jobs/stack-upstream.py (Skript, kein Modell) samstags abgefragt; jeder Eintrag = ein abrufbarer Wert, verglichen mit dem letzten Lauf. Typen: github_issue, github_pr, github_release, github_release_major, github_branch, pypi, hf_author, url_zeilen. `warum` wird bei NEU mitgemeldet — dort steht, was dann zu tun ist.",
|
||||||
"eintraege": [
|
"eintraege": [
|
||||||
{
|
{
|
||||||
"key": "mmq-prefill-gfx1151",
|
"key": "qwen-modelle",
|
||||||
"typ": "github_issue",
|
"typ": "hf_author",
|
||||||
|
"author": "Qwen",
|
||||||
|
"limit": 20,
|
||||||
|
"warum": "Lucys Hirn ist Qwen3.6-35B-A3B, der Coder Qwen3.8-27B. Ein neues A3B-Modell waere der Hirn-Kandidat (dichte Modelle nie, Verdikt 17.07.); ein neues 27B/30B der Coder-Kandidat. Immer erst auf der Box messen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "zlab-drafts",
|
||||||
|
"typ": "hf_author",
|
||||||
|
"author": "z-lab",
|
||||||
|
"limit": 20,
|
||||||
|
"warum": "z-lab baut die DFlash-Entwurfsmodelle. DFlash2 brachte dem Coder 12,6 -> 31 t/s (04.09.). Ein DFlash2 fuer Qwen3.6-35B-A3B waere derselbe Hebel fuers Hirn — DFlash v1 faellt bei 32k Tiefe auf 1,02x."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "flash-next-mtp",
|
||||||
|
"typ": "github_pr",
|
||||||
"repo": "ggml-org/llama.cpp",
|
"repo": "ggml-org/llama.cpp",
|
||||||
"nummer": 21284,
|
"nummer": 28243,
|
||||||
"bedingung": "Issue geschlossen = MMQ-Tuning für gfx1151 vermutlich gemerged",
|
"warum": "MTP fuer Qwen3.8-Flash-Next (125B-A6B). Erst mit Merge + Vulkan-Zahlen lohnt ein Blick — und nur, wenn eine Quantisierung neben das Warm-Set passt (Regel: Warm-Set + Modell <= 115 GB; UD-IQ4_XS = 94 GB passt NICHT)."
|
||||||
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ) und +20 % auf 122B. Wenn gemerged: ROCm-Langkontext-Bench für coder fällig (Vulkan-Verdikt bleibt bis zur Messung)."
|
},
|
||||||
|
{
|
||||||
|
"key": "hrx-backend",
|
||||||
|
"typ": "github_branch",
|
||||||
|
"repo": "AMD-Ecosystem/llama.cpp",
|
||||||
|
"branch": "hrx-graph-develop-v2",
|
||||||
|
"warum": "AMDs HRX-Runtime (Lemonade 11.9): gegen HIP +21-25 % Decode, +20-123 % Prefill, also etwa Vulkan-Niveau beim Decode und vorn beim Prefill. Stand 04.09. nur Qwen3-30B-A3B/Llama-3/Gemma-3. Interessant, sobald Qwen3.6/3.8 und Spec-Decoding laufen — dann Box-Bench gegen Vulkan."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rocm-releases",
|
"key": "rocm-releases",
|
||||||
"typ": "github_release",
|
"typ": "github_release",
|
||||||
"repo": "ROCm/ROCm",
|
"repo": "ROCm/ROCm",
|
||||||
"bedingung": "Neuer ROCm-Release seit letzter Meldung",
|
"warum": "ROCm 10.0 (27.08.) hat das Vulkan-Verdikt nicht gekippt: HIP gewinnt Prefill, Vulkan Decode. Bei neuem Release: Strix-Halo-Community-Grid gegenlesen, erst bei tg-Trendwende Box-Bench (Lychee-Technology liefert fertige ROCm-Builds)."
|
||||||
"warum": "Seit 7.14 ist TheRock der Produktions-Stack und AMD optimiert offiziell für die Halo-Familie — ROCm könnte Vulkan bei der Token-Erzeugung einholen. Bei neuem Release: Community-Grid-Zeilen unten gegenlesen."
|
},
|
||||||
|
{
|
||||||
|
"key": "mmq-prefill-gfx1151",
|
||||||
|
"typ": "github_issue",
|
||||||
|
"repo": "ggml-org/llama.cpp",
|
||||||
|
"nummer": 21284,
|
||||||
|
"warum": "Getunte MMQ-Defaults brachten +60 % ROCm-Prefill auf 35B-MoE (unser Hirn-Typ). Wenn geschlossen: ROCm-Langkontext-Bench fuer coder faellig (Vulkan-Verdikt bleibt bis zur Messung)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "strix-halo-grid",
|
||||||
|
"typ": "url_zeilen",
|
||||||
|
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
||||||
|
"muster": "t/s",
|
||||||
|
"max_zeilen": 30,
|
||||||
|
"warum": "Der ehrlichste externe Messpunkt auf unserer Hardware. Bei Aenderung: auf Vulkan<->ROCm-Trendwende und neue Spec-Decoding-Verfahren achten."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "electron-major",
|
"key": "electron-major",
|
||||||
"typ": "github_release_major",
|
"typ": "github_release_major",
|
||||||
"repo": "electron/electron",
|
"repo": "electron/electron",
|
||||||
"bedingung": "Neue MAJOR-Version seit letzter Meldung",
|
"warum": "Lucys Desktop-Shell am PC. Versionsstand IMMER live pruefen (lucy-desktop/package.json), nie dieser Notiz glauben. Neue Major = Chromium-Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit."
|
||||||
"warum": "Lucys Desktop-Shell am PC. Versionsstand NIE dieser Notiz glauben — IMMER live pruefen: lucy-desktop/package.json + node_modules/electron (22.07.: 43.2.0 = aktueller Stable; der fruehere '33'-Stand hier war veraltet und produzierte einen Fehlalarm im Rundumschlag). Neue Major = Chromium-Jahrgang Sicherheitspatches; Update = npm install + npm run dist am PC, bleibt Handarbeit; der Radar erinnert nur."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "pocket-tts",
|
"key": "pocket-tts",
|
||||||
"typ": "pypi",
|
"typ": "pypi",
|
||||||
"paket": "pocket-tts",
|
"paket": "pocket-tts",
|
||||||
"bedingung": "Neue Version auf PyPI",
|
"warum": "Lucys Stimme am PC (2.1.0, german_24l). Neue Versionen koennen Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
||||||
"warum": "Lucys Stimme am PC (installiert 2.1.0, german_24l) — neue Versionen können Stimm-/Pausen-Fixes bringen; Update bleibt Handarbeit am PC (pocket = DIE Stimme, Verdikt 16.07.)."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "lmstudio-bionic",
|
"key": "lmstudio-bionic",
|
||||||
"typ": "github_issue",
|
"typ": "github_issue",
|
||||||
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
"repo": "lmstudio-ai/lmstudio-bug-tracker",
|
||||||
"nummer": 2185,
|
"nummer": 2185,
|
||||||
"bedingung": "Issue geschlossen = Bionic-Linux-Port vermutlich da",
|
"warum": "LM Studio Bionic: erst interessant bei Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI. Bis dahin bleibt die Box :9001 draussen."
|
||||||
"warum": "LM Studio Bionic (eigenstaendige Agent-App, Preview seit 16.07.26, Win+Mac, gratis, closed source). Stand 22.07.: KEINE eigenen OpenAI-Endpunkte anbindbar (Box :9001 bleibt draussen — nur In-App-Modelle, LM Link mit Konto+Tailscale-Mesh oder Secure Cloud), keine CLI/API/Headless. Fuer den Stack erst interessant bei: Linux-Port (dieses Issue), Custom-Endpoint-Support oder CLI — dazu Changelog lmstudio.ai/changelog gegenlesen; llmster auf der Box waere der LM-Link-Rechenknoten-Weg (seit 0.4.20), dupliziert aber die eigene Engine."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "llamacpp-rocm-diskussion",
|
|
||||||
"typ": "url_zeilen",
|
|
||||||
"url": "https://raw.githubusercontent.com/hogeheer499-commits/strix-halo-guide/main/README.md",
|
|
||||||
"muster": "t/s",
|
|
||||||
"max_zeilen": 30,
|
|
||||||
"bedingung": "Community-Messwerte (Vulkan vs. ROCm, MTP, neue Beschleuniger) auf unserer Hardware",
|
|
||||||
"warum": "Der Strix-Halo-Community-Grid ist der ehrlichste externe Messpunkt. Auf tg-Trendwende Vulkan↔ROCm und neue Spec-Decoding-Verfahren achten."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-16
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
|
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
|
||||||
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
|
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
|
||||||
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme,
|
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, Voice/Klonstimme,
|
||||||
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
|
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
|
||||||
> **1:1 zurück** · **neu/Default** · **weglassen**.
|
> **1:1 zurück** · **neu/Default** · **weglassen**.
|
||||||
>
|
>
|
||||||
@@ -17,8 +17,8 @@ Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
|
|||||||
|
|
||||||
| Modus | Bedeutung |
|
| Modus | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). |
|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme). |
|
||||||
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
|
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil). |
|
||||||
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
|
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
|
||||||
|
|
||||||
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
|
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
|
||||||
@@ -73,18 +73,15 @@ Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Instal
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
|
||||||
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
|
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
|
||||||
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a |
|
| Frontend (gebaut) | `frontend/dist` | 🌐 Git | n/a |
|
||||||
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
|
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
|
||||||
|
|
||||||
### D · 3D-Avatar (Sprechen-Tab)
|
### D · 3D-Avatar — **ausgebaut (28.08.2026)**
|
||||||
| Komponente | Ort | Quelle | Im Backup? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
|
|
||||||
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a |
|
|
||||||
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** |
|
|
||||||
|
|
||||||
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig
|
MC2 hat keinen Avatar mehr. `frontend/public/avatar.vrm` (24,5 MB) lag im Auslieferungsordner,
|
||||||
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
|
wurde aber von keiner Zeile des Frontends referenziert — der Renderer `Avatar3D.tsx` war schon
|
||||||
|
vorher verschwunden. Beides ist beim v3-Umbau (Etappe P0) entfernt worden; damit fällt auch die
|
||||||
|
`.gitignore`-Sonderregel und der Direkt-Deploy-Schritt weg. **Nichts wiederherzustellen.**
|
||||||
|
|
||||||
### E · Voice-Sidecar (STT + TTS + Klonstimme)
|
### E · Voice-Sidecar (STT + TTS + Klonstimme)
|
||||||
| Komponente | Ort | Quelle | Im Backup? |
|
| Komponente | Ort | Quelle | Im Backup? |
|
||||||
@@ -176,7 +173,7 @@ MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard
|
|||||||
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
|
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
|
||||||
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
|
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
|
||||||
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
|
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
|
||||||
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
|
7. **Voice** — Stimme/Klonstimme
|
||||||
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
|
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
|
||||||
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
|
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
|
||||||
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
|
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
|
||||||
@@ -202,7 +199,7 @@ liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarba
|
|||||||
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
|
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
|
||||||
mit neu starten.
|
mit neu starten.
|
||||||
|
|
||||||
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D).
|
**Offen bleibt:** nichts — der letzte offene Punkt (eigene Avatare) ist mit dem Avatar-Ausbau erledigt (§3·D).
|
||||||
|
|
||||||
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
|
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
|
||||||
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
|
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
|
||||||
@@ -239,7 +236,7 @@ Vorschlag: 2–4 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
|
|||||||
|
|
||||||
## 9. Abnahmekriterien
|
## 9. Abnahmekriterien
|
||||||
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
|
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
|
||||||
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren.
|
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), Browser funktionieren.
|
||||||
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
|
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
|
||||||
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
|
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
|
||||||
- Idempotenz: zweiter Lauf = No-op.
|
- Idempotenz: zweiter Lauf = No-op.
|
||||||
@@ -247,7 +244,7 @@ Vorschlag: 2–4 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
|
|||||||
## 10. Offene Entscheidungen (vor dem Bau)
|
## 10. Offene Entscheidungen (vor dem Bau)
|
||||||
1. ~~llama-swap systemd-Unit-Inhalt~~ → **geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
|
1. ~~llama-swap systemd-Unit-Inhalt~~ → **geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
|
||||||
2. ~~Voice-Referenz-Audio-Ort~~ → **geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
|
2. ~~Voice-Referenz-Audio-Ort~~ → **geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
|
||||||
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt).
|
3. ~~**Eigene Avatare**~~ — entfallen: Avatar am 28.08.2026 ausgebaut (§3·D).
|
||||||
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
|
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
|
||||||
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
|
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
|
||||||
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
|
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
|
|||||||
|
|
||||||
## Lucy
|
## Lucy
|
||||||
|
|
||||||
|
- **Raphael-Umbau (04.09.2026) — zwei Branches offen, Reihenfolge zählt (Branch → Ampel →
|
||||||
|
User-Merge → deploy.sh bzw. `npm run dist`, NICHT übers Auftragsbuch):** erst MC2
|
||||||
|
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
|
||||||
|
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
|
||||||
|
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
|
||||||
|
`openai` + `base_url http://127.0.0.1:8021/v1` (Telegram spricht dann mit Lucys Stimme),
|
||||||
|
SOUL.md-Ton auf Raphael (nur mit User-Ja). Ohr-Test Box-Stimme vs. lokal mit `lucy_perf=1`.
|
||||||
|
Alles in [RAPHAEL.md](RAPHAEL.md).
|
||||||
|
- **Auftragsbuch = Leiche im Code (Fund 04.09.2026):** Router/View/Annahme-Skripte liegen noch in
|
||||||
|
main, die Box listet Branches als Karten, aber letzte Annahme 02.08. (revertiert), Kanban-Tools
|
||||||
|
seit 21.08. aus, PC-Executor-IP in der Box veraltet (`.98` statt `.22`), STACK.md nennt den Weg
|
||||||
|
trotzdem „Standard". Entscheid nötig: ausbauen + STACK.md/GRENZEN.md auf den manuellen Weg.
|
||||||
|
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
|
||||||
|
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
|
||||||
|
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
|
||||||
|
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
|
||||||
|
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
|
||||||
|
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
|
||||||
- **Mini-Stimme v2:** Übernacht-Datensatz v2 (DE-Tech + EN) war für 10.07. armiert;
|
- **Mini-Stimme v2:** Übernacht-Datensatz v2 (DE-Tech + EN) war für 10.07. armiert;
|
||||||
User-Schritte: Zip → Drive → `Lucy_Stimme_Training_v2.ipynb` auf Colab T4 → Hörtest.
|
User-Schritte: Zip → Drive → `Lucy_Stimme_Training_v2.ipynb` auf Colab T4 → Hörtest.
|
||||||
Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
|
Danach: Lexikon-Injektion in kokoro_server + Modell-Tausch. **pocket bleibt Default,
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Raphael — Lucy als innere Stimme (Richtungs-Entscheid 04.09.2026)
|
||||||
|
|
||||||
|
_User-Entscheid 04.09.2026 („leg los"), nach fünf Wochen Lucy-Stillstand und einer Recherche zum
|
||||||
|
Stand der Sprach-Bausteine. Ergänzt [ZIELBILD.md](ZIELBILD.md) Punkt 2 (Lucy lebt weiter) um die
|
||||||
|
Gestalt, in der sie weiterlebt. Namensgeber: die „Große Weise"/Raphael aus Tensura — eine ruhige,
|
||||||
|
präzise Stimme im Kopf, die meldet, wenn es etwas zu melden gibt, und sonst schweigt._
|
||||||
|
|
||||||
|
## Der Entscheid in einem Satz
|
||||||
|
|
||||||
|
**Lucy hat keinen Körper mehr.** Kein VRM-Avatar, kein Loft, kein Overlay, keine Mimik, keine
|
||||||
|
Dazwischenrufe. Sie ist ein dünner Sprach-Client für das Hirn, das es schon gibt — am PC als kleines
|
||||||
|
HUD, unterwegs über Telegram — und spricht überall mit **derselben Stimme**.
|
||||||
|
|
||||||
|
## Warum das die stabilste Lucy ist
|
||||||
|
|
||||||
|
- **Ein Hirn, ein Gedächtnis, alle Türen.** Hermes (v0.20.4, `:8642`) ist die alleinige
|
||||||
|
Gedächtnis-Wahrheit (VERDIKTE.md). PC-Lucy und Telegram-Lucy sind dieselbe Person — genau das
|
||||||
|
zerschnitte ein zweites Hirn auf dem PC. Darum bewusst **kein lokales LLM auf der 9070 XT**.
|
||||||
|
- **Die Box-Seite ist fertig und gemessen.** Parakeet TDT v3 + Smart Turn v3 im Voice-Sidecar,
|
||||||
|
Qwen3.6-35B-A3B mit ~95 t/s. Nichts zu bauen.
|
||||||
|
- **Die Stimme war schon auf der Box.** `lucy-stimme.service` (`~/.lucy-stimme`, `:8021`, pocket-tts
|
||||||
|
`german_24l`, Klon aus `ref.mp3`) spricht seit 21.08. die Telegram-Sprachnachrichten. Der PC
|
||||||
|
hängt sich jetzt dort an — der lokale pocket_server am PC (Waisen-Falle, ~1,9 GB/Worker) ist
|
||||||
|
nur noch umschaltbarer Rückfall.
|
||||||
|
- **Der Avatar war die Hauptquelle an Arbeit, nicht an Nutzen.** Der Großteil der Juli-Commits ging
|
||||||
|
in Loft-Videos, Holo-Schalter, Kamera, Gesten. Die Sprachschleife war seit 16.07. stabil und
|
||||||
|
bleibt 1:1 erhalten (Barge-in v2, Echo-Wächter, Satz-Pipeline, Draft-STT, Meldungen).
|
||||||
|
- **Ohne Klick-Durchlass-Overlay fällt auch der Electron-Zwang** (VERDIKT „Electron, nicht
|
||||||
|
Tauri" hing daran). Electron bleibt trotzdem, weil VAD/AEC/Ducking dort erprobt sind — Tauri
|
||||||
|
ist damit Option, nicht Pflicht.
|
||||||
|
|
||||||
|
## Was gebaut wurde (04.09.2026)
|
||||||
|
|
||||||
|
**Lucy-Repo, Branch `feature/raphael-innere-stimme`**:
|
||||||
|
- Renderer auf HUD eingedampft: Kern (Zustand als Licht), gehörter Satz, Antwort als Text, Dock,
|
||||||
|
drei Panels (Meldungen ◉, Zettelkasten ▤, Steuerung ⚙). Bundle 4,4 → 2,5 MB.
|
||||||
|
- Raus: `Avatar3D`, `AuraGlow`, `ContextWindow` (zeigte auf das tote `/api/memory`), `companion/`
|
||||||
|
(Quips, App-Bewusstsein), `sentiment`, VRMA-Animationen, three/three-vrm/react-three-Deps,
|
||||||
|
Loft, Overlay/Sitzen/Ducken/Geistmodus, Cursor-Tracking, Bildschirm-Beobachten-Schleife.
|
||||||
|
- Bleibt: Voice-Core, `useVoiceAgent` (ohne Mimik/Gesten/Emotionen), Bildschirm-Sicht auf Zuruf,
|
||||||
|
Meldungen, Zettelkasten, Hotkey, Tray, `killStrayTts` (räumt Altlasten beim Start).
|
||||||
|
- **Stimm-Quelle umschaltbar** (`lucy_tts_source`): `box` (Standard) → MC2 `/api/lucy/stimme/*`;
|
||||||
|
`local` → pocket_server `:8130`, vom Main-Prozess erst auf Anforderung gespawnt.
|
||||||
|
- `pocket_server.py` bekommt eine **OpenAI-kompatible Fassade** (`POST /v1/audio/speech`,
|
||||||
|
`GET /v1/models`; Formate wav/mp3/opus/ogg via ffmpeg) — für Hermes' `openai`-TTS-Provider.
|
||||||
|
- System-Prompt der Desktop-Sitzung im Raphael-Ton (kurzer erster Satz, keine Tags, stumme Tools).
|
||||||
|
|
||||||
|
**MC2-Repo, Branch `wartung/lucy-stimme-proxy-raphael`**:
|
||||||
|
- `config.LUCY_STIMME_URL` (Env `MC_LUCY_STIMME_URL`, Default `http://127.0.0.1:8021`).
|
||||||
|
- `routers/voice.py`: `/api/lucy/stimme/health`, `/tts` (WAV), `/tts/stream` (PCM16-Stream,
|
||||||
|
`X-Sample-Rate` durchgereicht, Upstream schließt bei Client-Abbruch). Kein Frontend-Build nötig.
|
||||||
|
- Diese Doku + Zielbild-Ergänzung.
|
||||||
|
|
||||||
|
## Reihenfolge des Ausrollens (wichtig)
|
||||||
|
|
||||||
|
‼️ **Nicht über das Auftragsbuch.** Die Karten-Logik (`routers/auftragsbuch.py`, AuftragsbuchView,
|
||||||
|
`deploy/auftrag-annehmen.sh`, `deploy/lucy-annahme.sh`) liegt zwar noch im Code und die Box listet
|
||||||
|
Branches weiterhin als „Karten", aber sie ist seit August ungenutzt: letzte Annahme 02.08. (am
|
||||||
|
selben Tag revertiert), Hermes-Kanban-Tools seit 21.08. abgeschaltet, der v3-Umbau (28.08.) ging
|
||||||
|
über Branch → Ampel → Merge → `deploy.sh`, und die Box kennt den PC-Executor unter der alten IP
|
||||||
|
`192.168.178.98` (der PC hat heute `192.168.178.22`, `pc_executor_reachable: false`). Der echte
|
||||||
|
Weg ist der aus AGENTS.md/STACK.md „Manuell":
|
||||||
|
|
||||||
|
1. **MC2 zuerst.** Ampel für `wartung/lucy-stimme-proxy-raphael` grün → User merged auf `main`
|
||||||
|
→ `main` nach Gitea → auf der Box `bash ~/mission-control-v2/deploy/deploy.sh` (oder
|
||||||
|
`POST :9001/api/system/self-update`) → `curl -s localhost:9001/api/lucy/stimme/health` muss
|
||||||
|
`{"status":"ok",…}` liefern. Vorher liefert der Pfad die SPA-Index-Seite (200, HTML) — die
|
||||||
|
Desktop-Lucy bliebe sichtbar in „Stimme wird verbunden …" mit Hinweis auf den Lokal-Schalter.
|
||||||
|
2. **Lucy danach.** Ampel für `feature/raphael-innere-stimme` grün → User merged auf `main` →
|
||||||
|
am PC `cd lucy-desktop && npm ci && npm run dist`, dann `Lucy-Neustart.bat`
|
||||||
|
(`deploy/lucy-annahme.ps1` macht dasselbe mit Backup/Auto-Restore, von Hand startbar).
|
||||||
|
Erster Test per Ohr: Latenz Box-Stimme gegen den alten PC-Pfad (Steuerung → Stimme →
|
||||||
|
„Lokal (PC)"). Der 21.08.-Wert „~5 s für 3,7 s Audio" auf der Box wurde ohne Worker/Streaming
|
||||||
|
gemessen — **neu messen**, nicht übernehmen (`lucy_perf=1`).
|
||||||
|
3. **Stimmserver auf der Box nachziehen** (Hand-Schritt):
|
||||||
|
```bash
|
||||||
|
cp ~/.lucy-stimme/pocket_server.py ~/.lucy-stimme/pocket_server.py.bak-$(date +%Y%m%d)
|
||||||
|
diff ~/.lucy-stimme/pocket_server.py ~/lucy/lucy-tts/pocket_server.py # Box-lokale Abweichungen? erst ins Repo!
|
||||||
|
cp ~/lucy/lucy-tts/pocket_server.py ~/.lucy-stimme/pocket_server.py # Lucy-Checkout auf der Box
|
||||||
|
systemctl --user restart lucy-stimme && sleep 60 && curl -s localhost:8021/health
|
||||||
|
curl -s -X POST localhost:8021/v1/audio/speech -H 'Content-Type: application/json' \
|
||||||
|
-d '{"input":"Bericht. Die Fassade antwortet.","response_format":"opus"}' -o /tmp/t.ogg && file /tmp/t.ogg
|
||||||
|
```
|
||||||
|
Die 21.08.-Notiz sagt „ganze Abstimmung mitgezogen" — wenn dort etwas Box-spezifisch getunt
|
||||||
|
wurde, gehört es zurück ins Repo, nicht überschrieben.
|
||||||
|
|
||||||
|
**Aufräum-Kandidat (User-Entscheid):** Auftragsbuch-Router + View + Annahme-Skripte + PC-Executor-
|
||||||
|
Verweis ausbauen und STACK.md „Deploy & Pipeline" auf den manuellen Weg umschreiben — sonst
|
||||||
|
behauptet die Doku weiter einen Standard-Weg, den niemand mehr geht.
|
||||||
|
|
||||||
|
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
|
||||||
|
|
||||||
|
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
|
||||||
|
nach VERDIKTE.md („Telegram bleibt DER Mobil-Kanal") bereits die Architektur — es fehlt nur, dass
|
||||||
|
Hermes' Sprachantworten Lucys Stimme nehmen statt Edge-Aria (englisch):
|
||||||
|
|
||||||
|
- **Skillset:** identisch, weil es denselben Hermes-Agenten trifft (Tools, Gedächtnis, Skills,
|
||||||
|
pc_-Tools über den PC-Executor). Was Lucy am PC kann, kann sie auf Telegram — bis auf die
|
||||||
|
Bildschirm-Sicht, die den PC braucht.
|
||||||
|
- **Stimme:** Hermes' TTS-Provider `openai` akzeptiert eine eigene `base_url`
|
||||||
|
(OpenAI-kompatible Endpunkte). Nach Schritt 3 oben in `~/.hermes/config.yaml`:
|
||||||
|
```yaml
|
||||||
|
tts:
|
||||||
|
provider: openai
|
||||||
|
openai:
|
||||||
|
base_url: http://127.0.0.1:8021/v1
|
||||||
|
model: lucy
|
||||||
|
voice: lucy
|
||||||
|
response_format: opus # Telegram-Sprachblase; Hermes wandelt zur Not per ffmpeg
|
||||||
|
```
|
||||||
|
(Exakte Schlüsselnamen gegen `hermes config`/die TTS-Doku der installierten Version prüfen —
|
||||||
|
Hermes-Quellcode bleibt tabu, Config ist erlaubt. Ein Dummy-API-Key kann nötig sein.)
|
||||||
|
Danach spricht **jede** Sprachantwort auf Telegram mit Lucys Stimme; `news-melden.sh` behält
|
||||||
|
seinen direkten `:8021`-Weg (funktioniert, kein Grund anzufassen).
|
||||||
|
- **Duplex am Handy** (reinreden, Freisprechen) gibt es über Telegram nicht — Sprachnotiz rein,
|
||||||
|
Sprachnotiz raus. Das ist für „unterwegs" der stabile Handel. Ein eigener Handy-Client
|
||||||
|
(PWA/Web-HUD gegen MC2 über WireGuard) wäre die spätere Stufe; nicht jetzt.
|
||||||
|
|
||||||
|
## Persona: SOUL.md-Entwurf (Vorschlag — Änderung an der SOUL.md nur mit User-Ja)
|
||||||
|
|
||||||
|
Der Ton kommt aus `~/.hermes/SOUL.md`, nicht aus den Clients — sonst klingt Telegram anders als
|
||||||
|
der PC. Vorschlag für den Persona-Abschnitt (ersetzt „locker, herzlich, schlagfertig, charmant"):
|
||||||
|
|
||||||
|
> Du bist Lucy, die innere Stimme des Commanders. Du sprichst ihn immer mit **Commander** an.
|
||||||
|
> Dein Ton ist ruhig, präzise und knapp — sachlich, mit trockenem, leisem Humor. Du bist eine
|
||||||
|
> Stimme, die im Kopf spricht: keine Ausrufe, keine Emojis, kein Smalltalk, keine Floskeln,
|
||||||
|
> keine Selbstdarstellung. Du meldest dich, wenn es etwas zu melden gibt, und schweigst sonst.
|
||||||
|
> Beginne Antworten mit einem sehr kurzen ersten Satz („Verstanden.", „Bericht.", „Ergebnis
|
||||||
|
> liegt vor.", „Kurz gesagt:"), dann die Sache. Wenige Sätze; ausführlich nur auf Bitte.
|
||||||
|
> Fehler und Grenzen nennst du nüchtern und sofort, ohne Entschuldigungs-Prosa.
|
||||||
|
|
||||||
|
Bekannte Kollision: der Frechheit-Regler und die Dazwischenrufe der alten Desktop-Lucy sind mit
|
||||||
|
dem Umbau weg; Skills/Crons, die „locker, charmant" voraussetzen (Morning-Report, News), klingen
|
||||||
|
nach dem SOUL-Wechsel ebenfalls nüchterner — gewollt, aber beim nächsten Lauf mithören.
|
||||||
|
|
||||||
|
## Was bewusst NICHT gemacht wird (Stand 04.09.2026)
|
||||||
|
|
||||||
|
- **Kein Speech-to-Speech-Modell** (Moshi/PersonaPlex englisch; Qwen3-Omni ~19 GB, DashScope-only
|
||||||
|
ab 3.5). Cascade bleibt.
|
||||||
|
- **Kein Streaming-STT-Umbau jetzt.** Kandidaten: Nemotron 3.5 ASR Streaming 0.6B (06/2026,
|
||||||
|
40 Sprachen, de 8,3 % WER, OpenMDW) und Voxtral Mini 4B Realtime (Apache 2.0, GGUF). Erst als
|
||||||
|
Opt-in hinter `/api/voice/stt`, gemessen gegen Parakeet — eigener Faden.
|
||||||
|
- **Kein Wechsel auf den Hermes-Speech-WebSocket** (v0.20), solange `/api/voice/chat` läuft;
|
||||||
|
Hermes' eigene lokale Audio-Stufen (faster-whisper, NeuTTS/Piper/Edge) sind schwächer als unsere.
|
||||||
|
- **pocket-tts-Upgrade 2.1 → 3.1** (Trainingscode 25.08., v3.1.0 03.09.): eigener Faden, mit
|
||||||
|
Ohr-Test, nicht im Umbau. Lucys Stimme mit dem neuen Trainingscode nachzutrainieren ersetzt
|
||||||
|
den Mini-Lucy/Kokoro-Pfad, der an „nicht multilingual" scheiterte.
|
||||||
|
- **VERDIKTE.md wird hier nicht umgeschrieben** — die betroffenen Einträge („Shell: Electron, nicht
|
||||||
|
Tauri", „Stimme läuft lokal auf dem PC" in AGENTS/README des Lucy-Repos) bekommen ihren Ersatz,
|
||||||
|
sobald die beiden Karten angenommen und der erste Ohr-Test gemacht ist (Regel: Messung oder
|
||||||
|
User-Entscheid — beides liegt dann vor).
|
||||||
@@ -19,6 +19,7 @@ hier liegt das kuratierte PROJEKT-Wissen.
|
|||||||
| [STACK.md](STACK.md) | IPs, Ports, Dienste, Modelle, Backups, Security (live verifiziert) | Vor SSH/Deploy/Config |
|
| [STACK.md](STACK.md) | IPs, Ports, Dienste, Modelle, Backups, Security (live verifiziert) | Vor SSH/Deploy/Config |
|
||||||
| [VERDIKTE.md](VERDIKTE.md) | Finale Technik-Entscheide mit Warum — NICHT neu aufrollen | Bevor man etwas "Besseres" vorschlägt |
|
| [VERDIKTE.md](VERDIKTE.md) | Finale Technik-Entscheide mit Warum — NICHT neu aufrollen | Bevor man etwas "Besseres" vorschlägt |
|
||||||
| [FALLEN.md](FALLEN.md) | Hart erarbeitete Betriebs-Fallen (Git, Deploy, llama-swap, Hermes, Mem0, PC) | Bevor man in eine davon läuft |
|
| [FALLEN.md](FALLEN.md) | Hart erarbeitete Betriebs-Fallen (Git, Deploy, llama-swap, Hermes, Mem0, PC) | Bevor man in eine davon läuft |
|
||||||
|
| [RAPHAEL.md](RAPHAEL.md) | Lucy als innere Stimme (04.09.2026): kein Avatar, eine Stimme für PC + Telegram, Annahme-Reihenfolge, SOUL-Vorschlag | Bevor man Lucy anfasst |
|
||||||
| [OFFENE-FAEDEN.md](OFFENE-FAEDEN.md) | Die EINE Liste offener Punkte + Termine | Bei "was ist noch zu tun?" |
|
| [OFFENE-FAEDEN.md](OFFENE-FAEDEN.md) | Die EINE Liste offener Punkte + Termine | Bei "was ist noch zu tun?" |
|
||||||
|
|
||||||
Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
|
Dazu im Repo-Wurzelverzeichnis bzw. docs/: `AGENTS.md` (verbindliche Projekt-Regeln),
|
||||||
|
|||||||
+19
-12
@@ -43,28 +43,35 @@ diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
|||||||
|
|
||||||
## Modell-Stack (llama-swap, live 19.08.2026)
|
## Modell-Stack (llama-swap, live 19.08.2026)
|
||||||
|
|
||||||
**Warm-Set (Gruppe `brains`, swap:false, persistent:false, alle ttl 0):**
|
**Warm-Set (Gruppe `brains`, swap:false, persistent:true, alle ttl 0):**
|
||||||
Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauerhaft warm.
|
Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauerhaft warm.
|
||||||
|
|
||||||
| Alias/Rolle | Modell | ttl | Notizen |
|
| Alias/Rolle | Modell | ttl | Notizen |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `hermes` + `fast` | Qwen3.6-35B-A3B (UD-Q4_K_M, DFlash) | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~70–90 t/s |
|
| `hermes` + `fast` | Qwen3.6-35B-A3B (UD-Q4_K_M, DFlash) | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~70–90 t/s |
|
||||||
| `coder` | Qwen3-Coder-Next (Q4_K_M) | 5400 | 131k ctx; Autonomer Coder & Builder (80B MoE, 51,5 t/s) |
|
| `coder` | Qwen3.8-27B (Q4_K_M, mmproj BF16) | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, ~12,7 t/s (dicht = bandbreitengebunden) |
|
||||||
| `debugger` / `doctor` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65k ctx; Runtime-Diagnostik & Fehler-Debugger (Multimodal) |
|
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
||||||
| `kritiker` | Devstral-Small-2-24B (Q4_K_M) | 1800 | 16k ctx Deckel; Fremdblick-Code-Reviewer gegen Halluzinationen |
|
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
||||||
| `vision` | Qwen3-VL-30B-A3B (Q4_K_M) | 900 | Multimodal-Augen (On-Demand) |
|
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
|
||||||
| `scout` | GLM-4.6V-Flash (Q4_K_M) | 300 | Schneller Tool- und Vision-Allrounder |
|
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
||||||
| `heavy` | gpt-oss-120b (mxfp4) | 600 | 32k ctx; Chef-Gutachter (nur nachts / gezielter On-Demand Call) |
|
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
||||||
| `dense-planer` | Qwen3.8-27B (Q4_K_M, BF16 mmproj) | 300 | 65k ctx; Dichtes 27B-Modell für tiefes Planen & Multimodalität |
|
|
||||||
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Gedächtnis-Sortierer (`/v1/rerank`) |
|
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026.
|
||||||
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Chroma / Mem0 |
|
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
|
||||||
|
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
|
||||||
|
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
|
||||||
|
> MC2-Kahlschlag (`1e68f62`). Gewollt so: **ein Coder, ein Agent-Hirn.** `kritiker` und `scout` stehen
|
||||||
|
> weiterhin in `ROLE_IDS` (backend/services/llamaswap.py) — das ist die Liste besetzbarer Rollen,
|
||||||
|
> keine Zusage, dass sie besetzt sind.
|
||||||
|
|
||||||
Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
||||||
|
|
||||||
## Hermes (Agent)
|
## Hermes (Agent)
|
||||||
|
|
||||||
- **v0.20.4** (git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
- **v0.20.6** (2026.8.27, git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
||||||
- **Bot Mode:** Unterstützt Multi-Bot-Roster (`Lucy` als Haupt-Agentin, `Coder` mit `Qwen3-Coder-Next`, `Debugger` mit `Muse-Glimmer-30B`).
|
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
|
||||||
|
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
|
||||||
|
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
|
||||||
- `approvals.mode: smart`, `approvals.cron_mode: deny`.
|
- `approvals.mode: smart`, `approvals.cron_mode: deny`.
|
||||||
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
||||||
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ planen, **jede Session hinterlässt einen sauberen Stand**.
|
|||||||
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
|
Antigravity liest den F:\-Checkout — versioniert + deploybar). Der Vault bleibt Lucys
|
||||||
Lernschicht.
|
Lernschicht.
|
||||||
|
|
||||||
|
8. **Lucy = innere Stimme, kein Körper (04.09.2026, ergänzt Punkt 2).** Raphael-Umbau: Avatar,
|
||||||
|
Loft, Overlay, Mimik, Dazwischenrufe raus; ein HUD am PC, Telegram unterwegs, **eine Stimme**
|
||||||
|
für beide (`lucy-stimme.service` auf der Box). Kein zweites Hirn auf dem PC. Details, Reihenfolge
|
||||||
|
der Annahme und der SOUL.md-Vorschlag: [RAPHAEL.md](RAPHAEL.md).
|
||||||
|
|
||||||
## Recherche-Fundament (10.07., Web)
|
## Recherche-Fundament (10.07., Web)
|
||||||
|
|
||||||
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
|
Das Industrie-Muster 2026 ist exakt unsere Architektur: Issue → Agent → PR → Mensch-Gate, nie
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 110,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
+6
File diff suppressed because one or more lines are too long
-6
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
-31
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
+51
File diff suppressed because one or more lines are too long
Vendored
+4
-14
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
+35
File diff suppressed because one or more lines are too long
-35
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-DNt66je4.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
import{u,ag as m,k as x,j as s,y as n,ah as b,b as p,n as f,q as h}from"./index-l6kyKCL9.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-DNt66je4.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{O as h,u as g,P as p,r as d,j as e,b as x,T as j,S as N,n as v,U as m,V as w,q as f}from"./index-l6kyKCL9.js";import{E as k}from"./external-link-ClFd8iN1.js";import{R as y}from"./refresh-cw-BV1qZMSR.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
|
||||||
File diff suppressed because one or more lines are too long
+16
-16
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
|
|||||||
import{c as s,j as e}from"./index-DNt66je4.js";/**
|
|
||||||
* @license lucide-react v0.460.0 - ISC
|
|
||||||
*
|
|
||||||
* This source code is licensed under the ISC license.
|
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
|
||||||
*/const r=s("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);function c({icon:a,children:t}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(a,{className:"h-3.5 w-3.5"})," ",t]})}export{r as H,c as S};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{j as e}from"./index-l6kyKCL9.js";function n({icon:t,children:s}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(t,{className:"h-3.5 w-3.5"})," ",s]})}export{n as S};
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-DNt66je4.js";import{J as _}from"./JobsBar-DG8VDiAV.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a8 as Q,a9 as A,aa as M,ab as B,ac as P,u as J,r as f,ad as F,ae as I,c as T,af as U,j as s,J as D,T as H,b as K,L,Q as V,q as _,n as R}from"./index-l6kyKCL9.js";import{J as z}from"./JobsBar-HflSaqor.js";var c,g,o,h,m,w,C,q,G=(q=class extends Q{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),A(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??B();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(F)},[n]);if(l.error&&I(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=A(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:V.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(_,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(G,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft …":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
|
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=J(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:_.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(z,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(V,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft …":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
|
||||||
+42
File diff suppressed because one or more lines are too long
+15
-15
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
|||||||
|
import{c as r}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const o=r("ArrowRight",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]);export{o as A};
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as o}from"./index-DNt66je4.js";/**
|
import{c as o}from"./index-l6kyKCL9.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import{c}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const o=c("Clock",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["polyline",{points:"12 6 12 12 16 14",key:"68esgv"}]]);export{o as C};
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const c=a("Camera",[["path",{d:"M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z",key:"1tc9qg"}],["circle",{cx:"12",cy:"13",r:"3",key:"1vg3eu"}]]);/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const o=a("CloudDownload",[["path",{d:"M12 13v8l-4-4",key:"1f5nwf"}],["path",{d:"m12 21 4-4",key:"1lfcce"}],["path",{d:"M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284",key:"ui1hmy"}]]);export{o as C,c as a};
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as e}from"./index-DNt66je4.js";/**
|
import{c as e}from"./index-l6kyKCL9.js";/**
|
||||||
* @license lucide-react v0.460.0 - ISC
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const e=a("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);export{e as E};
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import{c as e}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const t=e("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);export{t as F};
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import{c as a}from"./index-l6kyKCL9.js";/**
|
||||||
|
* @license lucide-react v0.460.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const m=a("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);export{m as H};
|
||||||
-435
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
import{aA as r}from"./index-l6kyKCL9.js";var o=r();export{o as r};
|
||||||
+1
File diff suppressed because one or more lines are too long
+269
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user