1 Commits

Author SHA1 Message Date
Gitea Actions 88c391746c Automatischer Frontend Build (46edbe50c1) [skip ci] 2026-08-07 22:50:09 +00:00
359 changed files with 782 additions and 32124 deletions
-27
View File
@@ -1,27 +0,0 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
-28
View File
@@ -1,28 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
}
]
}
-92
View File
@@ -1,92 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git fetch *)",
"Bash(git push:*)",
"Bash(git rev-list *)",
"Bash(ssh hitonabi@192.168.178.151:*)",
"Bash(grep -E \"\\\\.py$|^d\")",
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
"Bash(python -c ' *)",
"Bash(echo \"tsc exit: $?\")",
"WebSearch",
"WebFetch(domain:lobehub.com)",
"WebFetch(domain:docs.litellm.ai)",
"WebFetch(domain:github.com)",
"WebFetch(domain:runaihome.com)",
"WebFetch(domain:docs.getbifrost.ai)",
"WebFetch(domain:thefrontierlab.ai)",
"WebFetch(domain:www.glukhov.org)",
"WebFetch(domain:cognio.so)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
"Bash(git checkout *)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
"Bash(xargs cat)",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"WebFetch(domain:unsloth.ai)",
"WebFetch(domain:huggingface.co)",
"Bash(xargs ls -la)",
"Bash(xargs wc -l)",
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
"WebFetch(domain:docs.mem0.ai)",
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"WebFetch(domain:www.hermes-ai.net)",
"WebFetch(domain:releases.electronjs.org)",
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)"
]
}
}
-12
View File
@@ -1,12 +0,0 @@
# Shell-/Deploy-Skripte MÜSSEN LF behalten — sonst bricht der Deploy auf der Box
# (CRLF macht `set -euo pipefail` zu `pipefail\r` → "invalid option name").
*.sh text eol=lf
*.bash text eol=lf
# systemd-Units und Service-Configs ebenfalls LF.
*.service text eol=lf
*.timer text eol=lf
# Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf
*.bat text eol=crlf
-22
View File
@@ -1,22 +0,0 @@
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local
*.env
.DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
-48
View File
@@ -1,48 +0,0 @@
# AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
-72
View File
@@ -1,72 +0,0 @@
# Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren**
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
„Werkstatt"), nicht mehr manuelle Releases.
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
## Status: **live + eingefroren (MC2 Final)**
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
ohne Betreuung monatelang weiter):
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
Branch → Gate → Deploy selbst).
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
## Entwickeln
**Backend:**
```bash
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
| `MC_PORT` | `9000` | MC-Backend-Port |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
-50
View File
@@ -1,50 +0,0 @@
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
## Nordstern & Randbedingungen (WICHTIG für den Review)
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
-160
View File
@@ -1,160 +0,0 @@
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen.
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import (
agent,
auftragsbuch,
chronik,
connect,
console,
gateway_proxy,
health,
hermes_ui,
maintenance,
memory,
models,
routing,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import memory as memory_svc
from services import reminders, sentry, warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)).
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
log.info(
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL,
)
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL,
memory_svc.AUTO_DEDUPE_THRESHOLD,
)
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
)
try:
yield
finally:
for task in tasks:
_ = task.cancel()
await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
) -> httpx.Response:
resp = await call_next(request)
if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(
voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
_DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}")
def spa(full_path: str):
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try:
target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target)
except (ValueError, OSError):
pass
index = FRONTEND_DIST / "index.html"
if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
return FileResponse(
index, headers={"Cache-Control": "no-cache, must-revalidate"}
)
return {"detail": "frontend not built"}
-131
View File
@@ -1,131 +0,0 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
-56
View File
@@ -1,56 +0,0 @@
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
-60
View File
@@ -1,60 +0,0 @@
{
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-07-03",
"models": [
{
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
}
]
}
-9
View File
@@ -1,9 +0,0 @@
fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9
huggingface_hub>=0.27
mcp>=1.2.0
tzdata>=2024.1
View File
-44
View File
@@ -1,44 +0,0 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter
from pydantic import BaseModel
from fastapi import HTTPException
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
class SetBrainReq(BaseModel):
model_id: str
@router.get("/agent/status")
def status() -> dict:
return agent_status()
@router.get("/agent/brain")
def brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
return hermes_brain_info()
@router.post("/agent/brain/set")
def set_brain(body: SetBrainReq) -> dict:
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
res = set_agent_brain(body.model_id)
if not res.get("ok"):
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
return res
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
-62
View File
@@ -1,62 +0,0 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str) -> dict:
res = auftragsbuch.diff_of(branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
-15
View File
@@ -1,15 +0,0 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from services import announce
router = APIRouter(prefix="/api")
@router.get("/chronik")
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
-21
View File
@@ -1,21 +0,0 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health
router = APIRouter(prefix="/api")
@router.get("/connect")
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
kwargs = {}
if mcp_path:
kwargs["mcp_script_path"] = mcp_path
return build_snippets(host=host, **kwargs)
@router.get("/connect/health")
def connect_health() -> dict:
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
return check_health()
-88
View File
@@ -1,88 +0,0 @@
"""
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
"""
import asyncio
import logging
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
_register("console", BOX_CONSOLE_UPSTREAM)
-140
View File
@@ -1,140 +0,0 @@
import os
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import LANES, choose_for_lane
from services.routing_policy import load_policy
router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE",
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
Persona (SOUL.md) regelt die Sprache selbst."""
if not _LANG_DIRECTIVE or alias == "hermes":
return
msgs = body.get("messages")
if not isinstance(msgs, list):
return
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None:
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
elif isinstance(first_sys.get("content"), str):
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
@router.get("/models")
async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json()
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
# Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx_map: dict[str, int] = {}
alias_entries: list[dict] = []
try:
from services import llamaswap
for m in llamaswap.list_models():
ctx = m.get("ctx")
# --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
# Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
# Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
# Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
slots = m.get("parallel_slots") or 1
if ctx and slots > 1:
ctx = ctx // slots
if ctx:
for api_id in m.get("api_ids", []):
ctx_map[api_id] = ctx
for alias in m.get("aliases", []):
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
"description": f"Alias für {m['name']}"}
if ctx:
entry["context_length"] = ctx
alias_entries.append(entry)
except Exception:
pass
if isinstance(data, dict) and isinstance(data.get("data"), list):
for entry in data["data"]:
if (ctx := ctx_map.get(entry.get("id"))):
entry.setdefault("context_length", ctx)
data["data"] = lanes + alias_entries + data["data"]
return JSONResponse(data, status_code=r.status_code)
async def _proxy(path: str, request: Request):
body = await request.json()
requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower()
alias, reason = choose_for_lane(lane, body)
body["model"] = alias
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
else:
alias = requested
routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy()
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"):
req = client.build_request("POST", url, json=body, timeout=None)
r = await client.send(req, stream=True)
if r.status_code != 200:
await r.aread()
try:
resp_json = r.json()
except Exception:
resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
async def gen():
try:
async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias)
yield chunk
finally:
await r.aclose()
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
r = await client.post(url, json=body, timeout=600.0)
resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/chat/completions")
async def chat_completions(request: Request):
return await _proxy("/v1/chat/completions", request)
@router.post("/completions")
async def completions(request: Request):
return await _proxy("/v1/completions", request)
-21
View File
@@ -1,21 +0,0 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
from fastapi import APIRouter
from config import VERSION
from services import gateway, llamaswap
router = APIRouter(prefix="/api")
@router.get("/health")
def health() -> dict:
return {
"status": "ok",
"version": VERSION,
"engine_reachable": llamaswap.engine_reachable(),
"gateway_reachable": gateway.gateway_reachable(),
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
"brain": llamaswap.brain_status(),
}
-135
View File
@@ -1,135 +0,0 @@
"""
Same-origin Reverse-Proxy für die EINGEBAUTE Hermes-Web-GUI (`hermes serve`/`dashboard`).
Anders als das ttyd-Terminal (reiner Byte-Durchreicher, siehe routers/console.py) ist die
Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRERE WebSockets
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter.
"""
import asyncio
import logging
import re
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response
from config import HERMES_BUILTIN_UI_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
_BASE = "hermes-ui"
# Hop-by-hop- + Kodierungs-Header, die ein Proxy nicht 1:1 weiterreichen darf (Länge/Encoding
# rechnet Starlette neu; httpx liefert bereits dekodierten Body).
_DROP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade", "content-encoding", "content-length", "host",
}
@router.websocket(f"/{_BASE}/{{path:path}}")
async def _proxy_ws(ws: WebSocket, path: str) -> None:
"""Beliebige WebSocket unter /hermes-ui/* (z. B. /hermes-ui/api/ws) an :9119 durchreichen."""
ws_upstream = HERMES_BUILTIN_UI_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://")
query = ws.url.query
target = f"{ws_upstream}/{path}" + (f"?{query}" if query else "")
await ws.accept()
try:
async with websockets.connect(
target, open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("hermes-ui ws-proxy (%s) beendet/fehlgeschlagen", path, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
# Die SPA liest ihren API/WS-Basis-Pfad zur Laufzeit aus `window.__HERMES_BASE_PATH__` (nf() im
# Bundle). `hermes serve` injiziert diese Zuweisung selbst ans Ende von <head> (Wert leer, weil es
# auf Root läuft) — dessen Wert überschreiben wir auf /hermes-ui, sonst setzt die GUI ihre /api-Calls
# auf MC2s eigenen /api-Namensraum (SPA-Fallback = HTML) ab. Die Assets liegen bereits per
# Vite-base=/hermes-ui/ richtig. MC2 macht die Ersetzung → überlebt Hermes-Updates, ohne die gebaute
# index.html patchen zu müssen. Fällt die Server-Injektion je weg, setzen wir sie ersatzweise selbst.
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
async def _proxy_http(request: Request, path: str = "") -> Response:
upstream = f"{HERMES_BUILTIN_UI_UPSTREAM}/{path}"
fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _DROP}
# Upstream unkomprimiert anfordern: sonst greift die HTML-Injektion nicht und die
# content-encoding-Header würden nicht zum (bereits dekodierten) Body passen.
fwd_headers["accept-encoding"] = "identity"
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as c:
r = await c.request(
request.method, upstream,
headers=fwd_headers, params=request.query_params, content=await request.body(),
)
except httpx.HTTPError as exc:
log.debug("hermes-ui http-proxy (%s) nicht erreichbar: %s", path, exc)
return Response(
content=b"Hermes-WebUI (hermes dashboard) nicht erreichbar.",
status_code=502, media_type="text/plain",
)
body = r.content
if "text/html" in r.headers.get("content-type", "").lower():
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
return Response(content=body, status_code=r.status_code, headers=resp_headers)
@router.get(f"/{_BASE}")
async def _root_redirect() -> RedirectResponse:
# Ohne Schrägstrich → auf /hermes-ui/ umleiten (die SPA erwartet den Präfix mit Slash).
return RedirectResponse(url=f"/{_BASE}/")
router.add_api_route(
f"/{_BASE}/{{path:path}}", _proxy_http,
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
name="hermes_ui_proxy",
)
-85
View File
@@ -1,85 +0,0 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel
from services import maintenance
router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel):
service: str
sudo_password: str | None = None
@router.get("/maintenance/updates")
def updates() -> dict:
return maintenance.updates()
@router.get("/maintenance/update-details")
def update_details(kind: str) -> dict:
if kind not in ("os", "engine", "swap", "hermes"):
raise HTTPException(400, "Unbekannte Update-Art.")
return maintenance.update_details(kind)
@router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict:
res = maintenance.check_updates_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/os-update")
def os_update(body: SudoReq) -> dict:
res = maintenance.os_update_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True):
return res
return res
@router.post("/maintenance/engine-update")
def engine_update(body: SudoReq) -> dict:
res = maintenance.engine_update_job(body.sudo_password)
if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return res
@router.post("/maintenance/swap-update")
def swap_update(body: SudoReq) -> dict:
res = maintenance.swap_update_job(body.sudo_password)
if not res:
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
return res
@router.post("/maintenance/hermes-update")
def hermes_update() -> dict:
return maintenance.hermes_update_job()
@router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job(body.sudo_password)
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
@router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service, body.sudo_password)
@router.get("/maintenance/logs")
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
return maintenance.logs(service, lines, x_sudo_password)
-93
View File
@@ -1,93 +0,0 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
import time
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
from services.voice_metrics import park, record_stage
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "knowledge"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "knowledge"
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.get("/memory/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
return memory.graph(min_score=min_score, top_k=top_k)
@router.post("/memory/learn", status_code=201)
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
return memory.learn(text=body.text, messages=body.messages,
source=body.source, category=body.category)
@router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
if q:
_t0 = time.perf_counter()
res = memory.list_memories(q=q, category=category)
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("memory_retrieve", _ms)
park("retrieve", _ms)
return res
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
-293
View File
@@ -1,293 +0,0 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
import psutil
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api")
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
@router.get("/models")
def models() -> dict:
items = llamaswap.list_models()
return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
@router.get("/discover")
def discover_models(force: bool = False) -> dict:
ram = _ram_gb()
data = discover.refresh_discover(ram) if force else discover.safe_discover(ram)
if not data:
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut.")
return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
name: str = "", role: str = "") -> dict:
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
ram = _ram_gb()
pb = params_b if params_b > 0 else budget.params_b_for(name)
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
return {
"params_b": round(pb, 1),
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel):
model_path: str
role: str | None = None
ctx: int = 8192
ttl: int | None = None
mmproj_path: str | None = None
jinja: bool = False
@router.post("/models/register")
def register(req: RegisterReq) -> dict:
try:
model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
mmproj_path=req.mmproj_path, jinja=req.jinja,
)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True, "model_id": model_id}
class InstallReq(BaseModel):
repo: str
role: str | None = None
quant: str = "Q4_K_M"
ctx: int | None = None
jinja: bool = False
hf_token: str | None = None
@router.get("/hf/search")
def hf_search(q: str = "") -> dict:
return {"results": hf.search(q)}
@router.get("/hf/quants")
def hf_quants(repo: str) -> dict:
repo = hf.normalize_repo(repo)
return {"repo": repo, "quants": hf.list_quants(repo)}
@router.post("/models/install")
def install(req: InstallReq) -> dict:
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo."""
repo = hf.normalize_repo(req.repo)
info = hf.resolve_gguf(repo, req.quant)
if not info["first"]:
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {repo} gefunden.")
subdir = repo.split("/")[-1]
target = MODELS_DIR / subdir
target.mkdir(parents=True, exist_ok=True)
model_path = str(target / info["first"])
mmproj_path = str(target / info["mmproj"]) if info["mmproj"] else None
ctx = req.ctx
if ctx is None:
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
try:
model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
except PermissionError as exc:
raise HTTPException(500, str(exc))
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo]
for f in info["files"]:
args.append(f)
if info["mmproj"]:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])}
@router.get("/jobs")
def jobs() -> dict:
return {"jobs": jobengine.public_jobs()}
@router.post("/jobs/{job_id}/cancel")
def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)}
class RoleReq(BaseModel):
role: str | None = None
@router.get("/roles/{role}/recommend")
def recommend_role(role: str) -> dict:
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
from services import roles
return roles.recommend_for_role(role)
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
if (body.role or "").strip().lower() == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
return res
if not llamaswap.set_role(model_id, body.role):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.get("/models/{model_id}/ctx/auto")
def auto_ctx(model_id: str) -> dict:
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
if not m:
raise HTTPException(404, "Modell nicht gefunden")
saw = budget.setup_aware_ctx_for_model(m)
return {"model_id": model_id, "current_ctx": m.get("ctx"),
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
"role": m.get("role"), **saw}
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/models/drafts")
def list_drafts(target: str = "") -> dict:
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
return llamaswap.drafts_for(target)
class DraftReq(BaseModel):
draft_path: str | None = None
@router.post("/models/{model_id}/draft")
def set_model_draft(model_id: str, body: DraftReq) -> dict:
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
try:
res = llamaswap.set_spec_draft(model_id, body.draft_path)
except PermissionError as exc:
raise HTTPException(500, str(exc))
if not res["ok"]:
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
return res
@router.post("/models/unload")
def unload_all_models() -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
with httpx.Client(timeout=10.0) as c:
r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload")
return {"ok": r.status_code == 200}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.post("/models/{model_id}/unload")
def unload_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
with httpx.Client(timeout=10.0) as c:
r = c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{model_id}")
return {"ok": r.status_code == 200}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict:
import httpx
from config import LLAMA_SWAP_URL
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.delete("/models/{model_id}")
def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
-37
View File
@@ -1,37 +0,0 @@
"""Erinnerungen & Routinen (A3) — dünner REST-Layer über services/reminders.py.
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
LAN-only wie alle MC2-Endpoints."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import reminders
router = APIRouter(prefix="/api")
class ReminderIn(BaseModel):
text: str # was angesagt werden soll
when: str # ISO-8601; ohne Offset = Commander-Zeitzone (Europe/Berlin)
repeat: str = "" # '' einmalig | daily | weekdays | weekly
@router.get("/reminders")
def list_reminders() -> dict:
return {"items": reminders.list_all()}
@router.post("/reminders")
def create_reminder(body: ReminderIn) -> dict:
try:
return {"ok": True, "item": reminders.create(body.text, body.when, body.repeat)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.delete("/reminders/{reminder_id}")
def delete_reminder(reminder_id: int) -> dict:
try:
return {"ok": True, "deleted": reminders.delete(reminder_id)}
except KeyError as exc:
raise HTTPException(404, str(exc.args[0]))
-43
View File
@@ -1,43 +0,0 @@
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import gateway
from services.routing_policy import policy_meta, save_policy
router = APIRouter(prefix="/api")
@router.get("/routing")
def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
@router.get("/routing/policy")
def get_policy() -> dict:
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
return policy_meta()
class PolicyPatch(BaseModel):
fast: str | None = None
heavy: str | None = None
coder: str | None = None
coder_lite: str | None = None
heavy_chars: int | None = None
coding_escalate_chars: int | None = None
fast_no_think: bool | None = None
@router.put("/routing/policy")
def put_policy(patch: PolicyPatch) -> dict:
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
try:
new_policy = save_policy(fields)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
return {"policy": new_policy}
-130
View File
@@ -1,130 +0,0 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen.
"""
import logging
import os
import subprocess
from fastapi import APIRouter
from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models
from services.pricing import compute_savings
from services.system import system_status
from services.token_stats import get_stats
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool:
try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
return {
"services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_ui": a["hermes_ui_url"],
},
}
@router.post("/system/backup")
def backup() -> dict:
return backup_svc.backup_now()
@router.get("/system/backups")
def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@router.post("/system/restart")
def restart(req: RestartReq) -> dict:
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
Sprache/MCP schlug fehl."""
return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats")
def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
role_map: dict[str, str | None] = {}
try:
for m in list_models():
role_map[m["name"].lower()] = m.get("role")
for alias in m.get("aliases", []):
role_map[alias.lower()] = m.get("role")
except Exception:
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
return compute_savings(get_stats(), role_map)
-357
View File
@@ -1,357 +0,0 @@
"""
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
"""
import logging
import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
try:
from guard import wrap_untrusted
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
def wrap_untrusted(text: str, label: str = "") -> str:
return text
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
multi = len(image_urls) > 1
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
"Keine Einleitung, keine Wiederholung der Frage. "
if multi else
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}})
try:
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
"messages": [{"role": "user", "content": content}],
})
r.raise_for_status()
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
except Exception as exc:
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return ""
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
class TTSIn(BaseModel):
text: str
engine: str = "piper"
voice: str = ""
language: str = ""
ref_path: str = ""
class ChatIn(BaseModel):
text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
model: str = ""
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen)
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
class AlarmIn(BaseModel):
text: str # die Alarm-Meldung
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
@router.post("/alarm")
def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leere Meldung.")
subject = (body.subject or "[Alarm]").strip()
try:
item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc:
raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item}
@router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
return announce.list_after(after, limit)
@router.get("/voice/health")
def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001
out["error"] = str(exc)
return out
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices")
def voice_voices() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@router.post("/voice/stt")
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
_t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
@router.get("/voice/reference")
def voice_get_reference() -> dict:
try:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
return {"active": False, "error": str(exc)}
@router.delete("/voice/reference")
def voice_clear_reference() -> dict:
try:
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
@router.post("/voice/tts")
async def voice_tts(body: TTSIn) -> Response:
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("tts"):
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
except httpx.HTTPError as exc:
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
@router.post("/voice/chat")
async def voice_chat(body: ChatIn) -> StreamingResponse:
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
if not HERMES_API_KEY:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
headers = {
"Authorization": f"Bearer {HERMES_API_KEY}",
"X-Hermes-Session-Id": body.session_id,
}
if body.session_key:
headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk
except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream")
-71
View File
@@ -1,71 +0,0 @@
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/api")
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
def _available() -> bool:
return VAULT.is_dir()
def _title_of(p: Path) -> str:
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
try:
with p.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s:
return s.lstrip("# ").strip()[:160]
except OSError:
pass
return p.stem
@router.get("/wissen")
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
return {"available": False, "files": []}
files = []
now = time.time()
for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
st = p.stat()
files.append({
"path": rel,
"name": p.stem,
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
"title": _title_of(p),
"mtime": st.st_mtime,
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
})
files.sort(key=lambda f: f["mtime"], reverse=True)
return {"available": True, "files": files}
@router.get("/wissen/datei")
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
try:
target = (VAULT / pfad).resolve()
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
raise HTTPException(404, "Notiz nicht gefunden.")
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
"mtime": target.stat().st_mtime}
except HTTPException:
raise
except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.")
-71
View File
@@ -1,71 +0,0 @@
"""Zeitmaschine — Snapshots ansehen und per Klick zu einem Stand zurückkehren.
Die Maschinerie existiert komplett (deploy/backup.sh Timer 03:30, Off-Box-Kopie,
deploy/restore.sh mit Pre-Restore-Sicherung); hier kommt nur der letzte Meter dazu:
Liste + Inhalt in der UI und ein Restore-Start als EIGENE systemd-Unit — restore.sh
stoppt mission-control-2 selbst, als Kind des Backends stürbe der Lauf mittendrin."""
import os
import re
import subprocess
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import backup as backup_svc
router = APIRouter(prefix="/api")
_REPO_ROOT = Path(__file__).resolve().parents[2]
RESTORE_SH = _REPO_ROOT / "deploy" / "restore.sh"
_FILE_RX = re.compile(r"^mc2-state-[0-9T:_-]+\.tar\.gz$")
class RestoreIn(BaseModel):
file: str
@router.get("/zeitmaschine")
def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
Sicherheits-Backup des aktuellen Zustands — der Schritt ist also reversibel."""
if os.name != "posix":
raise HTTPException(400, "Wiederherstellen geht nur auf der Box.")
if not _FILE_RX.match(body.file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
if not (backup_svc.BACKUP_DIR / body.file).is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
try:
from services import announce
announce.add(f"Zeitmaschine: Wiederherstellung von {body.file} gestartet — "
"die Dienste starten gleich neu.", "[Zeitmaschine]", "zeitmaschine", "silent")
except Exception:
pass
unit = f"mc2-restore-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(RESTORE_SH), "--yes", body.file],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
raise HTTPException(500, f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}")
return {"ok": True, "unit": unit}
View File
-262
View File
@@ -1,262 +0,0 @@
"""
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
"""
import logging
import os
import re
import httpx
import psutil
from config import (BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try:
from ruamel.yaml import YAML
p = HERMES_HOME / "config.yaml"
if p.exists():
with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {}
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
return str(m.get("default") or m.get("model") or "auto")
except Exception:
log.debug("_active_brain_name: Lesefehler", exc_info=True)
return "auto"
def hermes_brain_info() -> dict:
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
from services import llamaswap
models = llamaswap.list_models()
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
bl = brain.lower()
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
or next((m for m in models if bl in (m["name"] or "").lower()), None)
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
current = None
if cur:
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
"params_b": cur_params, "quant": cur.get("quant"),
"size_bytes": cur.get("size_bytes"),
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
budget = None
try:
from services.budget import footprint_gb, gtt_budget_gb
groups = llamaswap.list_groups()
persist = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
persist.update(g.get("members") or [])
cur_name = cur["name"] if cur else None
brain_gb = footprint_gb(cur) if cur else 0.0
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
warm = brain_gb + sum(footprint_gb(m) for m in models
if m["name"] in persist and m["name"] != cur_name)
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb()
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = {
"gtt_gb": gtt,
"brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1),
"fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - warm - largest_od, 1),
}
except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
def _reach(url: str, path: str = "") -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{url}{path}").status_code < 500
except httpx.HTTPError:
return False
def _count_enabled_mcp_servers() -> int:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
return {
"gateway_url": HERMES_API_URL,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
}
def set_agent_brain(model_id: str) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models:
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
if model_id == old:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try:
from services.llamaswap import set_ttl
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
if model_id not in brains:
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
set_ttl(model_id, 0)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try:
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
except PermissionError as exc:
return {"ok": False, "reason": str(exc)}
update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None
try:
b = hermes_brain_info().get("budget") or {}
if b and not b.get("fits", True):
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
except Exception:
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
return {"ok": True, "old": old, "new": model_id, "warning": warning}
def update_brain_model(new_model: str) -> bool:
from config import HERMES_HOME
home = HERMES_HOME
config_path = home / "config.yaml"
# Ensure home directory exists
home.mkdir(parents=True, exist_ok=True)
cfg = {}
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
except Exception:
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
return False
-107
View File
@@ -1,107 +0,0 @@
"""
Melde-Briefkasten der Box (Lucy-Proaktivität, Faden A3).
Alles, was die Box dem Commander aktiv sagen will (Health-Wächter, Auto-Updates,
Radar, Hermes-cron via notify.sh), landet als Eintrag hier. Die Lucy-Desktop-App
pollt `/api/voice/announcements` und SPRICHT neue Einträge von sich aus.
Persistenz als JSON neben den Modellen (übersteht Deploys/Neustarts, wie der
Discover-Cache). Bewusst klein: fortlaufende IDs als Cursor, Ring der letzten
MAX_ITEMS Einträge, ein Lock für die FastAPI-Threadpool-Worker.
"""
import json
import logging
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
def _load() -> dict:
global _state
if _state is None:
try:
_state = json.loads(STORE_PATH.read_text(encoding="utf-8"))
assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list)
except Exception:
_state = {"next_id": 1, "items": []}
return _state
def _save(state: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
# Briefkasten darf den Absender nie blockieren — dann eben nur in-memory.
log.warning("announce: Store %s nicht schreibbar", STORE_PATH, exc_info=True)
def add(text: str, subject: str = "", source: str = "", priority: str = "normal") -> dict:
"""Eintrag anhängen. priority: 'normal' (sprechen) | 'silent' (nur Verlauf/Panel)."""
text = (text or "").strip()
if not text:
raise ValueError("Leere Meldung.")
with _lock:
state = _load()
item = {
"id": state["next_id"],
"ts": time.time(),
"subject": (subject or "").strip()[:120],
"text": text[:4000],
"source": (source or "").strip()[:60],
"priority": priority if priority in ("normal", "silent") else "normal",
}
state["next_id"] += 1
state["items"].append(item)
del state["items"][:-MAX_ITEMS]
_save(state)
log.info("announce #%s [%s] %s: %.80s", item["id"], item["source"] or "-", item["subject"] or "-", text)
return item
def notify_telegram(subject: str, text: str) -> None:
"""Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1
verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op."""
if not (os.name == "posix" and shutil.which("bash")):
return
try:
subprocess.run(["bash", NOTIFY_SH, "-s", subject, text],
timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"})
except Exception:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
with _lock:
state = _load()
latest = state["next_id"] - 1
items = [] if after is None else [i for i in state["items"] if i["id"] > after][: max(1, min(limit, 100))]
return {"latest": latest, "items": items}
-316
View File
@@ -1,316 +0,0 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet deploy/auftrag-annehmen.sh als EIGENE systemd-Unit (detached):
Merge im isolierten Worktree → Push origin/main → Deploy → Health → bei Rot Revert+Alarm.
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {"ts": 0.0}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _git(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPO), *args],
capture_output=True, text=True, timeout=timeout)
def _fetch_throttled() -> None:
now = time.time()
if now - _fetch_cache["ts"] < _FETCH_EVERY:
return
_fetch_cache["ts"] = now
try:
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch fehlgeschlagen", exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(branch: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _remote_branches() -> list[str]:
r = _git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches (mit Commit-/Diff-Zusammenfassung + Status) und
Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
_fetch_throttled()
statuses = _statuses()
items = []
for branch in _remote_branches():
ref = f"origin/{branch}"
try:
ahead = int((_git(["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and (statuses.get(branch, {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
show = _git(["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
stat = (_git(["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": int(cts) if cts.strip().isdigit() else None,
"ahead": ahead,
"behind": behind,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Frontend-Quelltext ohne gebautes Bundle: Box kann nicht bauen (kein Node) —
# nach dem Annehmen bliebe die Oberfläche alt, bis am PC gebaut wird.
"frontend_ohne_build": frontend_src and not frontend_dist,
"status": statuses.get(branch),
})
except Exception:
log.warning("auftragsbuch: Branch %s nicht lesbar", branch, exc_info=True)
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str) -> dict:
if not _available():
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str) -> dict:
"""Annehmen: detached Runner starten (Merge→Push→Deploy→Health→ggf. Rollback)."""
if not _available():
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
state = (_statuses().get(branch) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / "auftrag-annehmen.sh"
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", branch.lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(branch, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str) -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Meldung in den Briefkasten — ehrlich und sichtbar."""
if not _available():
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
_set_status(branch, "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
try:
from services import announce
announce.add(f"Vorschlag '{branch}' wurde abgelehnt und der Branch gelöscht.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
-78
View File
@@ -1,78 +0,0 @@
"""
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
"""
import subprocess
import tarfile
from pathlib import Path
from config import MODELS_DIR
BACKUP_DIR = Path(MODELS_DIR) / "mc2-backups"
SRC_ROOT = Path(__file__).resolve().parents[2]
BACKUP_SH = SRC_ROOT / "deploy" / "backup.sh"
def _ts(p: Path) -> str:
"""Zeitstempel aus 'mc2-state-<ts>.tar.gz' (Path.stem ließe '.tar' stehen)."""
return p.name[len("mc2-state-"):-len(".tar.gz")]
def _latest() -> Path | None:
if not BACKUP_DIR.exists():
return None
snaps = sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True)
return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try:
with tarfile.open(tarball, "r:gz") as t:
top = {m.name.split("/")[1] for m in t.getmembers()
if m.name.startswith("./") and "/" in m.name[2:]}
top |= {m.name[2:] for m in t.getmembers()
if m.name.startswith("./") and "/" not in m.name[2:] and m.isfile()}
return sorted(x for x in top if x)
except Exception:
return []
def backup_now() -> dict:
"""Erstellt einen Voll-Zustands-Snapshot via deploy/backup.sh."""
try:
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
if r.returncode != 0:
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
latest = _latest()
if not latest:
return {"ok": False, "snapshot": "", "files": [], "error": "Kein Backup erzeugt"}
return {
"ok": True,
"snapshot": _ts(latest),
"files": _components(latest),
"size_mb": round(latest.stat().st_size / 1_000_000, 2),
}
def list_backups() -> list[dict]:
if not BACKUP_DIR.exists():
return []
out = []
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
out.append({
"snapshot": _ts(p),
"file": p.name,
"size_mb": round(p.stat().st_size / 1_000_000, 2),
})
return out
-205
View File
@@ -1,205 +0,0 @@
"""
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
• Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist`
in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
verdrängte damals die ganze Gruppe).
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
deren Footprint mit einplanen.
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
"""
import re
import psutil
from services.fit import (
QUANT_BYTES_PER_PARAM,
estimate_memory_gb,
extract_params_b,
max_ctx_in_budget,
)
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
def gtt_budget_gb() -> float:
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
try:
with open("/proc/cmdline") as f:
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
if m:
return round(int(m.group(1)) / 1024.0, 1)
except Exception:
pass
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
def params_of_model(model: dict) -> float:
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und
Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
(size_bytes = nur erster Teil → ignoriert) ab."""
caps = model.get("capabilities") or {}
quant = model.get("quant") or "Q4_K_M"
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta
path = model.get("gguf_path")
if not path:
return None
meta = gguf_meta.arch_meta(path)
if not meta:
return None
ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model)
weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx)
if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0)
def params_b_for(name: str) -> float:
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
from services import catalog
meta = catalog.meta_for_name(name) if name else None
if meta and meta.get("total_params_b"):
return float(meta["total_params_b"])
return extract_params_b(name)
def _coresident_members(groups: dict) -> set:
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026)
überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre
Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
out: set = set()
for g in (groups or {}).values():
if isinstance(g, dict) and g.get("swap") is False:
out.update(g.get("members") or [])
return out
def reserved_gb(role: str | None) -> dict:
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
`swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben.
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
Gruppen-Mitgliedern → reserviert deren Summe.
- Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe →
reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die
Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
"""
from services import llamaswap
models = llamaswap.list_models()
groups = llamaswap.list_groups()
cores = _coresident_members(groups)
brain = next((m for m in models if (m.get("role") == "hermes")), None)
brain_gb = footprint_gb(brain) if brain else 0.0
role = (role or "").strip().lower()
holder = next((m for m in models if (m.get("role") == role)), None) if role else None
holder_name = holder["name"] if holder else None
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen.
in_group = role == "hermes" or bool(holder_name and holder_name in cores)
if in_group:
others = sum(footprint_gb(m) for m in models
if m["name"] in cores and m["name"] != holder_name)
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
# on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren.
warm = sum(footprint_gb(m) for m in models if m["name"] in cores)
return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb}
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
"""Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem
bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
gtt = gtt_budget_gb()
r = reserved_gb(role)
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
ctx = max_ctx_in_budget(params_b, quant, budget)
return {
"ctx": ctx,
"gtt_gb": gtt,
"reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1),
"mode": r["mode"],
}
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
-135
View File
@@ -1,135 +0,0 @@
"""
Modell-Capabilities — EINE Quelle der Wahrheit für Modell-Eigenschaften
(MoE / Tools / Vision / Coder / Reasoning / Embedding / Kontext).
Portiert aus Mission Control v1 (model_caps.py).
Quellen, geschichtet: GGUF-Header (offline, authoritativ) → cmd-Flags
(--jinja/--mmproj) → HF-Block (tags + chat_template) → Familien-Fallback.
Tool-Fähigkeit dreistufig: yes (bestätigt) | likely (Familie) | no.
"""
import re
import struct
from services.fit import extract_active_params_b, extract_params_b
_GGUF_FIXED = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
def _read_gguf_meta(path: str) -> dict:
"""Liest nur den GGUF-Metadaten-Header (architecture/context_length/expert_count/
parameter_count). Bricht vor dem Tokenizer-Array ab → schnell, lädt NICHT das Modell."""
out: dict = {}
try:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
struct.unpack("<I", f.read(4))[0]
f.read(8)
kv = struct.unpack("<Q", f.read(8))[0]
def ru32() -> int: return struct.unpack("<I", f.read(4))[0]
def ru64() -> int: return struct.unpack("<Q", f.read(8))[0]
def rstr() -> str: return f.read(ru64()).decode("utf-8", "replace")
def rval(t: int):
if t == 8: return rstr()
if t == 0: return struct.unpack("<B", f.read(1))[0]
if t == 1: return struct.unpack("<b", f.read(1))[0]
if t == 2: return struct.unpack("<H", f.read(2))[0]
if t == 3: return struct.unpack("<h", f.read(2))[0]
if t == 4: return struct.unpack("<I", f.read(4))[0]
if t == 5: return struct.unpack("<i", f.read(4))[0]
if t == 6: return struct.unpack("<f", f.read(4))[0]
if t == 7: return f.read(1) != b"\x00"
if t == 10: return struct.unpack("<Q", f.read(8))[0]
if t == 11: return struct.unpack("<q", f.read(8))[0]
if t == 12: return struct.unpack("<d", f.read(8))[0]
if t == 9:
et = ru32(); cnt = ru64()
if et == 8:
for _ in range(cnt):
f.seek(ru64(), 1)
elif et == 9:
for _ in range(cnt):
rval(9)
else:
f.seek(cnt * _GGUF_FIXED.get(et, 0), 1)
return None
raise ValueError(f"unbekannter GGUF-Typ {t}")
want = {"architecture", "context_length", "expert_count", "parameter_count"}
for _ in range(kv):
key = rstr()
t = ru32()
if key == "tokenizer.ggml.tokens":
break
v = rval(t)
short = key.split(".")[-1]
if short in want and short not in out:
out[short] = v
except Exception:
return out
return out
_TOOL_FAMILIES = (
"qwen2.5", "qwen3", "qwen2", "hermes", "mistral", "mixtral", "devstral",
"command-r", "command_r", "llama-3.1", "llama3.1", "llama-3.3", "llama-4", "llama4",
"functionary", "watt", "firefunction", "granite", "glm-4", "glm-5", "ministral",
)
_REASON_KW = (
"-r1", "deepseek-r1", "qwq", "magistral", "-think", "thinking", "-o1",
"gpt-oss", "reasoning", "exaone-deep", "phi-4-reasoning", "phi-4-mini-reasoning",
)
_CODE_KW = ("coder", "-code", "code-", "codestral", "starcoder", "deepseek-coder")
_VISION_KW = ("-vl", "vision", "llava", "pixtral", "multimodal", "-mm-", "qwen3vl", "qwen2-vl")
_EMBED_KW = ("bge", "e5-", "gte-", "nomic-embed", "embed")
_MOE_ARCH = ("moe", "mixtral", "deepseek2", "deepseek3", "llama4", "qwen3moe", "grok")
def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict | None = None) -> dict:
"""Capability-Tag-Set für ein Modell. Alle Quellen optional — nutzt, was da ist."""
low = (name or "").lower()
cmdl = (cmd or "").lower()
hf = hf or {}
meta = _read_gguf_meta(gguf_path) if gguf_path else {}
arch = str(meta.get("architecture") or hf.get("architecture") or "").lower()
tags = [str(t).lower() for t in (hf.get("tags") or [])]
chat_tpl = str(hf.get("chat_template") or "")
expert_count = int(meta.get("expert_count") or 0)
moe = (
expert_count > 1
or any(a in arch for a in _MOE_ARCH)
or bool(re.search(r"\d+x\d+\.?\d*b", low))
or bool(re.search(r"a\d+\.?\d*b", low))
)
active_b = extract_active_params_b(name)
pcount = int(meta.get("parameter_count") or 0)
params_b = round(pcount / 1e9, 1) if pcount else extract_params_b(name)
ctx = meta.get("context_length")
if not ctx:
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
ctx = int(m.group(1)) if m else None
# Cap context length at 131072 for Qwen / Hermes models to prevent reporting scaled RoPE context of 256k+ which might OOM or be unstable.
if ctx and ctx > 131072 and ("qwen" in low or "hermes" in low):
ctx = 131072
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
vision = "--mmproj" in cmdl or "vl" in arch or "clip" in arch or any(k in low for k in _VISION_KW)
coder = any(k in low for k in _CODE_KW)
reasoning = any(k in low for k in _REASON_KW) or "reasoning" in tags
embedding = "bert" in arch or any(k in low for k in _EMBED_KW)
return {
"moe": moe, "active_b": active_b, "tools": tools, "vision": vision,
"coder": coder, "reasoning": reasoning, "embedding": embedding,
"ctx": ctx, "params_b": params_b or None, "arch": arch or None,
}
-115
View File
@@ -1,115 +0,0 @@
"""
Kuratierter Modell-Katalog ("Cookbook", inspiriert von Odysseus): EINE Quelle der
Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) statt
Namens-Raterei. Macht Empfehlung + Upgrade-Erkennung präzise und MoE-bewusst
für die bandbreiten-limitierte Strix-Halo-Box.
Daten: backend/models_catalog.json. Fällt sanft aus (leerer Katalog), wenn die
Datei fehlt → discover nutzt dann nur die HF-Dynamik.
"""
import json
import math
import os
import re
from services.fit import estimate_memory_gb, estimate_speed
_CATALOG_PATH = os.path.join(os.path.dirname(__file__), "..", "models_catalog.json")
_cache: dict = {"mtime": 0.0, "models": []}
def _load() -> list[dict]:
try:
mt = os.path.getmtime(_CATALOG_PATH)
if mt != _cache["mtime"]:
with open(_CATALOG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
_cache.update(mtime=mt, models=[m for m in data.get("models", []) if m.get("name")])
except (OSError, ValueError):
_cache.update(mtime=0.0, models=[])
return _cache["models"]
def _norm(name: str) -> str:
"""Vergleichs-Stamm: kleingeschrieben, Org-Prefix/Quant/GGUF/Split entfernt."""
s = (name or "").lower().split("/")[-1]
s = re.sub(r"\.gguf$", "", s)
s = re.sub(r"-\d+-of-\d+$", "", s)
s = re.sub(r"[-_](ud-)?(i?q\d[\w]*|f16|bf16|fp16|f32|mxfp4)$", "", s)
return s.strip("-_ ")
def entries() -> list[dict]:
return list(_load())
def entries_for_role(role: str) -> list[dict]:
return [e for e in _load() if e.get("role") == role]
def meta_for_name(name: str) -> dict | None:
"""Katalog-Metadaten zu einem Modell(namen) — matcht lokalen Namen ODER HF-Repo."""
n = _norm(name)
if not n:
return None
for e in _load():
cand = {_norm(e.get("name", "")), _norm(e.get("repo", ""))}
if n in cand or any(c and (c in n or n in c) for c in cand):
return e
return None
def fit_of(e: dict, ram_gb: float) -> dict:
"""Hardware-Fit eines Katalog-Eintrags (MoE-bewusst über active_params_b)."""
total = float(e.get("total_params_b") or 7)
active = float(e.get("active_params_b") or total)
quant = e.get("quant") or "Q4_K_M"
ctx = int(e.get("ctx") or 32768)
req_gb = estimate_memory_gb(total, quant, ctx)
tps = estimate_speed(req_gb, ram_gb, (active / total) if total else 1.0)
usable = max(ram_gb - 4.0, 0)
if req_gb > usable:
level, text = "too_tight", "Zu groß (OOM)"
elif req_gb > usable * 0.8:
level, text = "marginal", "Könnte knapp werden"
else:
level, text = "perfect", "Passt perfekt"
return {"level": level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)}
def stack_score(e: dict, ram_gb: float) -> float:
"""Score für DIESE Hardware: muss passen, dann Wissen (total params) + Tempo
(tps — belohnt MoE durch niedrige aktive Params automatisch). Bandbreiten-Box
→ MoE gewinnt bei vergleichbarem Wissen gegen dense."""
fit = fit_of(e, ram_gb)
if fit["level"] == "too_tight":
return -100.0
total = float(e.get("total_params_b") or 7)
fit_bonus = 3.0 if fit["level"] == "perfect" else 1.0
knowledge = math.log2(total + 1) / 8.0 # ~0..1 (bis ~256B)
speed = min((fit["tps"] or 0) / 80.0, 1.0) # normalisiert; MoE = hohe tps
return fit_bonus + 1.2 * knowledge + 1.0 * speed
def to_model_dict(e: dict, ram_gb: float) -> dict:
"""Katalog-Eintrag → discover-kompatibles Modell-Dict (echte Metadaten)."""
total = float(e.get("total_params_b") or 7)
active = e.get("active_params_b")
repo = e.get("repo") or e.get("name")
role = e.get("role")
caps = {
"moe": bool(e.get("moe")), "active_b": active,
"tools": "yes" if e.get("tools") else "no",
"vision": bool(e.get("vision")), "coder": role == "coder",
"reasoning": role == "heavy", "embedding": False,
"ctx": e.get("ctx"), "params_b": total, "arch": e.get("family"),
}
return {
"name": e.get("name"), "author": repo.split("/")[0] if "/" in repo else "catalog",
"repo": repo, "role": role, "params_b": total, "active_b": active,
"moe": bool(e.get("moe")), "generation": e.get("generation"),
"family": e.get("family"), "quant": e.get("quant") or "Q4_K_M",
"tags": ["catalog"], "downloads": 0, "fit": fit_of(e, ram_gb),
"optimal_ctx": int(e.get("ctx") or 32768), "caps": caps, "curated": True,
}
-160
View File
@@ -1,160 +0,0 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
"""
import json
import httpx
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
DEFAULT_HOST = "192.168.178.151"
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
PRIMARY_MODEL = "coder"
def _caps(tools: bool = True, images: bool = False) -> dict:
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
return {"tools": tools, "images": images, "parallel_tool_calls": False,
"prompt_cache_key": False, "chat_completions": True,
"interleaved_reasoning": False, "max_tokens_parameter": False}
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
IDE_MODELS = [
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
]
def _gw(host: str) -> str:
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
return f"http://{host}:{PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST,
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host)
mc_url = f"http://{host}:{PORT}"
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
kilo = json.dumps({
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": PRIMARY_MODEL,
}, indent=2)
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
hermes_desktop = (
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
f"#\n"\
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
f"# (MC2-Proxy auf dem Gateway)\n"\
f"#\n"\
f"# 2. Session-Token: auf der Box liegen in\n"\
f"# ~/.hermes/desktop-gateway-token\n"\
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
f"#\n"\
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
f"#\n"\
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
)
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
claude_code = (
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
f"# • claude-code-router (leichtgewichtig, npm)\n"
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
f"\n"
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
f'export ANTHROPIC_MODEL="coder"'
)
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return {
"host": host,
"gateway_url": gw,
"mc_url": mc_url,
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
# Claude Code (starke Sessions).
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
"tools": {
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · Ask/Debug→chat."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
},
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
}
def check_health() -> dict:
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/v1/models")
if r.status_code == 200:
n = len(r.json().get("data", []))
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
else:
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception: # noqa: BLE001
pass
memory = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{MEM0_SERVICE_URL}/health")
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get("http://127.0.0.1:9119/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
-179
View File
@@ -1,179 +0,0 @@
"""
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import math
import os
import time
from datetime import datetime
import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout"
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception:
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
return []
def _age_days(last_modified, now_ts: float) -> float:
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
if not last_modified:
return 540.0
try:
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
except Exception:
return 540.0
def _score(m: dict, now_ts: float) -> float:
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
- Capability: mehr Parameter (log-skaliert),
- Popularity: Downloads (log-skaliert).
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
now_ts = time.time()
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: -_score(m, now_ts),
)
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0 and not catalog.entries():
raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}),
})
cats = []
for c in CATEGORIES:
role = c["role"]
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
# macht die Empfehlung präzise statt Namens-Raterei.
cat_entries = sorted(catalog.entries_for_role(role),
key=lambda e: -catalog.stack_score(e, ram_gb))
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
if m["fit"]["level"] != "too_tight"]
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
hf_ranked = rank_runnable(by_cat[role])
seen = {catalog._norm(m["repo"]) for m in cat_models}
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
combined = cat_models + extra
if combined:
cats.append({
"role": role, "title": c["title"], "icon": c["icon"],
"models": combined[:6],
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
"recommended": (cat_models[0]["repo"] if cat_models
else (hf_ranked[0]["repo"] if hf_ranked else None)),
})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception:
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
return data
def load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
return None
def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception:
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
return cached
-106
View File
@@ -1,106 +0,0 @@
"""
Hardware-Fit-Mathe (VRAM/RAM, tps-Schätzung) für APUs mit Unified Memory
(Bosgame M5 / Strix Halo). Portiert aus Mission Control v1 (hw_math.py).
"""
import re
# Bytes pro Parameter je GGUF-Quant (Annahme).
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35, "Q3_K_S": 0.38, "Q3_K_M": 0.42, "Q3_K_L": 0.45,
"Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55,
"Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65,
"Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00,
"MXFP4": 0.55, "FP8": 1.05, "AWQ": 0.55,
}
def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
"""Geschätzter Speicherbedarf in GB (Gewichte + Kontext-KV).
KV-Cache skaliert NICHT linear mit den Gesamt-Parametern (er hängt an
Layern × KV-Heads, gedämpft durch GQA) → sqrt-Skalierung, kalibriert am
gemessenen Punkt Hermes-4-14B @ 128K ≈ 19 GB KV."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
context_vram = (ctx / 8192) * (max(params_b, 7) / 7) ** 0.5 * 0.84
return weights + context_vram
def extract_active_params_b(name: str) -> float | None:
"""Aktive Parameter bei MoE ('30B-A3B' → 3.0). None bei Dense."""
m = re.search(r"(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b", name.lower())
return float(m.group(1)) if m else None
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
"""Geschätzte t/s anhand der ~273 GB/s Bandbreite der APU.
moe_active_ratio = aktive/gesamt Params; < 1 bei MoE."""
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
raw_tps = (bw / req_gb) * 0.55
if moe_active_ratio < 0.8:
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float, name: str = "") -> dict:
"""Fit für ein Shared-Memory-System (APU). name → MoE-Erkennung (optional)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
active_b = extract_active_params_b(name) if name else None
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
usable_ram = max(sys_ram_gb - 4.0, 0)
if req_gb > usable_ram:
fit_level, text = "too_tight", "Zu groß (OOM)"
elif req_gb > usable_ram * 0.8:
fit_level, text = "marginal", "Könnte knapp werden"
else:
fit_level, text = "perfect", "Passt perfekt"
return {"level": fit_level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)}
def extract_params_b(name: str) -> float:
"""Parametergröße (Mrd.) aus Repo-/Dateiname. 8x7B (MoE) → 56."""
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", name)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", name)
return float(m.group(1)) if m else 7.0
_NICE_CTX = [2048, 4096, 8192, 16384, 32768, 49152, 65536, 98304, 131072]
def max_ctx_in_budget(params_b: float, quant: str, budget_gb: float) -> int:
"""Größter 'schöner' Kontext, dessen Gewichte + KV in budget_gb passen.
Budget-basierter Kern → wird von der setup-bewussten ctx-Vergabe
(services.budget) mit dem ECHTEN freien Budget gefüttert."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
ctx_budget = budget_gb - weights
if ctx_budget <= 0:
return 2048
# KV pro 8k — EXAKTE Inverse von estimate_memory_gb (sqrt, kalibriert an
# Hermes-14B@128K≈19GB). Vorher linear → für große Modelle viel zu konservativ.
per_8k = (max(params_b, 7) / 7) ** 0.5 * 0.84
raw_ctx = (ctx_budget / per_8k) * 8192
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
"""Roh-Obergrenze: größter Kontext für dieses Modell ALLEIN gegen den
Gesamt-RAM (80 % nutzbar). Ignoriert bewusst das übrige Setup —
setup-bewusst rechnet services.budget.setup_aware_ctx."""
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
-46
View File
@@ -1,46 +0,0 @@
"""
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
"""
from config import PORT
from services.llamaswap import engine_reachable
from services.routing_policy import load_policy
def routing_summary() -> dict:
p = load_policy()
coding_default = p["coder_lite"] or p["coder"]
return {
"mode": "builtin",
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
"lanes": [
{
"name": "chat",
"aka": "auto",
"target": f"{p['fast']}{p['heavy']} (nach Komplexität)",
"threshold_chars": p["heavy_chars"],
},
{
"name": "coding",
"target": f"{coding_default}{p['coder']} (Eskalation)",
"escalate_chars": p["coding_escalate_chars"],
},
],
# Rückwärtskompatible Flach-Liste (alte UI/Clients).
"routes": [
{"name": "chat", "target": f"{p['fast']}{p['heavy']} (nach Komplexität)"},
{"name": "coding", "target": f"{coding_default}{p['coder']} (Eskalation)"},
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
],
"heavy_threshold_chars": p["heavy_chars"],
"fallbacks": [],
"context_window_fallbacks": [],
}
def gateway_reachable() -> bool:
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
return engine_reachable()
-41
View File
@@ -1,41 +0,0 @@
"""Token-Erfassung für den Builtin-Gateway.
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
"""
import json
import logging
from services.token_stats import increment_tokens
log = logging.getLogger(__name__)
def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
if not usage:
return
prompt = usage.get("prompt_tokens", 0)
completion = usage.get("completion_tokens", 0)
if prompt or completion:
increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"usage"' not in chunk:
return
text = chunk.decode("utf-8", errors="ignore")
for line in text.splitlines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
record_usage(json.loads(data_str).get("usage"), model)
except json.JSONDecodeError:
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
-266
View File
@@ -1,266 +0,0 @@
"""
GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell
**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
Decoding in llama.cpp — sonst: "draft model vocab type must match target").
Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald
`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab
identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten
Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
beim Laden bleibt der letzte Schiedsrichter.
"""
import hashlib
import struct
from functools import lru_cache
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md)
_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \
_T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
_SCALAR_FMT = {
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
}
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
class _Reader:
def __init__(self, f):
self.f = f
def read(self, n: int) -> bytes:
b = self.f.read(n)
if len(b) != n:
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
return b
def u32(self) -> int:
return struct.unpack("<I", self.read(4))[0]
def u64(self) -> int:
return struct.unpack("<Q", self.read(8))[0]
def gstr(self) -> str:
n = self.u64()
return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype)
if not fmt:
self.skip_value(vtype)
return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert."""
if vtype == _T_STRING:
self.f.seek(self.u64(), 1)
elif vtype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[vtype], 1)
elif vtype == _T_ARRAY:
etype = self.u32()
count = self.u64()
if etype == _T_STRING:
for _ in range(count):
self.f.seek(self.u64(), 1)
elif etype in _SCALAR_SIZE:
self.f.seek(_SCALAR_SIZE[etype] * count, 1)
else:
raise ValueError(f"unbekannter Array-Elementtyp {etype}")
else:
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
def _read_fingerprint(path: str) -> dict | None:
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
"tokens_sha": None}
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
etype = r.u32()
count = r.u64()
fp["n_vocab"] = count
if etype != _T_STRING:
return None
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
h = hashlib.sha256()
h.update(count.to_bytes(8, "little"))
for _ in range(count):
n = r.u64()
h.update(r.read(n))
fp["tokens_sha"] = h.hexdigest()
# model/pre kommen vor tokens → wir haben alles. Abbrechen.
break
if key in _WANT_STRINGS and vtype == _T_STRING:
val = r.gstr()
if key == "tokenizer.ggml.model":
fp["model"] = val
elif key == "tokenizer.ggml.pre":
fp["pre"] = val
else:
fp["arch"] = val
else:
r.skip_value(vtype)
if fp["model"] is None and fp["n_vocab"] is None:
return None
return fp
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=256)
def _cached(path: str, mtime: float, size: int) -> tuple | None:
fp = _read_fingerprint(path)
if fp is None:
return None
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
def fingerprint(path: str) -> dict | None:
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
import os
try:
st = os.stat(path)
except OSError:
return None
t = _cached(path, st.st_mtime, st.st_size)
if t is None:
return None
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]}
def vocab_key(path: str) -> tuple | None:
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256
der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
verlangt llama.cpp für Speculative Decoding."""
fp = fingerprint(path)
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
return None
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
def compatible(target_path: str, draft_path: str) -> bool | None:
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
a = vocab_key(target_path)
b = vocab_key(draft_path)
if a is None or b is None:
return None
return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length",
)
def _read_arch_meta(path: str) -> dict | None:
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
raw: dict = {}
arch = None
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr()
continue
if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype)
else:
r.skip_value(vtype)
n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig."""
import os
try:
st = os.stat(path)
except OSError:
return None
return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
}
_GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv)
-103
View File
@@ -1,103 +0,0 @@
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen,
freie Suche, Repo-URL→ID, verfügbare Quants."""
import os
import re
import sys
import httpx
def normalize_repo(s: str) -> str:
"""Akzeptiert volle HF-URL oder `org/repo` → liefert immer `org/repo`."""
s = (s or "").strip()
m = re.search(r"huggingface\.co/([^/\s]+/[^/\s?#]+)", s)
if m:
return m.group(1)
return s.strip("/")
def list_quants(repo: str) -> list[str]:
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
quants: set[str] = set()
for e in _tree(repo):
p = str(e.get("path", ""))
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
if m:
quants.add(m.group(1).upper())
# gängige Reihenfolge zuerst
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
return sorted(quants, key=lambda q: (order.get(q, 99), q))
def search(q: str = "", limit: int = 24) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
url = (f"https://huggingface.co/api/models?filter=gguf"
f"&sort=downloads&direction=-1&limit={limit}")
if q and q.strip():
url += f"&search={q.strip()}"
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
except Exception:
return []
out = []
for m in (data if isinstance(data, list) else []):
rid = m.get("id")
if rid:
out.append({"repo": rid, "downloads": int(m.get("downloads") or 0),
"likes": int(m.get("likes") or 0)})
return out
def hf_bin() -> str:
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
def _tree(repo: str) -> list[dict]:
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=true"
with httpx.Client(timeout=20.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
def _size(entry: dict) -> int:
return int(entry.get("size") or (entry.get("lfs") or {}).get("size") or 0)
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
"""
tree = _tree(repo)
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
q = quant.lower()
# mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# bevorzugt den gewünschten Quant
pref = [e for e in model if q in e["path"].lower()]
chosen = pref or model
if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
split = any("-of-" in e["path"].lower() for e in chosen)
if split:
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
files = [e["path"] for e in parts]
first = files[0]
total = sum(_size(e) for e in parts)
else:
# ein einzelnes File: nimm das kleinste passende (typisch genau eins)
chosen.sort(key=lambda e: _size(e))
first = chosen[0]["path"]
files = [first]
total = _size(chosen[0])
if mmproj:
total += next((_size(e) for e in ggufs if e["path"] == mmproj), 0)
return {"files": files, "first": first, "total_bytes": total, "mmproj": mmproj, "split": split}
-195
View File
@@ -1,195 +0,0 @@
"""
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
"""
import glob
import os
import shlex
import subprocess
import threading
import time
import uuid
JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {}
_LOG_CAP = 400
def _append_log(job: dict, line: str) -> None:
job["log"].append(line)
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
def _pump_output(job: dict, stream) -> None:
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
buf = b""
overwrite = False
pending_cr = False
def commit():
line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
job["log"][-1] = line
else:
_append_log(job, line)
while True:
ch = stream.read(1)
if not ch:
break
if pending_cr:
pending_cr = False
if ch == b"\n":
commit(); overwrite = False; buf = b""
continue
commit(); overwrite = True; buf = b""
if ch == b"\r":
pending_cr = True
elif ch == b"\n":
commit(); overwrite = False; buf = b""
else:
buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
job = JOBS[job_id]
job["state"] = "running"
try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None,
bufsize=0,
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
_pump_output(job, proc.stdout)
proc.wait()
job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
cb = job.pop("_on_done", None)
if cb and job["state"] == "done":
try:
cb()
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
job["total_bytes"] = total_bytes
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
prev_t = prev_b = None
rate = 0.0
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
break
try:
inc = glob.glob(pat, recursive=True)
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
if cur:
j["progress"] = min(99, int(cur * 100 / total_bytes))
j["done_bytes"] = cur
now = time.time()
if prev_t is not None and now > prev_t and cur >= prev_b:
inst = (cur - prev_b) / (now - prev_t)
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
if rate > 0:
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
j = JOBS.get(job_id)
if j and j["state"] == "done":
j["progress"] = 100
j.pop("eta_s", None)
threading.Thread(target=_watch, daemon=True).start()
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:
job_id = uuid.uuid4().hex[:12]
# Mask password in log if present in args
log_args = list(args)
JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group,
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
"returncode": None, "started_at": time.time(), "finished_at": None,
}
if on_done:
JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
return job_id
def active_in_group(group: str) -> dict | None:
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
for j in JOBS.values():
if j.get("group") == group and j.get("state") in ("running", "queued"):
return j
return None
def cancel_job(job_id: str) -> bool:
job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"):
return False
job["canceled"] = True
_append_log(job, "[mc] Abbruch angefordert…")
proc = _PROCS.get(job_id)
if proc is not None:
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
else:
job["state"] = "canceled"
job["finished_at"] = time.time()
return True
def public_jobs() -> list[dict]:
"""Jobs ohne interne Felder (_on_done) für die API."""
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
-576
View File
@@ -1,576 +0,0 @@
"""
Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die
llama-swap-API. Portiert & erweitert aus Mission Control v1.
NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""
import logging
import os
import re
import httpx
from ruamel.yaml.scalarstring import LiteralScalarString
from config import (
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
)
log = logging.getLogger(__name__)
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident
# in der `brains`-Gruppe); UI-Label „Hirn".
ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes", "kritiker", "reranker"}
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
_SPLIT_RE = re.compile(r"-(\d+)-of-(\d+)\.gguf$", re.IGNORECASE)
def _gguf_total_size(path: str) -> int | None:
"""Gesamtgröße eines GGUF inkl. ALLER Split-Teile (…-00001-of-00003.gguf).
Die Größe nur des ersten Teils ist bei Splits irreführend (oft nur ein Header)."""
try:
base = os.path.basename(path)
m = _SPLIT_RE.search(base)
if not m:
return os.path.getsize(path)
prefix, dirn = base[:m.start()], os.path.dirname(path)
total = sum(os.path.getsize(os.path.join(dirn, f))
for f in os.listdir(dirn)
if f.startswith(prefix) and _SPLIT_RE.search(f))
return total or os.path.getsize(path)
except OSError:
return None
# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
if not CONFIG_PATH.exists():
return {"models": {}}
from ruamel.yaml import YAML
r_yaml = YAML()
r_yaml.preserve_quotes = True
with CONFIG_PATH.open("r", encoding="utf-8") as f:
data = r_yaml.load(f) or {}
if not data.get("models"):
data["models"] = {}
return data
def _parse_model(name: str, spec: dict) -> dict:
spec = spec or {}
cmd = str(spec.get("cmd", "")).strip()
ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None
path = filename = quant = ""
size_bytes = None
if (m := _PATH_RE.search(cmd)):
path = m.group(1).replace("'", "").replace('"', "")
filename = os.path.basename(path)
if os.path.exists(path):
size_bytes = _gguf_total_size(path)
if (q := _QUANT_RE.search(path)):
quant = q.group(1).upper()
aliases = spec.get("aliases") or []
if isinstance(aliases, str):
aliases = [aliases]
aliases = [str(a) for a in aliases]
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd
# Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md).
spec_draft = None
if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \
or (m_draft := re.search(r"(?<![\w-])-md\s+([^\s]+)", cmd)):
spec_draft = os.path.basename(m_draft.group(1).replace("'", "").replace('"', ""))
spec_type = None
if (m_st := re.search(r"--spec-type\s+([^\s]+)", cmd)):
spec_type = m_st.group(1)
# Spec ist nur AKTIV, wenn BEIDES gesetzt ist (Draft-Modell UND --spec-type).
spec_active = bool(spec_draft and spec_type)
parallel_match = re.search(r"--parallel\s+(\d+)", cmd)
parallel_slots = int(parallel_match.group(1)) if parallel_match else 1
from services.caps import capabilities
return {
"name": name,
"role": role,
"aliases": aliases,
"api_ids": [name] + aliases,
"ctx": ctx,
"ttl": spec.get("ttl"),
"cmd": cmd,
"gguf_path": path,
"filename": filename,
"quant": quant,
"size_bytes": size_bytes,
"incomplete": not path,
"prompt_cache": prompt_cache,
"spec_draft_model": spec_draft,
"spec_type": spec_type,
"spec_active": spec_active,
"parallel_slots": parallel_slots,
"capabilities": capabilities(
name=filename or name, cmd=cmd,
gguf_path=(path if (path and os.path.exists(path)) else ""),
),
}
def list_models() -> list[dict]:
cfg = read_config()
return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]
def engine_reachable() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
except Exception:
return False
# --- Schreiben ---------------------------------------------------------------
def model_id_from_path(model_path: str) -> str:
"""Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne
'-GGUF'. Fallback: Dateiname ohne Quant-Suffix.
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
d = os.path.basename(os.path.dirname(model_path))
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
return name or "modell"
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
for mid, spec in models.items():
if mid == model_id or not isinstance(spec, dict):
continue
al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
if al:
spec["aliases"] = al
else:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
else:
spec.pop("aliases", None)
def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
"""Vision-Modelle brauchen --mmproj <projektor> und --jinja."""
if mmproj_path:
if "--mmproj" not in cmd:
cmd += f" --mmproj {mmproj_path}"
if "--jinja" not in cmd:
cmd += " --jinja"
return cmd
def write_config(cfg: dict) -> None:
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
from ruamel.yaml import YAML
r_yaml = YAML()
r_yaml.preserve_quotes = True
with tmp.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH)
# llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
# Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
try:
from services import warmer
warmer.nudge()
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
pass
except PermissionError as exc:
raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
) from exc
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
cfg = read_config()
model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
cmd = _augment_vision(cmd, model_path, mmproj_path)
if jinja and "--jinja" not in cmd:
cmd += " --jinja"
role_lower = (role or "").strip().lower()
# KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen
# (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit
# neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix).
# NICHT bei Vision-Modellen: --cache-reuse + --mmproj bricht llama-server (live verifiziert,
# deshalb fahren vision/scout auf der Box ohne cache-reuse).
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
cmd += " --cache-reuse 256 -cram 16384"
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
if role_lower == "coder" and "--parallel" not in cmd:
cmd += " --parallel 2"
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
# dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI
# setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft.
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
write_config(cfg)
return model_id
# --- Speculative-Draft / Vocab-Kompatibilität --------------------------------
def list_drafts() -> list[dict]:
"""Alle Draft-GGUFs in DRAFTS_DIR mit Tokenizer-Fingerprint."""
from services import gguf_meta
out = []
if DRAFTS_DIR.is_dir():
for p in sorted(DRAFTS_DIR.glob("*.gguf")):
out.append({
"path": str(p), "filename": p.name,
"size_bytes": p.stat().st_size if p.exists() else None,
"vocab": gguf_meta.fingerprint(str(p)),
})
return out
def _is_mtp_draft(draft_path: str) -> bool:
"""Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen
Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type
draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch
('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant')."""
base = os.path.basename(draft_path).lower()
if base.startswith("mtp-") or "-mtp" in base or "assistant" in base:
return True
from services import gguf_meta
arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower()
return arch.endswith("-assistant") or "mtp" in arch
def _spec_flags_for_draft(draft_path: str) -> str:
"""Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf →
`--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft →
`--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)"""
if _is_mtp_draft(draft_path):
return (f" --model-draft {draft_path} --spec-type draft-mtp"
f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}")
return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}"
def _sibling_mtp_drafters(target_path: str) -> list[str]:
"""MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf',
'*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft."""
out: list[str] = []
d = os.path.dirname(target_path)
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
fl = f.lower()
if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl):
p = os.path.join(d, f)
if p != target_path:
out.append(p)
return out
def find_compatible_draft(target_path: str) -> str | None:
"""Pfad eines vocab-kompatiblen Drafts für target_path, oder None.
Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction),
dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible
Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar →
bewusst KEIN Draft anhängen)."""
if not target_path or not os.path.exists(target_path):
return None
from services import gguf_meta
candidates: list[str] = list(_sibling_mtp_drafters(target_path))
if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH):
candidates.append(SPEC_DRAFT_MODEL_PATH)
for d in list_drafts():
if d["path"] not in candidates:
candidates.append(d["path"])
for c in candidates:
if gguf_meta.compatible(target_path, c) is True:
return c
return None
def spec_draft_flags(target_path: str) -> str:
"""llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder ''
wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft)."""
d = find_compatible_draft(target_path)
return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
"drafts": drafts,
}
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
abgelehnt (idiotensicher). Returns {ok, reason}."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
if not os.path.isabs(draft_path) and "/" not in draft_path:
draft_path = str(DRAFTS_DIR / draft_path)
if not os.path.exists(draft_path):
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
from services import gguf_meta
target = ""
if (mt := _PATH_RE.search(cmd)):
target = mt.group(1).replace("'", "").replace('"', "")
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
if comp is not True:
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
"würde beim Laden scheitern."
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)
return {"ok": True, "reason": ""}
# --- Groups (Ko-Residenz) ----------------------------------------------------
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
Mitglieder werden nie von anderen Gruppen verdrängt.
llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm
stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden
03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter
geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen."""
cfg = read_config()
groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
"members": list(members)}
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
# ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft
# nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300
# entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren.
if persist:
models = cfg.get("models") or {}
for mid in members:
spec = models.get(mid)
if isinstance(spec, dict):
spec["ttl"] = 0
write_config(cfg)
def list_groups() -> dict:
return read_config().get("groups") or {}
def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
cfg = read_config()
if model_id not in (cfg.get("models") or {}):
return False
set_role_alias(cfg, model_id, role)
write_config(cfg)
return True
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg)
return True
def set_ttl(model_id: str, ttl: int) -> bool:
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return False
spec["ttl"] = int(ttl)
write_config(cfg)
return True
def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
"""
cfg = read_config()
models = cfg.get("models") or {}
if model_id not in models:
return False
model_spec = models[model_id] or {}
cmd = str(model_spec.get("cmd", "")).strip()
if (m := _PATH_RE.search(cmd)):
path = m.group(1).replace("'", "").replace('"', "")
if path:
# 1. Haupt-GGUF-Datei löschen
if os.path.exists(path):
try:
os.remove(path)
except Exception:
pass
# 2. Split-GGUF-Teile löschen (z.B. dateiname-00001-of-00005.gguf etc.)
dirname = os.path.dirname(path)
basename = os.path.basename(path)
if os.path.isdir(dirname):
split_idx = basename.find("-00001-of-")
if split_idx != -1:
prefix = basename[:split_idx]
for f in os.listdir(dirname):
if f.startswith(prefix) and f.endswith(".gguf"):
try:
os.remove(os.path.join(dirname, f))
except Exception:
pass
# mmproj-Datei (Vision adapter) aus dem Befehl parsen & löschen
if "mmproj" in cmd:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match:
m_path = mmproj_match.group(1)
if os.path.exists(m_path):
try:
os.remove(m_path)
except Exception:
pass
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
try:
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
os.rmdir(dirname)
except Exception:
pass
del models[model_id]
for g in (cfg.get("groups") or {}).values():
if isinstance(g, dict) and model_id in (g.get("members") or []):
g["members"] = [m for m in g["members"] if m != model_id]
write_config(cfg)
return True
def brain_model_name() -> str | None:
"""Modellname von Lucys Agent-Hirn. Bevorzugt das Modell mit dem 'hermes'-Alias/-Rolle;
fällt auf Hermes' aktives `model.default` zurück (deckt den Fall ab, dass die Config direkt
auf einen Modellnamen statt den Alias zeigt)."""
models = list_models()
for m in models:
names = {str(a).lower() for a in (m.get("aliases") or [])}
if m.get("role"):
names.add(str(m["role"]).lower())
if "hermes" in names:
return m["name"]
# Fallback: das real von Hermes genutzte Hirn (model.default), per Alias/Name auflösen.
try:
from services.agent import _active_brain_name
brain = (_active_brain_name() or "").lower()
if brain and brain != "auto":
cur = next((m for m in models if (m.get("role") or "").lower() == brain), None) \
or next((m for m in models if brain in (m["name"] or "").lower()), None)
if cur:
return cur["name"]
except Exception:
log.debug("brain_model_name: Hermes-Fallback fehlgeschlagen", exc_info=True)
return None
def brain_status() -> dict:
"""Ist Lucys Agent-Hirn (Rolle 'hermes') WIRKLICH geladen & bereit? Prüft /running — ein
abgestürztes Modell (z.B. OOM/Crash nach Engine-Update) erscheint dort NICHT als running.
Fängt damit den Fall 'Engine erreichbar, aber Hirn tot', den engine_reachable() nicht sieht."""
name = brain_model_name()
running = get_running_models()
return {"role": "hermes", "model": name, "ready": bool(name and name in running)}
def get_running_models() -> list[str]:
"""Fragt den /running Endpunkt von llama-swap ab. Gibt die Namen der geladenen
Modelle zurück. Neuere llama-swap-Versionen liefern Objekte ({model, state, ...})
statt Strings — beide Formen werden auf Namens-Strings normalisiert."""
try:
with httpx.Client(timeout=2.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code == 200:
data = r.json().get("running") or []
return [x.get("model", "") if isinstance(x, dict) else x for x in data]
except Exception:
log.warning("get_running_models fehlgeschlagen", exc_info=True)
return []
-800
View File
@@ -1,800 +0,0 @@
"""
Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware),
Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py).
Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig
erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job.
"""
import os
import re
import subprocess
import time
from datetime import datetime
import httpx
import psutil
from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENGINE_UPDATE_CMD = os.environ.get(
"MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh")
# Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo).
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp")
_engine_cache = {"ts": 0.0, "avail": False}
# Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
def _latest_engine_asset_release() -> dict | None:
try:
rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
for rel in (rels if isinstance(rels, list) else []):
if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])):
return rel
except Exception:
pass
return None
# Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes
# Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt.
SWAP_UPDATE_CMD = os.environ.get(
"MC_SWAP_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-swap.sh")
SWAP_BIN = os.environ.get("MC_SWAP_BIN", "/usr/local/bin/llama-swap")
SWAP_REPO = os.environ.get("MC_SWAP_REPO", "mostlygeek/llama-swap")
_swap_cache = {"ts": 0.0, "avail": False}
# Stack-Funktionsprüfung NACH jedem Update (OS/Engine/Router): verifiziert per echter
# Inferenz, dass der Stack noch läuft → Job wird rot, wenn ein Update etwas zerschossen hat.
STACK_POSTCHECK = os.path.join(_REPO_ROOT, "deploy", "stack-postcheck.sh")
def _installed_engine_build() -> int | None:
"""Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None.
Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version."""
bin_path = os.path.join(ENGINE_PATH, "llama-server")
if not os.path.exists(bin_path):
return None
try:
env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH)
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
timeout=20, env=env)
txt = (out.stderr or "") + (out.stdout or "")
# Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: <hash> (9821)", "b9821".
if (m := re.search(r"version:\s*(\d{3,})", txt)) \
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
or (m := re.search(r"\bb(\d{3,})\b", txt)):
return int(m.group(1))
except Exception:
return None
return None
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _os_upgradable() -> int:
try:
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
# grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0.
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10)
return int((out.stdout or "0").strip() or 0)
except Exception:
return 0
def _engine_update_available() -> bool:
now = time.time()
if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"]
avail = False
try:
rel = _latest_engine_asset_release() or {}
tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
installed = _installed_engine_build()
if latest is not None and installed is not None:
avail = latest > installed # präziser Build-Nummer-Vergleich
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
except Exception:
avail = False
_engine_cache.update(ts=now, avail=avail)
return avail
def _installed_swap_version() -> int | None:
"""Versions-Nummer der installierten llama-swap-Binary (z.B. 228), oder None."""
if not os.path.exists(SWAP_BIN):
return None
try:
out = subprocess.run([SWAP_BIN, "--version"], capture_output=True, text=True, timeout=15)
txt = (out.stdout or "") + (out.stderr or "")
if (m := re.search(r"version:\s*(\d+)", txt)) or (m := re.search(r"\bv?(\d{2,})\b", txt)):
return int(m.group(1))
except Exception:
return None
return None
def _swap_update_available() -> bool:
now = time.time()
if now - _swap_cache["ts"] < 3600:
return _swap_cache["avail"]
avail = False
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
installed = _installed_swap_version()
if latest is not None and installed is not None:
avail = latest > installed
except Exception:
avail = False
_swap_cache.update(ts=now, avail=avail)
return avail
_comp_cache = {"ts": 0.0, "data": []}
def _hermes_agent_update() -> dict:
"""Hermes-Agent wird aus **git** aktualisiert (CLI `hermes update` = git pull origin <branch>).
Darum HEAD vs. origin/<branch> prüfen (fetch + behind-count) — NICHT GitHub-Releases: die
werden selten getaggt, main läuft ihnen voraus → sonst zeigt das UI nie ein Update an."""
info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None,
"latest": None, "update": False, "reachable": None}
git = system.find_hermes_agent_git()
if not git or not git.get("path"):
return info
path = git["path"]
info["current"] = git.get("hash")
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
info["reachable"] = (fetch.returncode == 0)
if fetch.returncode == 0:
cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"],
capture_output=True, text=True, timeout=8)
behind = int(cnt.stdout.strip() or "0") if cnt.returncode == 0 else 0
info["behind"] = behind
info["update"] = behind > 0
oh = subprocess.run(["git", "-C", path, "rev-parse", "--short", f"origin/{branch}"],
capture_output=True, text=True, timeout=8).stdout.strip()
info["latest"] = (f"{oh} ({behind} neu)" if behind else (oh or info["current"]))
except Exception:
info["reachable"] = False
return info
def _components_cached() -> list[dict]:
"""Update-Status von Hermes-Agent (1h-Cache → GitHub schonen)."""
now = time.time()
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
return _comp_cache["data"]
data = [_hermes_agent_update()]
_comp_cache.update(ts=now, data=data)
return data
def _params_of(m: dict) -> float:
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
from services.fit import QUANT_BYTES_PER_PARAM
caps = m.get("capabilities") or {}
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
def _gen_key(name: str):
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
low = (name or "").lower()
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
"-coder" if ("coder" in low or "-code" in low) else ""
for fam, pat in _FAM_PATS:
m = re.search(pat, low)
if m:
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
return (fam + sub, ver)
return None
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
cm = catalog.meta_for_name(name)
if cm:
return {"family": cm.get("family"), "gen": cm.get("generation"),
"total": float(cm.get("total_params_b") or 0),
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
d = model_dict or {}
g = _gen_key(name)
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
return {"family": (d.get("family") or (g[0] if g else None)),
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
def model_upgrades() -> list[dict]:
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
disc = discover.safe_discover(_ram_gb())
if not disc:
return []
installed = llamaswap.list_models()
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
for c in disc.get("categories", []):
role = c["role"]
im = inst_by_role.get(role)
if im is None:
continue
rec = c.get("recommended")
if not rec:
continue
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
i = _meta(im["name"], im=im)
r = _meta(rec, model_dict=rec_model)
if not i["family"] or not r["family"] or i["family"] != r["family"]:
continue # andere/unbekannte Familie → kein Upgrade
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
continue # ältere Generation → niemals
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
if same_gen:
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
continue # gleiche Gen, nicht größer → kein Upgrade
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
continue
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
continue
base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base
if base in cmds or (stem and stem in cmds):
continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec})
return out
def _last_apt_update() -> float | None:
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
if os.path.exists(path):
try:
return os.path.getmtime(path)
except Exception:
pass
return None
def updates() -> dict:
ups = model_upgrades()
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"swap": 1 if _swap_update_available() else 0,
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
"components": _components_cached()}
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
def _os_held_back() -> list[dict]:
"""Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund.
Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen:
• Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'.
• zurückgehalten wegen neuer Abhängigkeiten → 'kept back'.
Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl,
wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen."""
held: list[dict] = []
sections = {
"The following upgrades have been deferred due to phasing:": "phasing",
"The following packages have been kept back:": "kept_back",
}
try:
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=25)
reason: str | None = None
for line in (out.stdout or "").splitlines():
hdr = sections.get(line.strip())
if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln
reason = hdr
continue
if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts
for name in line.split():
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
pass
held.sort(key=lambda p: p["name"])
return held
def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche
Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back)."""
out_pkgs: list[dict] = []
try:
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
# (deutsche Box meldet sonst "[aktualisierbar von:]" und die Liste bliebe leer).
out = subprocess.run(["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null"],
capture_output=True, text=True, timeout=20)
for line in (out.stdout or "").splitlines():
# Format: name/repo neue_version arch [upgradable from: alte_version]
m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]",
line.strip())
if m:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()})
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
def engine_update_details() -> dict:
"""Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link."""
info: dict = {"kind": "engine", "installed_build": _installed_engine_build(),
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = _latest_engine_asset_release() or {}
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
info["name"] = rel.get("name") or tag
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
# Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call,
# wenn das Modal bei aktuellem Stand geöffnet wird).
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
def swap_update_details() -> dict:
"""Installierte vs. neueste llama-swap-Version + Release-Name/-Notizen/-Link."""
info: dict = {"kind": "swap", "installed_build": _installed_swap_version(),
"latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None}
try:
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
info["name"] = rel.get("name") or tag
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes
# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer
# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …".
# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig
# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen.
_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}}
# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — <grund>".
# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut.
_ACTION_RX = re.compile(
r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE)
def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
"""Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt
ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`."""
empty = {"summary": "", "action_needed": None, "action_text": ""}
if not key:
return empty
cache = _relnotes_caches.setdefault(kind, {})
if cache.get("key") == key and cache.get("data"):
return cache["data"]
from config import LLAMA_SWAP_URL
prompt = (
"Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, "
"fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n"
f"{context}\n\n"
"Anstehende Änderungen:\n" + changes + "\n\n"
"Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n"
"Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n"
"'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das "
"Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der "
"Normalfall.\n"
"'AKTION: JA — <was er konkret tun/entscheiden muss>' — NUR wenn er wirklich selbst "
"handeln muss.\n"
"Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder "
"geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup "
"betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift."
)
try:
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
"model": "fast", "max_tokens": 550, "temperature": 0.2,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": prompt}],
})
r.raise_for_status()
resp = r.json()
choice = (resp.get("choices") or [{}])[0]
text = ((choice.get("message") or {}).get("content") or "").strip()
finish_reason = choice.get("finish_reason") or ""
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
# letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig.
if finish_reason == "length" and text:
head, _, _tail = text.rpartition("\n")
text = head if head else ""
# Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1,
# aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile).
action_needed: bool | None = None
action_text = ""
lines = text.splitlines()
for idx, ln in enumerate(lines):
if m := _ACTION_RX.match(ln):
action_needed = m.group(1).upper() == "JA"
action_text = (m.group(2) or "").strip()
text = "\n".join(lines[:idx] + lines[idx + 1:]).strip()
break
data = {"summary": text, "action_needed": action_needed, "action_text": action_text}
if text:
cache.update(key=key, data=data)
return data
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
def _summarize_hermes_commits(commits: list[dict]) -> dict:
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
"terminal-/web-Tools, approvals, cron).")
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
git = system.find_hermes_agent_git()
if not git or not git.get("path"):
info["error"] = "Hermes-Agent-Repo nicht gefunden."
return info
path = git["path"]
try:
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
info["branch"] = branch
subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
capture_output=True, text=True, timeout=25)
log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr",
f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10)
commits = []
for line in (log.stdout or "").splitlines():
parts = line.split("\x1f")
if len(parts) == 3:
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
info["commits"] = commits
info["behind"] = len(commits)
if commits:
info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
def update_details(kind: str) -> dict:
return {"os": os_update_details, "engine": engine_update_details,
"swap": swap_update_details,
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
actual_cmd = list(cmd)
has_sudo = False
if cmd and cmd[0] == "sudo":
has_sudo = True
# If we have a password, use -S instead of -n
if sudo_password is not None:
if "-n" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-n"]
if "-S" not in actual_cmd:
actual_cmd.insert(1, "-S")
else:
# Force -n to fail cleanly if password is required
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
try:
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
err_msg = p.stderr or ""
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
if sudo_password is not None:
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "out": "", "err": str(exc)}
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
res = _run(["sudo", "true"], sudo_password=sudo_password)
if not res["ok"]:
return res
return None
def restart_service(name: str, sudo_password: str | None = None) -> dict:
if name in SYSTEM_SERVICES:
if err := check_sudo_needs_password(sudo_password):
return err
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
lines = max(1, min(lines, 1000))
if service in USER_SERVICES:
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
return {"ok": r["ok"], "text": r["out"] or r["err"]}
if service in SYSTEM_SERVICES:
# Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal).
# Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen.
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
if r["ok"]:
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
if err := check_sudo_needs_password(sudo_password):
return err
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": False, "text": "", "err": "Dienst nicht erlaubt."}
def check_updates_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
def _maintenance_busy() -> dict | None:
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts)."""
if j := jobengine.active_in_group("maintenance"):
return {"ok": False, "status": "busy", "running": j.get("label")}
return None
def os_update_job(sudo_password: str | None = None) -> dict:
if busy := _maintenance_busy():
return busy
if err := check_sudo_needs_password(sudo_password):
return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
# sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab.
cmd = ("sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
group="maintenance", sudo_password=sudo_password)
return {"ok": True, "job_id": job_id}
def engine_update_job(sudo_password: str | None = None) -> dict | None:
if not ENGINE_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def swap_update_job(sudo_password: str | None = None) -> dict | None:
if not SWAP_UPDATE_CMD:
return None
if busy := _maintenance_busy():
return busy
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
"Router-Update (llama-swap)",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def _hermes_update_cmd() -> str:
"""Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build →
Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job."""
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh")
# Backup → Stop UI (verhindert Crash durch Löschen der Sourcen) → update → doctor →
# UI-Build mit MC2-Basepath (/hermes-ui/) → Gateway + UI-Neustart → Smoke-Test.
hui_web = os.path.join(path, "web")
hui_dist = os.path.join(path, "hermes_cli", "web_dist")
npm_path = os.path.expanduser("~/.hermes/node/bin")
build_cmd = (
f"if [ -d {hui_web} ]; then "
f"cd {hui_web} && PATH={npm_path}:$PATH npx --no-install vite build --base=/hermes-ui/ --outDir /tmp/h-build --emptyOutDir "
f"&& rm -rf {hui_dist} && cp -r /tmp/h-build {hui_dist}; fi"
)
# EHRLICHER Exit-Code (Lehre 08.07.): Die Dienste müssen auch bei einem GESCHEITERTEN
# Update wieder hochkommen (die UI wurde ja gestoppt) — aber der Job darf dann nicht
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
return ("RC=0; "
f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
f"&& {build_cmd}; }} || RC=1; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; "
f"sleep 6; bash {postcheck} || RC=1; "
f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; "
f"exit $RC")
def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
if busy := _maintenance_busy():
return busy
def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id}
def update_all_job(sudo_password: str | None = None) -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy():
return busy
upd = updates()
parts: list[tuple[str, str]] = []
if upd.get("engine") and ENGINE_UPDATE_CMD:
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
if upd.get("swap") and SWAP_UPDATE_CMD:
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
if any(c.get("update") is True for c in upd.get("components") or []):
parts.append(("Hermes-Agent", _hermes_update_cmd()))
os_pending = (upd.get("os") or 0) > 0
if os_pending:
if err := check_sudo_needs_password(sudo_password):
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
if not parts:
return err
os_pending = False
else:
parts.append(("OS (apt)", (
"sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")))
if not parts:
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
cmd = " && ".join(
f"(echo; echo '════════ [{i + 1}/{len(parts)}] {label} ════════'; {c})"
for i, (label, c) in enumerate(parts))
if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = "".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done,
sudo_password=sudo_password)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password):
return err
return _run(["sudo", "reboot"], sudo_password=sudo_password)
-177
View File
@@ -1,177 +0,0 @@
"""
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
"""
import asyncio
import logging
import os
import re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
log = logging.getLogger(__name__)
CATEGORIES = ("identity", "knowledge", "rules", "events")
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
def _get(path: str, **params) -> list | dict:
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict) -> dict:
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict) -> dict:
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _delete(path: str) -> dict:
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def list_memories(q: str = "", category: str = "") -> list[dict]:
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
try:
return _put(f"/memory/{mid}", {"content": content, "category": category})
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
def delete_memory(mid: str) -> bool:
try:
_delete(f"/memory/{mid}")
return True
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return False
raise
def learn(text: str | None = None, messages: list[dict] | None = None,
source: str = "auto", category: str = "stable") -> dict:
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
return _post("/learn", {"text": text, "messages": messages,
"source": source, "category": category})
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
return _get("/graph", min_score=min_score, top_k=top_k)
def export_text() -> dict:
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r.get("category") != current:
current = r.get("category", "")
lines.append(f"\n## {current}\n")
src = r.get("source", "")
when = (r.get("updated_at") or "")[:10]
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
return {"text": "\n".join(lines), "count": len(rows)}
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a.get("content", ""))
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b.get("category") != a.get("category"):
continue
nb = _norm(b.get("content", ""))
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
if delete_memory(d["id"]):
removed += 1
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
async def auto_dedupe_loop() -> None:
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
while True:
try:
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
if res.get("removed"):
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
except Exception:
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
-58
View File
@@ -1,58 +0,0 @@
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
"""
import os
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
PRICING: dict[str, tuple[float, float]] = {
"heavy": (15.0, 75.0),
"coder": (3.0, 15.0),
"hermes": (1.0, 5.0),
"fast": (0.15, 0.60),
"scout": (0.15, 0.60),
"vision": (0.15, 0.60),
"reasoning": (0.15, 0.60),
}
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
# damit historische Ersparnis erhalten bleibt.
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
"""
prompt = stats.get("prompt_tokens", 0)
completion = stats.get("completion_tokens", 0)
modeled_p = modeled_c = 0
saved_usd = 0.0
for m_name, m_tokens in (stats.get("models") or {}).items():
mp = m_tokens.get("prompt", 0)
mc = m_tokens.get("completion", 0)
modeled_p += mp
modeled_c += mc
role = role_map.get(m_name, m_name)
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
baseline_p = max(0, prompt - modeled_p)
baseline_c = max(0, completion - modeled_c)
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
"saved_usd": round(saved_usd, 2),
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
}
-170
View File
@@ -1,170 +0,0 @@
"""
Erinnerungen & Routinen (Lucy-Proaktivität, Faden A3).
„Lucy, erinner mich morgen um 9 an …" — der Hermes-Agent legt per Tool (mcp_mc.py:
reminder_create) einen Eintrag an; der Wecker-Loop hier feuert ihn zur Zeit in den
Melde-Briefkasten (announce.py → Lucy spricht) und parallel auf Telegram.
Bewusst OHNE Hermes-cron: der Wecker läuft deterministisch im MC2-Backend (systemd,
überlebt Gateway-Ausfälle) und bleibt in UNSEREN Schichten (Hermes-Quellcode/Config
bleibt Auto-Update-Kanal, Verdikt Faden 11c).
Zeiten: ISO-8601. Ohne Offset gilt die Zeitzone des Commanders (MC_LOCAL_TZ,
Default Europe/Berlin) — die Box selbst läuft auf UTC, naive Zeiten dürfen daher
NIE als Box-Lokalzeit interpretiert werden. Wiederholung: daily | weekdays | weekly.
"""
import json
import logging
import os
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR
from services import announce
log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly")
LATE_NOTE_S = 600 # feuert >10 min zu spät (Box war aus) → ehrlich dazusagen
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
def _load() -> dict:
global _state
if _state is None:
try:
_state = json.loads(STORE_PATH.read_text(encoding="utf-8"))
assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list)
except Exception:
_state = {"next_id": 1, "items": []}
return _state
def _save(state: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
log.warning("reminders: Store %s nicht schreibbar", STORE_PATH, exc_info=True)
def _parse_when(when: str) -> datetime:
"""ISO-8601 → bewusste Zeit. Naive Angaben = Commander-Zeitzone (NICHT Box-UTC)."""
try:
dt = datetime.fromisoformat(when.strip())
except ValueError:
raise ValueError("Zeit nicht lesbar — bitte ISO-8601 mit Datum UND Uhrzeit, z.B. 2026-07-04T09:00.")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=LOCAL_TZ)
return dt
def _fmt(ts: float) -> str:
"""Menschlich lesbare Commander-Lokalzeit (fürs Tool-Echo/Listing)."""
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%a %d.%m.%Y %H:%M")
def _advance(ts: float, repeat: str) -> float:
"""Nächste Wiederholung NACH jetzt (holt verpasste Termine ohne Mehrfach-Feuern auf)."""
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
now = time.time()
while dt.timestamp() <= now:
dt += timedelta(days=7 if repeat == "weekly" else 1)
if repeat == "weekdays":
while dt.weekday() > 4: # Sa/So überspringen
dt += timedelta(days=1)
return dt.timestamp()
def create(text: str, when: str, repeat: str = "") -> dict:
text = (text or "").strip()
if not text:
raise ValueError("Leerer Erinnerungs-Text.")
repeat = (repeat or "").strip().lower()
if repeat == "once":
repeat = ""
if repeat not in REPEATS:
raise ValueError("repeat muss leer, daily, weekdays oder weekly sein.")
ts = _parse_when(when).timestamp()
if repeat:
if ts <= time.time(): # Startpunkt vorbei → auf die nächste Wiederholung rücken (Uhrzeit bleibt exakt)
ts = _advance(ts, repeat)
elif ts <= time.time() - 60:
raise ValueError(f"Die Zeit liegt in der Vergangenheit ({_fmt(ts)}) — bitte neu umrechnen "
f"(jetzt ist {_fmt(time.time())}).")
with _lock:
state = _load()
if len(state["items"]) >= MAX_ITEMS:
raise ValueError(f"Zu viele offene Erinnerungen (max {MAX_ITEMS}).")
item = {"id": state["next_id"], "text": text[:500], "next_ts": ts,
"repeat": repeat, "created_ts": time.time()}
state["next_id"] += 1
state["items"].append(item)
_save(state)
log.info("reminder #%s angelegt: %s%s%s", item["id"], _fmt(ts), text[:60],
f" (Routine {repeat})" if repeat else "")
return {**item, "when_local": _fmt(ts)}
def list_all() -> list[dict]:
with _lock:
items = sorted(_load()["items"], key=lambda i: i["next_ts"])
return [{**i, "when_local": _fmt(i["next_ts"])} for i in items]
def delete(reminder_id: int) -> dict:
with _lock:
state = _load()
for i, item in enumerate(state["items"]):
if item["id"] == reminder_id:
state["items"].pop(i)
_save(state)
return {**item, "when_local": _fmt(item["next_ts"])}
raise KeyError(f"Erinnerung {reminder_id} nicht gefunden.")
def _fire_due() -> None:
now = time.time()
with _lock:
state = _load()
due = [i for i in state["items"] if i["next_ts"] <= now]
if not due:
return
for item in due:
if item["repeat"]:
item["next_ts"] = _advance(item["next_ts"], item["repeat"])
else:
state["items"].remove(item)
_save(state)
for item in due:
late = now - item["next_ts"] > LATE_NOTE_S if not item["repeat"] else False
text = f"Erinnerung, Commander: {item['text']}"
if late:
text += f" (Eigentlich fällig um {_fmt(item['next_ts'])} — die Box war wohl aus.)"
announce.add(text, subject="[Erinnerung]", source="reminder")
announce.notify_telegram("[Erinnerung]", item["text"])
log.info("reminder #%s gefeuert%s", item["id"], " (Routine)" if item["repeat"] else "")
async def reminders_loop() -> None:
"""Wecker (Hintergrund-Task im MC2-Lifespan). Verpasste einmalige Erinnerungen
(Box war aus) feuern beim nächsten Tick nach — ehrlich als verspätet markiert."""
import asyncio
log.info("reminders: Wecker aktiv (Tick %ss, Zeitzone %s)", INTERVAL, LOCAL_TZ)
while True:
try:
await asyncio.to_thread(_fire_due)
except Exception:
log.debug("reminders: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
-143
View File
@@ -1,143 +0,0 @@
"""
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
"""
import psutil
from services import budget, catalog, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _capability_suit(role: str, caps: dict, name: str) -> float:
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
if role == "vision":
if not vision:
return 0.0 # harte Anforderung
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
if role == "coder":
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
if role == "hermes":
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
if "hermes" in low:
return 1.0
return 0.6 if tools else 0.1
if role == "fast":
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
return 1.0 if tools else 0.6
if role == "heavy":
return 1.0
if role == "scout":
return 0.9 if vision else 0.7
return 0.5
def _pref(role: str, params: float, tps: float) -> float:
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
role = (role or "").lower()
if role == "fast":
speed = min(tps / 25.0, 1.0)
size_ok = 1.0 if params <= 50 else 50.0 / params
return speed * size_ok
if role == "heavy":
return min(params / 120.0, 1.0)
if role == "hermes":
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 724B ideal als Hirn
if role == "vision":
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
if role == "coder":
return 0.5 + 0.5 * min(params / 80.0, 1.0)
if role == "scout":
return 1.0 if params <= 40 else 0.5
return 0.5
def _catalog_role_match(role: str, name: str) -> bool:
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
meta = catalog.meta_for_name(name)
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
incomplete: bool, cat_match: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "coder" and not caps.get("coder"):
return "kein Coder-Modell"
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if cat_match:
bits.append("Katalog-Pick ✓")
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("Hermes" if "hermes" in (name or "").lower()
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
if caps.get("moe"):
bits.append("MoE")
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
return " · ".join(bits)
def recommend_for_role(role: str) -> dict:
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
role = (role or "").strip().lower()
ram = _ram_gb()
out = []
for m in llamaswap.list_models():
caps = m.get("capabilities") or {}
params = budget.params_of_model(m)
quant = m.get("quant") or "Q4_K_M"
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
incomplete = bool(m.get("incomplete"))
fits = (fit["level"] != "too_tight") and not incomplete
tps = fit["tps"] or 0
suit = _capability_suit(role, caps, m["name"])
cat_match = _catalog_role_match(role, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
if not fits:
score -= 5.0
out.append({
"name": m["name"], "current_role": m.get("role"),
"params_b": round(params, 1), "quant": quant,
"fit": fit, "suitable": suitable, "incomplete": incomplete,
"score": round(score, 3),
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
})
out.sort(key=lambda x: -x["score"])
rec = next((o["name"] for o in out if o["suitable"]), None)
for o in out:
o["recommended"] = (o["name"] == rec)
return {"role": role, "recommended": rec, "models": out}
-90
View File
@@ -1,90 +0,0 @@
"""
Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
triviale Kurzfrage ohne Code → `fast` (Tempo).
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
(routing_policy.py, hot-reload; Env = Defaults). Lucy läuft NICHT hierüber — die ist der
Hermes-Agent (:8642), eigene Ebene.
"""
import re
from services.routing_policy import load_policy
# Modell-Aliases & Zeichen-Schwellen liegen jetzt in der UI-editierbaren Policy
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
LANES = ["coding", "chat"]
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
_HEAVY_KW = re.compile(
r"\b(beweis|prove|theorem|komplex|complex|schwierig|"
r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|"
r"root\s*cause|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b",
re.IGNORECASE,
)
# Coding-spezifische „das ist groß/architektonisch" Signale → heavy statt coder.
_CODING_HEAVY_KW = re.compile(
r"\b(architekt|architect|system[-\s]?design|refactor\s+the\s+(whole|entire)|"
r"ganze[ns]?\s+(architektur|codebase|projekt)|migrat\w+\s+(the\s+)?(whole|entire|gesamte)|"
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
re.IGNORECASE,
)
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
_CODE_HINT = re.compile(
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
r"npm|pip|git|docker|terminal|shell|command)\b",
re.IGNORECASE | re.MULTILINE,
)
def _text_of(body: dict) -> str:
msgs = body.get("messages") or []
return "\n".join(str(m.get("content") or "") for m in msgs)
def _route_chat(text: str, n: int) -> tuple[str, str]:
p = load_policy()
if n > p["heavy_chars"]:
return p["heavy"], f"langer Kontext ({n} > {p['heavy_chars']} Zeichen)"
if _HEAVY_KW.search(text):
return p["heavy"], "Komplexitäts-Schlüsselwort erkannt"
return p["fast"], "Standard"
def _route_coding(text: str, n: int) -> tuple[str, str]:
# Agentisches Coden (OpenCode/RooCode/…) bleibt IMMER beim dedizierten Coder — NIE heavy/fast
# (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K1M Kontext selbst,
# langer Repo-Kontext ist bei Agenten der Normalfall und darf NICHT zu heavy umrouten.
# (Phase 2b: warme schnelle Coder-Stufe coder_lite als Default + coder als Eskalation.)
p = load_policy()
if p["coder_lite"] and not _CODING_HEAVY_KW.search(text) and n <= p["coding_escalate_chars"]:
return p["coder_lite"], "Coding (schneller Coder)"
return p["coder"], "Coding -> starker Coder"
def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
"""Wählt das echte Modell-Alias für eine Lane. Gibt (alias, begründung) zurück."""
lane = LANE_ALIASES.get((lane or "chat").lower(), (lane or "chat").lower())
text = _text_of(body)
n = len(text)
if lane == "coding":
return _route_coding(text, n)
return _route_chat(text, n) # chat + alles Unbekannte
def choose_model(body: dict) -> tuple[str, str]:
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
return choose_for_lane("chat", body)
-127
View File
@@ -1,127 +0,0 @@
"""
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
bleiben in router_logic.py im Code.
"""
import json
import os
import threading
from pathlib import Path
from config import MODELS_DIR
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default) not in ("0", "false", "")
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
}
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
FIELDS: list[dict] = [
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
]
_LOCK = threading.Lock()
_CACHE: dict = {"mtime": None, "policy": None}
def _read_file() -> dict:
try:
with open(POLICY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _coerce(patch: dict) -> dict:
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
spec = {f["key"]: f for f in FIELDS}
out: dict = {}
for k, v in (patch or {}).items():
f = spec.get(k)
if not f:
continue # unbekannte Keys still verwerfen
if f["type"] == "int":
iv = int(v)
lo, hi = f.get("min", 1), f.get("max", 10**9)
if not (lo <= iv <= hi):
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
out[k] = iv
elif f["type"] == "bool":
out[k] = bool(v)
else: # str
sv = str(v).strip()
if k != "coder_lite" and not sv:
raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv
return out
def _coerce_safe(patch: dict) -> dict:
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
try:
return _coerce(patch)
except (ValueError, TypeError):
return {}
def load_policy() -> dict:
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
try:
mtime = POLICY_PATH.stat().st_mtime
except OSError:
mtime = None
with _LOCK:
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
merged = {**DEFAULTS}
if mtime is not None:
merged.update(_coerce_safe(_read_file()))
_CACHE["mtime"] = mtime
_CACHE["policy"] = merged
return dict(_CACHE["policy"])
def save_policy(patch: dict) -> dict:
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
-141
View File
@@ -1,141 +0,0 @@
"""
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
"""
import asyncio
import logging
import os
import time
import httpx
import psutil
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool:
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500
except Exception:
return False
def _check_engine() -> bool:
return llamaswap.engine_reachable()
def _check_brain() -> bool:
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
st = llamaswap.brain_status()
if st.get("ready"):
return True
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
def _check_disk() -> bool:
try:
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
except Exception:
return True # kein Messwert ≠ Alarm
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
CHECKS: dict[str, tuple] = {
"engine": (_check_engine,
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
"Die Modell-Engine ist wieder da."),
"brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
"hermes": (lambda: _reach(HERMES_API_URL),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."),
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
"Mein Gedächtnis ist wieder online."),
"voice": (lambda: _reach(VOICE_SERVICE_URL),
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
"Der Hör-Dienst läuft wieder."),
"disk": (_check_disk,
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
"Die Platte hat wieder genug Luft."),
}
class _Watch:
__slots__ = ("fails", "alerted", "alert_ts")
def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge
self.alerted = False # Alarm ist raus, Entwarnung steht aus
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
def _notify_telegram(subject: str, text: str) -> None:
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
legt der Wächter selbst ab."""
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
def _tick() -> None:
now = time.time()
for name, (check, fail_msg, ok_msg) in CHECKS.items():
w = _watches[name]
try:
ok = bool(check())
except Exception:
ok = False
if ok:
w.fails = 0
if w.alerted:
w.alerted = False
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
_notify_telegram("[Box wieder ok]", ok_msg)
continue
w.fails += 1
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
if due:
prefix = "" if not w.alerted else "Immer noch: "
w.alerted = True
w.alert_ts = now
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
_notify_telegram("[Box-Problem]", prefix + fail_msg)
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
async def sentry_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
await asyncio.sleep(START_DELAY)
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
-35
View File
@@ -1,35 +0,0 @@
"""
Vertrauenswürdige Quellen + Kategorien für die automatische Modell-Entdeckung.
Rollen = die EINE Quelle der Wahrheit, identisch zu den llama-swap-Serving-Rollen
und der UI: fast · heavy · coder · vision · hermes (Agent-Hirn) · scout.
"""
# HF-Orgs, die zuverlässig aktuelle, hochwertige GGUF-Quants veröffentlichen.
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kanonische Rollen — eine Quelle der Wahrheit (deckt sich mit llamaswap.ROLE_IDS,
# maintenance.ROLE_MAP, frontend ModelBadges.ROLES + Discover.ROLE_METADATA).
# `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn".
ROLE_IDS = ["fast", "heavy", "coder", "vision", "hermes", "scout", "kritiker", "reranker"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
# Die `role` ist zugleich der Alias-Vorschlag und EINE der 5 kanonischen Rollen.
CATEGORIES = [
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
{"role": "hermes", "title": "Lucys Hirn (Agent)", "icon": "brain-circuit",
"kw": ["hermes"]}, # Agent-Hirn: Hermes-Familie am robustesten (natives Tool-Calling).
{"role": "heavy", "title": "Schweres Reasoning", "icon": "brain",
"kw": ["reasoning", "-think", "thinking", "gpt-oss", "deepseek-r", "-r1", "qwq",
"-70b", "-72b", "-120b", "-123b", "-235b", "-405b", "-a10b", "-a22b"]},
{"role": "fast", "title": "Schnelles Alltags-Hirn", "icon": "zap",
"kw": ["-a3b", "-a1", "-a2", "-30b", "-32b", "-14b", "-8b", "-7b", "-4b", "-moe"]},
{"role": "scout", "title": "Multimodal-Allrounder", "icon": "compass",
"kw": []}, # Fallback: instruct/chat-Modelle, die in keine Spezialrolle fallen
]
# Repo-Namensteile, die bei der Entdeckung übersprungen werden (Roh-/Spezialformate).
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
-202
View File
@@ -1,202 +0,0 @@
"""
System/OS-Metriken für die Box (Bosgame / Strix Halo).
CPU/RAM/Disk via psutil (plattformübergreifend). GPU-Auslastung/VRAM/Temperatur
via sysfs (amdgpu) — nur Linux; auf anderen Plattformen None (amd-smi fehlt auf
der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Bytes).
"""
import glob
import os
import subprocess
import threading
import psutil
from config import MODELS_DIR
def _read_int(path: str) -> int | None:
try:
with open(path) as f:
return int(f.read().strip())
except Exception:
return None
def _gpu_sysfs() -> dict | None:
"""AMD-GPU-Auslastung + Speicher via sysfs (Linux). Findet die Basis-Card
dynamisch (Strix Halo ist oft card1, nicht card0) und überspringt die
Connector-Verzeichnisse (card1-DP-1 …). Strix Halo nutzt Unified Memory →
GTT ist der eigentliche große Pool; VRAM ist nur der kleine Carve-out."""
for dev in sorted(glob.glob("/sys/class/drm/card*/device")):
card = dev.split("/")[-2] # z.B. "card1" oder "card1-DP-1"
if "-" in card: # Connector-Dir → kein GPU-Device
continue
busy = _read_int(f"{dev}/gpu_busy_percent")
if busy is None:
continue
return {
"busy_percent": busy,
"vram_used": _read_int(f"{dev}/mem_info_vram_used"),
"vram_total": _read_int(f"{dev}/mem_info_vram_total"),
"gtt_used": _read_int(f"{dev}/mem_info_gtt_used"),
"gtt_total": _read_int(f"{dev}/mem_info_gtt_total"),
}
return None
def _temps() -> dict | None:
"""CPU/GPU-Temperatur via hwmon (Linux). None bei Fehlen."""
out: dict = {}
for hw in glob.glob("/sys/class/hwmon/hwmon*"):
name = ""
try:
with open(f"{hw}/name") as f:
name = f.read().strip()
except Exception:
continue
t = _read_int(f"{hw}/temp1_input")
if t is None:
continue
c = round(t / 1000.0, 1)
if name in ("k10temp", "zenpower", "coretemp"):
out["cpu"] = c
elif name in ("amdgpu", "edge"):
out["gpu"] = c
return out or None
def get_git_info(path: str) -> dict | None:
expanded = os.path.expanduser(path)
if not os.path.isdir(expanded) or not os.path.exists(os.path.join(expanded, ".git")):
return None
try:
res = subprocess.run(
["git", "log", "-1", "--format=%h|%cd|%s", "--date=short"],
cwd=expanded, capture_output=True, text=True, timeout=3
)
if res.returncode != 0:
return None
parts = res.stdout.strip().split("|", 2)
h = parts[0]
d = parts[1]
s = parts[2] if len(parts) > 2 else ""
branch_res = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
branch = branch_res.stdout.strip() if branch_res.returncode == 0 else "unknown"
status_res = subprocess.run(
["git", "status", "--porcelain"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
dirty = bool(status_res.stdout.strip()) if status_res.returncode == 0 else False
return {
"hash": h,
"date": d,
"subject": s,
"branch": branch,
"dirty": dirty,
"path": expanded
}
except Exception:
return None
def find_hermes_agent_git() -> dict | None:
env_path = os.environ.get("MC_HERMES_AGENT_PATH")
if env_path:
info = get_git_info(env_path)
if info:
return info
candidates = [
"~/hermes-agent",
"~/.hermes/hermes-agent",
"~/.hermes"
]
for c in candidates:
info = get_git_info(c)
if info:
return info
return None
def get_engine_version() -> dict:
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
git_info = get_git_info(engine_path)
if git_info:
return {**git_info, "type": "git"}
candidates = [
os.path.join(engine_path, "llama-server"),
os.path.join(engine_path, "bin", "llama-server"),
"/usr/local/bin/llama-server",
"/usr/bin/llama-server",
"llama-server"
]
for binary in candidates:
if binary != "llama-server" and not os.path.exists(binary):
continue
try:
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
output = (res.stdout or "").strip() or (res.stderr or "").strip()
if output:
lines = output.splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
_VERSION_CACHE = {"ts": 0.0, "data": {}}
_VERSION_LOCK = threading.Lock()
def check_versions_cached() -> dict:
import time
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
# Lock gegen Scan-Stampede: FastAPI führt sync-Routen im Threadpool aus → ohne Lock würden
# parallele Dashboard-/Agent-Aufrufe die git-/Versions-Scans mehrfach gleichzeitig anwerfen.
# Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen.
with _VERSION_LOCK:
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
data = {
"mc2": get_git_info(mc2_path),
"engine": get_engine_version(),
"hermes_agent": find_hermes_agent_git()
}
_VERSION_CACHE["ts"] = now
_VERSION_CACHE["data"] = data
return data
def system_status() -> dict:
vm = psutil.virtual_memory()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = {"total": du.total, "used": du.used, "percent": du.percent}
except Exception:
disk = None
return {
"cpu": {"percent": psutil.cpu_percent(interval=0.1), "cores": psutil.cpu_count()},
"ram": {"total": vm.total, "used": vm.used, "percent": vm.percent},
"gpu": _gpu_sysfs(),
"temp": _temps(),
"disk": disk,
"versions": check_versions_cached(),
}
-99
View File
@@ -1,99 +0,0 @@
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
"""
import atexit
import json
import logging
import threading
import time
from pathlib import Path
from config import HERMES_HOME
STATS_FILE = HERMES_HOME / "token_stats.json"
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
log = logging.getLogger(__name__)
_lock = threading.Lock()
_stats: dict | None = None
_dirty = False
_last_flush = 0.0
def _load_from_disk() -> dict:
if not STATS_FILE.exists():
return dict(_BASELINE)
try:
with open(STATS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
data.setdefault("prompt_tokens", 0)
data.setdefault("completion_tokens", 0)
data.setdefault("models", {})
return data
except (OSError, json.JSONDecodeError):
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
return dict(_BASELINE)
def _ensure_loaded() -> dict:
global _stats
if _stats is None:
_stats = _load_from_disk()
return _stats
def _write(stats: dict) -> None:
try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f)
tmp.replace(STATS_FILE)
except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
with _lock:
return json.loads(json.dumps(_ensure_loaded()))
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
global _dirty, _last_flush
with _lock:
stats = _ensure_loaded()
stats["prompt_tokens"] += prompt
stats["completion_tokens"] += completion
if model:
m = stats.setdefault("models", {}).setdefault(
model.lower(), {"prompt": 0, "completion": 0})
m["prompt"] += prompt
m["completion"] += completion
_dirty = True
now = time.monotonic()
if now - _last_flush >= FLUSH_INTERVAL:
_write(stats)
_dirty = False
_last_flush = now
def flush() -> None:
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
global _dirty
with _lock:
if _dirty and _stats is not None:
_write(_stats)
_dirty = False
atexit.register(flush)
-187
View File
@@ -1,187 +0,0 @@
"""
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
Zwei Sichten auf dieselben Messungen:
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) avg/p50/p95/last je Stufe über die
letzten N Messungen. Gut für Trends (Wie schnell ist STT im Schnitt?").
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) jeder einzelne Chat-Turn als eigener Datensatz mit
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
**Was MC2 messen kann und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück messbar und als
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
im Hermes-LLM-Loop ohne Callback an MC2 für MC2 unsichtbar, sie stecken im Generierung"-Bucket.
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
letzte Retrieve werden global geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
In-Memory + thread-safe (keine Datei-I/O Latenz-Telemetrie ist transient, Restart = Reset).
"""
import threading
import time
import uuid
from collections import deque
_LOCK = threading.Lock()
_MAX = 200
_STAGES: dict[str, deque] = {}
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
def record_stage(stage: str, ms: float) -> None:
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
if ms is None or ms < 0:
return
with _LOCK:
dq = _STAGES.get(stage)
if dq is None:
dq = _STAGES[stage] = deque(maxlen=_MAX)
dq.append(float(ms))
def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED:
return
with _LOCK:
_PARKED[kind] = (float(ms), time.perf_counter())
def _take_stt(max_age: float = 20.0) -> float | None:
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
with _LOCK:
v = _PARKED.get("stt")
if v and (time.perf_counter() - v[1]) <= max_age:
_PARKED["stt"] = None
return round(v[0], 1)
return None
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
Turns) und frisch ist."""
with _LOCK:
v = _PARKED.get("retrieve")
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
_PARKED["retrieve"] = None
return round(v[0], 1)
return None
class Timer:
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
Funktioniert um `await`-Aufrufe herum (enter await exit)."""
def __init__(self, stage: str) -> None:
self.stage = stage
self._t0 = 0.0
def __enter__(self) -> "Timer":
self._t0 = time.perf_counter()
return self
def __exit__(self, *exc) -> None:
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
class TurnTrace:
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
self.id = uuid.uuid4().hex[:8]
self.ts = time.time()
self.perf0 = time.perf_counter()
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
self.session_id = (session_id or "")[:24]
self.kind = kind
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
self.had_images = False
self.error: str | None = None
def note_vision(self, ms: float) -> None:
self.vision_ms = round(ms, 1)
record_stage("vision", ms)
def mark_brain_start(self) -> None:
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
self._brain0 = time.perf_counter()
def note_ttfb(self) -> None:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms)
def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0
stt = _take_stt() # rollend bereits in /voice/stt erfasst
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = {
"id": self.id,
"ts": round(self.ts, 3),
"session": self.session_id,
"kind": self.kind,
"had_images": self.had_images,
"stt_ms": stt,
"vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"mem0_ms": mem0,
"total_ms": round(total, 1),
"error": self.error,
}
with _LOCK:
_TURNS.append(rec)
return rec
def _summary(vals: list[float]) -> dict:
if not vals:
return {"count": 0}
s = sorted(vals)
n = len(s)
return {
"count": n,
"avg_ms": round(sum(s) / n, 1),
"p50_ms": round(s[n // 2], 1),
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
"last_ms": round(vals[-1], 1),
}
def get_metrics() -> dict:
"""Rollende Zusammenfassung je Stufe."""
with _LOCK:
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
def get_trace(limit: int = 20) -> list[dict]:
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
with _LOCK:
return list(_TURNS)[-limit:][::-1]
-130
View File
@@ -1,130 +0,0 @@
"""
Hält das ganze WARM-SET (Hirn + Gedächtnis/embed) dauerhaft warm. Die Augen (vision/VL-30B)
sind seit 07.07. ON-DEMAND (ttl 900, User-Entscheid) und gehören NICHT mehr zum Warm-Set.
Hintergrund: llama-swap ist EIN-Gruppen-resident lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
Set über deploy/warmup.sh nach Hirn UND embed (sonst bliebe embed kalt, live vom
Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell.
warmup.sh deckt korrekt ab: fast über /v1/chat/completions, embed über /v1/embeddings
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast') + MC_WARMUP_EMBED (Default 'embed').
"""
import asyncio
import logging
import os
import subprocess
import httpx
from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None
def nudge() -> None:
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
solange der Wächter (noch) nicht läuft."""
if _loop is not None and _wake is not None and not _loop.is_closed():
try:
_loop.call_soon_threadsafe(_wake.set)
except RuntimeError:
pass
def _run_warmup() -> bool:
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
False, wenn das Skript fehlt."""
if not os.path.exists(_WARMUP_SH):
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False
try:
subprocess.Popen(["bash", _WARMUP_SH],
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return False
def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
except Exception:
return set()
want: set[str] = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or [])
return want
async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try:
async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {}
except Exception:
return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
want = _warmset_members()
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
return [] if ready else ["<leer>"]
return [m for m in want if m not in ready]
async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge lädt via warmup.sh nach."""
global _loop, _wake
_loop = asyncio.get_running_loop()
_wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True:
try:
missing = await _warmset_missing()
if missing:
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
_run_warmup()
except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup()
except asyncio.TimeoutError:
pass
-3
View File
@@ -1,3 +0,0 @@
.venv/
__pycache__/
*.pyc
-207
View File
@@ -1,207 +0,0 @@
"""
Hermes PC Executor läuft auf dem Windows PC.
Empfängt Tool-Befehle vom MCP-Server der AI Box und führt sie lokal aus.
"""
import base64
import hmac
import io
import os
import socket
import subprocess
import webbrowser
from urllib.parse import quote
import uvicorn
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI(title="Hermes PC Executor")
MY_PORT = int(os.environ.get("HERMES_PC_PORT", "7777"))
# Auf welchem Interface lauschen. Default 0.0.0.0 (LAN), per Env einschränkbar (z.B. die LAN-IP des PCs).
MY_HOST = os.environ.get("HERMES_PC_HOST", "0.0.0.0")
# ── Auth ───────────────────────────────────────────────────────────────────
# Shared Secret. OHNE Token sind die gefährlichen Endpunkte (Shell/Eingabe/Öffnen/Screenshot)
# fail-closed gesperrt → ein un-konfigurierter Executor ist KEINE offene Remote-Code-Execution mehr.
# Der Token muss identisch auf der Box (Hermes-Env PC_EXECUTOR_TOKEN → mcp_pc.py) gesetzt sein.
AUTH_TOKEN = os.environ.get("HERMES_PC_TOKEN", "").strip()
def require_auth(authorization: str | None = Header(default=None)) -> None:
"""Bearer-Token-Prüfung (konstante Zeit). 503 wenn der Executor ohne Token läuft (fail-closed),
401 bei fehlendem/falschem Token."""
if not AUTH_TOKEN:
raise HTTPException(
503,
"Executor ohne HERMES_PC_TOKEN gestartet — Steuer-Endpunkte sind aus Sicherheitsgründen "
"gesperrt. Setze die Env-Variable HERMES_PC_TOKEN (identisch zur Box) und starte neu.",
)
expected = f"Bearer {AUTH_TOKEN}"
if not authorization or not hmac.compare_digest(authorization, expected):
raise HTTPException(401, "Ungültiges oder fehlendes Bearer-Token.")
# ── Shell ──────────────────────────────────────────────────────────────────
@app.post("/shell", dependencies=[Depends(require_auth)])
async def run_shell(req: dict):
cmd = req.get("command", "")
try:
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace",
)
return {
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
"returncode": result.returncode,
}
except subprocess.TimeoutExpired:
raise HTTPException(408, "Timeout nach 60s")
except Exception as e:
raise HTTPException(500, str(e))
# ── Screen ─────────────────────────────────────────────────────────────────
@app.post("/screenshot", dependencies=[Depends(require_auth)])
async def take_screenshot():
try:
import mss
from PIL import Image
with mss.mss() as sct:
monitor = sct.monitors[1]
raw = sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
img.thumbnail((1280, 720))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=75)
return {"screenshot": base64.b64encode(buf.getvalue()).decode()}
except Exception as e:
raise HTTPException(500, str(e))
# ── Input ──────────────────────────────────────────────────────────────────
@app.post("/type", dependencies=[Depends(require_auth)])
async def type_text(req: dict):
text = req.get("text", "")
try:
import pyautogui
pyautogui.write(text, interval=0.03)
return {"ok": True}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/key", dependencies=[Depends(require_auth)])
async def press_keys(req: dict):
keys = req.get("keys", "")
try:
import pyautogui
parts = [k.strip() for k in keys.split("+")]
pyautogui.hotkey(*parts)
return {"ok": True}
except Exception as e:
raise HTTPException(500, str(e))
# ── Medien & Lautstärke (A3: Lucy steuert den PC per Sprache) ──────────────
# Media-Keys wirken auf den AKTIVEN Player (Spotify, YouTube, VLC …) — genau wie
# die Tasten auf einer Multimedia-Tastatur. Lautstärke = System-Master (2 % je Schritt).
MEDIA_KEYS = {"play_pause": "playpause", "next": "nexttrack", "prev": "prevtrack", "stop": "stop"}
VOLUME_KEYS = {"up": "volumeup", "down": "volumedown", "mute": "volumemute"}
@app.post("/media", dependencies=[Depends(require_auth)])
async def media_control(req: dict):
action = (req.get("action") or "").strip().lower()
key = MEDIA_KEYS.get(action)
if not key:
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(MEDIA_KEYS)}")
try:
import pyautogui
pyautogui.press(key)
return {"ok": True, "action": action}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/volume", dependencies=[Depends(require_auth)])
async def volume_control(req: dict):
action = (req.get("action") or "").strip().lower()
key = VOLUME_KEYS.get(action)
if not key:
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(VOLUME_KEYS)}")
steps = 1 if action == "mute" else max(1, min(25, int(req.get("steps", 5))))
try:
import pyautogui
pyautogui.press(key, presses=steps, interval=0.02)
return {"ok": True, "action": action, "steps": steps}
except Exception as e:
raise HTTPException(500, str(e))
# ── Apps / Browser ─────────────────────────────────────────────────────────
@app.post("/open", dependencies=[Depends(require_auth)])
async def open_target(req: dict):
target = req.get("target", "")
try:
if target.startswith("http://") or target.startswith("https://"):
webbrowser.open(target)
else:
os.startfile(target)
return {"ok": True}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/search", dependencies=[Depends(require_auth)])
async def search_web(req: dict):
query = req.get("query", "")
url = f"https://www.google.com/search?q={quote(query)}"
webbrowser.open(url)
return {"ok": True, "url": url}
# ── Health ─────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok", "host": socket.gethostname()}
def _get_local_ip() -> str:
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return socket.gethostbyname(socket.gethostname())
def _ensure_streams() -> None:
"""Unter pythonw (kein Konsolenfenster) sind sys.stdout/stderr = None →
uvicorns Logging crasht beim Start. Dann auf eine Logdatei umbiegen."""
import sys
if sys.stdout is not None and sys.stderr is not None:
return
logdir = os.path.join(
os.environ.get("LOCALAPPDATA", os.path.dirname(os.path.abspath(__file__))),
"HermesPCExecutor",
)
os.makedirs(logdir, exist_ok=True)
f = open(os.path.join(logdir, "executor.log"), "a", buffering=1, encoding="utf-8")
sys.stdout = f
sys.stderr = f
if __name__ == "__main__":
_ensure_streams()
my_ip = _get_local_ip()
auth_state = "Token AKTIV" if AUTH_TOKEN else "KEIN Token → Steuer-Endpunkte GESPERRT (fail-closed)"
print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT} (bind {MY_HOST}) — {auth_state}")
uvicorn.run(app, host=MY_HOST, port=MY_PORT)
-8
View File
@@ -1,8 +0,0 @@
@echo off
cd /d "%~dp0"
echo === Hermes PC Executor Setup ===
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt -q
echo.
echo Fertig. Starte mit start.bat
pause
-6
View File
@@ -1,6 +0,0 @@
fastapi>=0.111.0
uvicorn>=0.30.0
httpx>=0.27.0
mss>=9.0.1
Pillow>=10.0.0
pyautogui>=0.9.54
-9
View File
@@ -1,9 +0,0 @@
@echo off
cd /d "%~dp0"
echo === Hermes PC Executor ===
echo Port: 7777
echo Hermes (AI Box) kann jetzt auf diesen PC zugreifen.
echo Fenster offen lassen solange Hermes PC-Zugriff braucht.
echo.
.venv\Scripts\python executor.py
pause
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
#
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
#
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
set -uo pipefail
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
API="${MC_API:-http://127.0.0.1:9001}"
SLUG="$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
status(){ # $1=state $2=detail
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, branch, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = p + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, p)
PY
}
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
status "laeuft" "Merge wird vorbereitet"
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
PRE="$(git rev-parse origin/main)"
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
cleanup_wt
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "origin/$BRANCH" -m "Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)" >>"$LOG" 2>&1; then
git -C "$WT" merge --abort 2>/dev/null || true
fail "Merge-Konflikt mit main — der Branch ist veraltet, bitte am PC auflösen"
fi
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
if [ -n "${PYS// /}" ]; then
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
# shellcheck disable=SC2086
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
fi
status "laeuft" "Push nach main läuft"
PUSHED=0
for _ in 1 2 3; do
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
sleep 5
done
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
cleanup_wt
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
DEPLOY_OK=1
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
HEALTH=0
if [ "$DEPLOY_OK" = 1 ]; then
for _ in $(seq 1 18); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
sleep 5
done
fi
if [ "$HEALTH" = 1 ]; then
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "Deploy grün, Health-Check bestanden"
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
exit 0
fi
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
status "rollback" "Health rot — automatischer Revert läuft"
git fetch -q origin || true
cleanup_wt
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
cleanup_wt
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
R=0
for _ in $(seq 1 12); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
sleep 5
done
if [ "$R" = 1 ]; then
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
fi
cleanup_wt
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
exit 2
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env bash
# Wöchentlicher Auto-Update-Lauf der Box (Autonomie E2). Läuft als User via
# mc2-autoupdate.timer (So 04:30, nach dem 03:30-Backup) oder manuell.
#
# Prinzip „Haushaltsgerät": Fremd-Software (Router → Engine → Hermes) updatet sich
# selbst über die BESTEHENDEN Pfade (update-swap.sh / update-engine.sh / hermes-update-Job).
# Jede Ebene: Update-Check → Update → Postcheck. Rot ⇒ Rollback + SELBST-PINNING im
# Pin-Register + Telegram-Meldung. Grün ⇒ Telegram „eingespielt X→Y".
# Gepinnte Ebenen werden übersprungen, bis der Pin manuell gelöst wird.
#
# OS-Ebene: bewusst NICHT hier — unattended-upgrades (nur Security) wird in der
# einmaligen sudo-Session eingerichtet (siehe deploy/sudoers-mc2-autonomie).
# Modelle: NIE automatisch (User entscheidet nach Bench, Etappe E5).
#
# Router/Engine laufen als root: braucht die NOPASSWD-Regel aus
# deploy/sudoers-mc2-autonomie. Fehlt sie, wird die Ebene übersprungen + gemeldet.
set -uo pipefail # bewusst KEIN -e: jede Ebene wird kontrolliert abgearbeitet
API="${MC_API:-http://127.0.0.1:9001}"
PINS="${MC_PINS_FILE:-/srv/models/mc2-pins.json}"
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
HERMES_DIR="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
BACKUP_DIR="${MC_BACKUP_DIR:-/srv/models/mc2-backups}"
JOB_TIMEOUT="${MC_AUTOUPDATE_JOB_TIMEOUT:-1200}" # Sekunden für den Hermes-Job
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
say(){ echo "[autoupdate] $*"; }
notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; }
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
# ── Pin-Register ─────────────────────────────────────────────────────────────
pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; }
is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; }
pin_info(){ jq -r --arg k "$1" '.[$k] | "\(.version // "?") seit \(.datum // "?") (\(.grund // "?"))"' "$PINS" 2>/dev/null; }
set_pin(){ # $1 komponente $2 version(known-good) $3 grund
local tmp; tmp="$(mktemp)"
jq --arg k "$1" --arg v "$2" --arg g "$3" --arg d "$(date +%F)" \
'.[$k] = {pinned: true, version: $v, grund: $g, datum: $d}' "$PINS" > "$tmp" && mv "$tmp" "$PINS"
}
# ── Helfer ───────────────────────────────────────────────────────────────────
details(){ curl -sf --max-time 60 "$API/api/maintenance/update-details?kind=$1"; }
# „darf ich das PASSWORTLOS?" — sudo -n -l <cmd> reicht nicht (Exit 0 auch wenn nur
# MIT Passwort erlaubt); deshalb explizit die NOPASSWD-Zeilen der Whitelist greppen.
sudo_ok(){ sudo -n -l 2>/dev/null | grep -F "NOPASSWD" | grep -qF "$1"; }
# Router und Engine teilen denselben Ablauf: root-Skript mit eingebautem
# Postcheck+Rollback (Exit 0 grün / 1 zurückgerollt / 2 kaputt).
run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4 installed $5 latest
local comp="$1" script="$2" name="$3" installed="$4" latest="$5" rc
say "$name: Update $installed$latest wird eingespielt…"
sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$?
case "$rc" in
0) SUMMARY+=("$name: $installed$latest eingespielt, Stack-Check grün.")
notify "$name aktualisiert: $installed$latest. Stack-Check grün, alles läuft." ;;
1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün"
SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.")
notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;;
*) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
notify "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
esac
}
check_layer_root(){ # $1 komponente $2 skript $3 anzeigename $4 details-kind
local comp="$1" script="$2" name="$3" kind="$4" det installed latest
if is_pinned "$comp"; then
say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen."
SUMMARY+=("$name: gepinnt, übersprungen.")
return
fi
det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; SUMMARY+=("$name: Check fehlgeschlagen."); return; }
installed="$(jq -r '.installed_build // empty' <<<"$det")"
latest="$(jq -r '.latest_build // empty' <<<"$det")"
if [ -z "$installed" ] || [ -z "$latest" ]; then
say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')."
SUMMARY+=("$name: Versions-Check unklar, nichts getan.")
return
fi
if [ "$latest" -le "$installed" ] 2>/dev/null; then
say "$name: aktuell ($installed)."
SUMMARY+=("$name: aktuell ($installed).")
return
fi
if ! sudo_ok "$script"; then
say "$name: Update $installed$latest verfügbar, aber sudo-Freischaltung fehlt."
SUMMARY+=("$name: Update $installed$latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren).")
return
fi
run_root_update "$comp" "$script" "$name" "$installed" "$latest"
}
# ── Hermes-Agent (User-Space, via MC2-Job + Polling; Rollback machen WIR) ────
check_hermes(){
local name="Hermes-Agent" det behind old_head resp job_id state t
if is_pinned hermes; then
say "$name: GEPINNT ($(pin_info hermes)) — übersprungen."
SUMMARY+=("$name: gepinnt, übersprungen.")
return
fi
det="$(details hermes)" || { SUMMARY+=("$name: Check fehlgeschlagen."); return; }
behind="$(jq -r '.behind // 0' <<<"$det")"
if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then
say "$name: aktuell."
SUMMARY+=("$name: aktuell.")
return
fi
old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)"
say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…"
resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update")" || { SUMMARY+=("$name: Job-Start fehlgeschlagen."); return; }
job_id="$(jq -r '.job_id // empty' <<<"$resp")"
if [ -z "$job_id" ]; then
say "$name: kein Job gestartet: $resp"
SUMMARY+=("$name: Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null)).")
return
fi
t=0
while [ "$t" -lt "$JOB_TIMEOUT" ]; do
state="$(curl -sf --max-time 15 "$API/api/jobs" | jq -r --arg id "$job_id" '.jobs[] | select(.id==$id) | .state' 2>/dev/null)"
case "$state" in done|failed|canceled) break ;; esac
sleep 10; t=$((t + 10))
done
if [ "$state" = "done" ]; then
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
return
fi
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
# Autonomie 0g. Nur Config, reversibel, hart gegatet; scheitert es, fällt es sauber in den Rollback.
say "$name: Job endete '$state' — versuche Selbstreparatur (Config) vor dem Rollback…"
local sr_out sr_rc sugg=""
sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$?
echo "$sr_out"
if [ "$sr_rc" -eq 0 ]; then
SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).")
return
fi
sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)"
# Rollback: Code auf alten Stand, Config aus dem frischen Backup, Neustart, Gehirn-Check.
say "$name: Selbstreparatur griff nicht — ROLLBACK auf $old_head"
if [ -n "$old_head" ]; then
git -C "$HERMES_DIR" reset --hard "$old_head" >/dev/null 2>&1
local bak stage
bak="$(ls -1t "$BACKUP_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1)"
if [ -n "$bak" ]; then
stage="$(mktemp -d)"
tar -xzf "$bak" -C "$stage" ./hermes 2>/dev/null || tar -xzf "$bak" -C "$stage" hermes 2>/dev/null
[ -f "$stage/hermes/config.yaml" ] && cp -a "$stage/hermes/config.yaml" "$HOME/.hermes/config.yaml"
[ -f "$stage/hermes/.env" ] && cp -a "$stage/hermes/.env" "$HOME/.hermes/.env"
rm -rf "$stage"
fi
systemctl --user restart hermes-gateway; sleep 6
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün"
SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.")
notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+
Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
return
fi
fi
SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
}
# ── Lauf ─────────────────────────────────────────────────────────────────────
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
pins_init
if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then
notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen."
exit 1
fi
check_layer_root swap update-swap.sh "Router (llama-swap)" swap
check_layer_root engine update-engine.sh "Engine (llama.cpp)" engine
check_hermes
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
$(printf '• %s\n' "${SUMMARY[@]}")"
say "Fertig."
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env bash
# Voll-Zustands-Backup der AI-Box: alles, was NICHT aus Git oder per Re-Download
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
#
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
#
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
#
# Off-Box (C12): nach dem lokalen Tarball wird der Backup-Ordner per rsync auf ein
# ZWEITES Gerät gespiegelt (Default: Proxmox-Host), damit ein Plattenausfall der Box
# nicht auch die Backups mitnimmt. Fehlschlag ist NICHT fatal — das lokale Backup gilt.
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
DEST_DIR="$MODELS_DIR/mc2-backups"
RETAIN="${MC_BACKUP_RETAIN:-14}"
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
TS="$(date +%Y%m%d-%H%M%S)"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
[ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true
# --- Known-Good-Manifest -----------------------------------------------------
# Versions-Schnappschuss des LAUFENDEN Stacks: pip freeze je venv, Engine-Build,
# Git-Stände, Modell-Liste. Zweck: die requirements sind teils ungepinnt (>=) —
# bei einer Neuinstallation sagen DIESE Listen, welche Versionen nachweislich
# zusammen liefen. Rein informativ, restore.sh fasst es nicht an.
# Alles best-effort: ein fehlendes venv/Tool darf das Backup nie abbrechen.
KG="$STAGE/known-good"
mkdir -p "$KG"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
"$1" -m pip freeze > "$2" 2>/dev/null \
|| "$UV" pip freeze --python "$1" > "$2" 2>/dev/null \
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
}
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
{
echo "known-good-Stand vom $TS auf $(hostname)"
echo "os : $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-?}") · kernel $(uname -r)"
echo "mc2 : $(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo '?') ($(git -C "$SRC" log -1 --format=%cs 2>/dev/null || echo '?'))"
echo "hermes-agent : $(git -C "$HERMES_AGENT" rev-parse --short HEAD 2>/dev/null || echo '?')"
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
echo "python-venvs :"
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
echo " $P$("$P" --version 2>&1 || echo 'fehlt')"
done
} > "$KG/versions.txt" || true
# Modell-Bestand (Namen + Größen): welche GGUF/mmproj-Dateien liefen zusammen.
find "$MODELS_DIR" -maxdepth 3 \( -name '*.gguf' -o -name '*mmproj*' \) -print0 2>/dev/null \
| xargs -r -0 du -h 2>/dev/null | sort -k2 > "$KG/models.txt" || true
cat > "$STAGE/MANIFEST.txt" <<EOF
mc2-state backup
created : $TS
host : $(hostname)
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
EOF
mkdir -p "$DEST_DIR"
OUT="$DEST_DIR/mc2-state-$TS.tar.gz"
tar -czf "$OUT" -C "$STAGE" .
chmod 600 "$OUT" # enthält .env (Secrets)
# Retention: nur die letzten N behalten.
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f
echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))"
# --- Off-Box-Spiegel (C12) -------------------------------------------------
# Spiegelt die Tarballs 1:1 aufs Zweitgerät; --delete zieht die Retention mit,
# sodass die Off-Box-Kopie nicht unbegrenzt wächst. Perms (600) bleiben erhalten.
if [ -n "$OFFSITE" ]; then
if [ -f "$OFFSITE_KEY" ]; then
if rsync -a \
-e "ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" \
--include='mc2-state-*.tar.gz' --exclude='*' --delete \
"$DEST_DIR/" "$OFFSITE/"; then
echo "OK — Off-Box-Kopie gespiegelt → $OFFSITE"
else
echo "WARN — Off-Box-Sync fehlgeschlagen (lokales Backup ist gültig): $OFFSITE" >&2
fi
else
echo "WARN — Off-Box-Key fehlt ($OFFSITE_KEY) → nur lokales Backup." >&2
fi
fi
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Brain-Config-Matrix (Referenz vom 02.07.2026): misst Qwen3.6 in mehreren Betriebsarten
# (MTP n-max / ohne Spec / KV-Quant) auf separatem Port. Für künftige Re-Benchmarks nach
# Engine-Updates oder bei neuen Hirn-Kandidaten. Ergebnis-Referenz (02.07., Vulkan, b9843):
# mtp3 78,6 t/s · mtp4 63,7 · ohne Spec 62,4 · mtp3+KVq8 78,6 (=gratis) · nospec+KVq8 62,1
set -u
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
PORT="${BENCH_PORT:-5899}"
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
run() { # $1=name $2=extra-flags
echo "=== $1 ==="
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" $BASE $2 >/tmp/brain-bench-server.log 2>&1 &
local PID=$!
for i in $(seq 1 120); do
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
sleep 2
kill -0 $PID 2>/dev/null || { echo " LOAD-CRASH"; tail -2 /tmp/brain-bench-server.log; return; }
done
for r in 0 1 2; do
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
-d '{"prompt":"Erklaere in drei kurzen Saetzen, was ein Mixture-of-Experts-Modell ist.","n_predict":150,"temperature":0}' \
> /tmp/brain-bench-probe.json
[ "$r" = "0" ] && continue # Warmlauf verwerfen
python3 - <<'PY'
import json
t = json.load(open("/tmp/brain-bench-probe.json")).get("timings", {})
print(f" tg={t.get('predicted_per_second',0):.1f} t/s"
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
PY
done
kill $PID 2>/dev/null; wait $PID 2>/dev/null; sleep 3
}
run "mtp3 (live-Config)" "--spec-type draft-mtp --spec-draft-n-max 3 -ctk q8_0 -ctv q8_0"
run "mtp4" "--spec-type draft-mtp --spec-draft-n-max 4 -ctk q8_0 -ctv q8_0"
run "ohne Spec" "-ctk q8_0 -ctv q8_0"
run "mtp3 KV-f16" "--spec-type draft-mtp --spec-draft-n-max 3"
echo "BENCH_DONE"
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Modell-Bench (Autonomie-Plan E5): misst pp/tg eines GGUF auf separatem Port, ohne die
# Live-Instanzen anzufassen. Grundlage der Modell-Selbst-Evaluation (Radar-Kandidaten).
# Nutzung: bash model-bench.sh <gguf-pfad> [extra llama-server-flags, z.B. --mmproj ...]
# Lehren vom 02.07.2026 eingebaut: python via Heredoc (kein f-String-Quoting-Bruch),
# Warmlauf vor der Messung, LOAD-CRASH wird gemeldet statt still zu hängen.
set -u
MODEL="${1:?Nutzung: model-bench.sh <gguf-pfad> [extra-flags]}"
shift || true
EXTRA="$*"
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
PORT="${BENCH_PORT:-5899}"
CTX="${BENCH_CTX:-32768}"
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
>/tmp/model-bench-server.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
for i in $(seq 1 240); do
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
sleep 2
kill -0 $PID 2>/dev/null || { echo "LOAD-CRASH:"; tail -3 /tmp/model-bench-server.log; exit 1; }
done
probe() {
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
-d '{"prompt":"Erklaere in fuenf Saetzen, wie ein Backup-Konzept fuer einen Homeserver aussieht.","n_predict":200,"temperature":0}' \
> /tmp/model-bench-probe.json
python3 - <<'PY'
import json
t = json.load(open("/tmp/model-bench-probe.json")).get("timings", {})
print(f" pp={t.get('prompt_per_second',0):.0f} t/s · tg={t.get('predicted_per_second',0):.1f} t/s"
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
PY
}
probe >/dev/null 2>&1 # Warmlauf (Shader-/Graph-Compile nicht mitmessen)
probe
probe
echo "BENCH_DONE"
-24
View File
@@ -1,24 +0,0 @@
[Unit]
Description=Box-Konsole — ttyd web terminal wrapping a login shell (direkter Box-Zugriff)
Documentation=https://github.com/tsl0922/ttyd
After=network.target
[Service]
# Exponiert eine echte interaktive Login-Shell (bash -l) der Box als Web-Terminal — der
# direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst
# zum hermes-terminal (das die Agent-CLI zeigt); dieser hier zeigt die nackte Shell.
#
# SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy
# (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von
# außen dicht → keine eigene Firewall-Regel nötig. Login-Schutz: ttyd startet die Shell über
# `su - hitonabi` → fragt beim Öffnen das Box-Passwort ab (kein gespeichertes Passwort, PAM
# prüft gegen das echte Konto). Trusted-Home-LAN, kein Internet-Exposure.
Type=simple
# --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy).
# `su - hitonabi` = Passwort-Login (Box-Passwort) vor der Shell. -t = dunkles Theme.
ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/su - hitonabi
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# Frontend bauen (vor jedem Deploy auf dem Entwickler-PC). Output → frontend/dist,
# das committet wird (kein Node-Build auf der Box).
set -euo pipefail
cd "$(dirname "$0")/../frontend"
npm ci
npm run build
echo "OK — frontend/dist gebaut."
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
set -uo pipefail
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
MC="$HOME/mission-control-v2"
VAULT="$HOME/wissens-vault"
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
cat "$MC/deploy/chef-gutachter-prompt.md"
echo
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
EVID="$(
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
git -C "$MC" fetch -q origin 2>/dev/null || true
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
echo
echo "### main-Commits der letzten 24 h:"
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
echo
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
echo
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
| sort | uniq -c | sort -rn | head -6
echo
echo "### Aktivitäts-Insights (1 Tag):"
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
)"
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
print(out)
except Exception:
pass'
}
RICHTER="$CHEF"
VERDIKT="$(judge "$CHEF" 420 2600)"
if [ -z "${VERDIKT// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
fi
echo "$EVID"
echo
if [ -z "${VERDIKT// /}" ]; then
echo "## CHEF-VERDIKT: AUSGEFALLEN"
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
else
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
echo "$VERDIKT"
fi
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
if [ -n "${VERDIKT// /}" ]; then
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
"source": "chef-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
fi
-15
View File
@@ -1,15 +0,0 @@
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
Regeln:
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
die GLM-Vertretung).
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
von dir.
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# Wrapper fuer "git commit", der das Frontend automatisch baut, wenn Dateien geaendert wurden.
# Benutzung: ./deploy/commit.sh -m "deine commit message"
set -euo pipefail
# In das Projekt-Root wechseln
cd "$(dirname "$0")/.."
FRONTEND_DIR="frontend"
# Pruefen, ob Dateien im Frontend-Ordner fuer den Commit vorgemerkt (staged) sind,
# die einen Build erfordern (src, index.html, package.json, etc.).
if git diff --cached --name-only | grep -q "^$FRONTEND_DIR/src/\|^$FRONTEND_DIR/index.html\|^$FRONTEND_DIR/package.json\|^$FRONTEND_DIR/vite.config.ts"; then
echo "=== Frontend-Aenderungen in der Stage entdeckt. Starte Build... ==="
(cd "$FRONTEND_DIR" && npm run build)
# Das neu gebaute dist-Verzeichnis direkt mit stagen
git add "$FRONTEND_DIR/dist"
echo "=== Build abgeschlossen und frontend/dist gestaged. ==="
else
echo "=== Keine Frontend-Aenderungen in der Stage. Ueberspringe Build. ==="
fi
# Führe den eigentlichen Commit mit den übergebenen Parametern durch
exec git commit "$@"
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env bash
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
#
# Erstinstallation (einmalig):
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
# bash ~/mission-control-v2/deploy/deploy.sh
set -euo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
cd "$SRC"
git fetch -q origin && git reset -q --hard origin/main
# venv + Abhängigkeiten
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
else
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
fi
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen.
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
if [ -d "$HOME/.hermes" ]; then
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
"$HOME/.hermes/plugins/mc2-memory/"
fi
# systemd-USER-Units installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
# MC2-Diät (Hermes Desktop, 07/2026): das Hermes-Terminal-ttyd (:7681) und das standalone
# hermes-webui (nesquena, Karteileiche seit dem Built-in-UI-Umbau) sind entfallen — die
# Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/. Einmalige, idempotente Stilllegung:
systemctl --user disable --now hermes-terminal 2>/dev/null || true
systemctl --user disable --now hermes-webui 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-webui.service"
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet.
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Voice-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
# Wöchentliches Auto-Update (Router/Engine/Hermes, Rollback+Pin+Telegram) — Autonomie E2.
cp "$SRC/deploy/mc2-autoupdate.service" "$HOME/.config/systemd/user/mc2-autoupdate.service"
cp "$SRC/deploy/mc2-autoupdate.timer" "$HOME/.config/systemd/user/mc2-autoupdate.timer"
# Tägliche Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv, Alarm bei Rot) — Autonomie 0d.
cp "$SRC/deploy/mc2-selfsmoke.service" "$HOME/.config/systemd/user/mc2-selfsmoke.service"
cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.timer"
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
cp "$SRC/deploy/radar-feed.sh" "$HOME/.hermes/scripts/radar-feed.sh"
# Selbstkritik-Cron (Faden 11b): Zwilling des Radars, Blick nach INNEN (Latenzen/Journal/Skills).
cp "$SRC/deploy/selbstkritik-feed.sh" "$HOME/.hermes/scripts/selbstkritik-feed.sh"
# Traum-Cron (Dreaming, Phase 2): Blick auf das GELERNTE → Wissens-Vault. Bootstrappt ~/wissens-vault.
cp "$SRC/deploy/dreaming-feed.sh" "$HOME/.hermes/scripts/dreaming-feed.sh"
# Fremdblick-Helfer: Ein-Schuss-Zweitmeinung von einem anderen Modell (umgeht 64K-Delegations-Floor).
cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
# Worker-Helfer (Orchestrator): Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstück zu fremdblick).
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
mkdir -p "$HOME/.hermes/skills/wartung"
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
# Orchestrator-Skill (Autonomie): Werkstatt verallgemeinert — zerlegen → an Worker delegieren → gaten.
mkdir -p "$HOME/.hermes/skills/orchestrator"
cp "$SRC/deploy/skills/orchestrator/SKILL.md" "$HOME/.hermes/skills/orchestrator/SKILL.md"
# PC-Pfad-Cache-Skill (Traum-Entwurf 09.07.2026, aufbereitet): Cache lesen → bei Miss pc_shell-Tool → merken.
mkdir -p "$HOME/.hermes/skills/pc-pfad-cache"
cp "$SRC/deploy/skills/pc-pfad-cache/SKILL.md" "$HOME/.hermes/skills/pc-pfad-cache/SKILL.md"
cp "$SRC/deploy/skills/pc-pfad-cache/pc_path_lookup.sh" "$HOME/.hermes/scripts/pc_path_lookup.sh"
chmod +x "$HOME/.hermes/scripts/pc_path_lookup.sh"
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
# Hermes-Update mit → hält die Einbettung frisch. Best-effort + Build-zu-Temp-dann-Swap, damit ein
# Fehlschlag die laufende web_dist NICHT leert (emptyOutDir).
cp "$SRC/deploy/hermes-builtin-ui.service" "$HOME/.config/systemd/user/hermes-builtin-ui.service"
_HUI_WEB="$HOME/.hermes/hermes-agent/web"
_HUI_DIST="$HOME/.hermes/hermes-agent/hermes_cli/web_dist"
if [ -d "$_HUI_WEB" ] && [ -x "$HOME/.hermes/node/bin/npx" ]; then
if ( cd "$_HUI_WEB" && PATH="$HOME/.hermes/node/bin:$PATH" npx --no-install vite build \
--base=/hermes-ui/ --outDir /tmp/hermes-ui-build --emptyOutDir ) >/tmp/hermes-ui-build.log 2>&1; then
# Dienst VOR dem Tausch stoppen — sonst crasht der laufende `hermes serve`, wenn er index.html
# im Lösch-Fenster (rm→cp) liest (FileNotFoundError → Start-Limit → tot). Der reguläre restart
# weiter unten bringt ihn sauber auf der neuen web_dist hoch.
systemctl --user stop hermes-builtin-ui 2>/dev/null || true
rm -rf "$_HUI_DIST" && cp -r /tmp/hermes-ui-build "$_HUI_DIST" && echo "Hermes-GUI (base=/hermes-ui/) gebaut."
else
echo "WARN: Hermes-GUI-Build fehlgeschlagen (siehe /tmp/hermes-ui-build.log) — bestehende web_dist bleibt."
fi
fi
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable box-console >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable voice-service >/dev/null 2>&1 || true
systemctl --user enable hermes-builtin-ui >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-autoupdate.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
# Eingebaute Hermes-Web-GUI (Loopback :9119) (re)starten — MC2 bettet sie unter /hermes-ui/ ein.
# reset-failed, damit ein zuvor am Start-Limit gestorbener Dienst wieder anläuft.
systemctl --user reset-failed hermes-builtin-ui 2>/dev/null || true
systemctl --user restart hermes-builtin-ui 2>/dev/null || true
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
sleep 2
echo "--- Health ---"
curl -sf http://127.0.0.1:9001/api/health && echo
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
bash "$SRC/deploy/warmup.sh" || true
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Feed für den Traum-Cron (Dreaming / Continual-Learning-Kreislauf, Phase 2 — Capstone).
# Zwilling von radar-feed.sh (Blick nach draußen) und selbstkritik-feed.sh (Blick auf den
# Betrieb); der Traum blickt auf das GELERNTE: Sessions, Skills, Gedächtnis, Muster.
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
# Prompt eingespeist: versionierter Auftrag (deploy/dreaming-prompt.md) + Live-Schlaf-Daten.
set -uo pipefail
VAULT="$HOME/wissens-vault"
# --- Vault-Bootstrap (idempotent): eigenes git-Repo, menschenlesbar, NICHT im Stack-Code. ---
if [ ! -d "$VAULT/.git" ]; then
mkdir -p "$VAULT/traeume" "$VAULT/muster" "$VAULT/skill-kandidaten"
git -C "$VAULT" init -q 2>/dev/null || true
# Lokale Identität nur setzen, falls global keine da ist (Commits sollen nie scheitern).
git -C "$VAULT" config user.email >/dev/null 2>&1 || git -C "$VAULT" config user.email "lucy@box.local"
git -C "$VAULT" config user.name >/dev/null 2>&1 || git -C "$VAULT" config user.name "Lucy (Traum)"
if [ ! -f "$VAULT/INDEX.md" ]; then
cat > "$VAULT/INDEX.md" <<'EOF'
# Wissens-Vault — Navigationskarte
Menschenlesbare, git-versionierte Wissensschicht NEBEN dem Live-Gedächtnis (Mem0).
Angelegt von Lucys nächtlichem Traum. Verlinkung im Obsidian-Stil `[[dateiname]]`.
## Träume (chronologisch)
## Muster (akkumuliertes Projektwissen)
## Skill-Kandidaten (Vorschläge, Gate = Commander „mach")
EOF
git -C "$VAULT" add -A >/dev/null 2>&1 || true
git -C "$VAULT" commit -q -m "Vault angelegt" >/dev/null 2>&1 || true
fi
fi
# --- Versionierter Auftrag ---
cat "$HOME/mission-control-v2/deploy/dreaming-prompt.md"
echo
echo "## SCHLAF-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
# Hinweis: erst voll einsammeln, DANN kürzen — sonst schließt `head` die Pipe früh,
# der Produzent bekommt SIGPIPE, pipefail feuert und die Fallback-Zeile lügt "nicht verfügbar".
echo "### Insights der letzten 7 Tage (Token-/Tool-/Aktivitätsmuster):"
_ins="$(bash -lc "hermes insights --days 7" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_ins:-(insights nicht verfügbar)}" | head -62
echo
echo "### Session-Store (Umfang):"
_sst="$(bash -lc "hermes sessions stats" 2>/dev/null)"
echo "${_sst:-(sessions stats nicht verfügbar)}" | head -12
echo
echo "### Jüngste Sessions (woran zuletzt gearbeitet wurde):"
_sls="$(bash -lc "hermes sessions list" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_sls:-(sessions list nicht verfügbar)}" | head -25
echo
echo "### Gelernt bisher (journey — Skills + Gedächtnis-Knoten, kompakt):"
bash -lc "hermes journey --json" 2>/dev/null \
| jq -c '{stats: .stats, skills: ([.nodes[]? | select(.kind=="skill" or .type=="skill") | .label // .name] | .[0:25]), cluster_labels: ([.clusters[]?.label] | .[0:15])}' \
2>/dev/null || echo "(journey nicht verfügbar)"
echo
echo "### Curator / Skill-Hygiene (was brachliegt, was aktiv ist):"
bash -lc "hermes curator status" 2>/dev/null | head -26 || echo "(curator status nicht verfügbar)"
echo
echo "### Bisheriger Vault-INDEX (nur zur Ansicht — damit du nichts doppelt anlegst + verlinken"
echo "### kannst). Die echte Datei liegt unter ~/wissens-vault/INDEX.md; zwischen den Markern"
echo "### steht ihr AKTUELLER Inhalt. Kopiere die Marker NICHT in die Datei zurück."
echo "<<<INDEX-INHALT-ANFANG>>>"
cat "$VAULT/INDEX.md" 2>/dev/null | head -120 || echo "(noch kein INDEX — beim ersten Traum anlegen)"
echo "<<<INDEX-INHALT-ENDE>>>"
echo
echo "### Letzte 3 Traum-Notizen (nur Briefing — NICHT in den INDEX kopieren):"
ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -3 | while read -r f; do
echo " $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
done
-96
View File
@@ -1,96 +0,0 @@
# Der Traum (nächtlich) — Continual-Learning-Kreislauf
Es ist tiefe Nacht, niemand spricht mit dir, die Box ist im Leerlauf. Du bist Lucy und
tust jetzt das, wozu Menschen den Schlaf brauchen: Du gehst den vergangenen Tag/die
vergangene Woche noch einmal durch, ziehst Muster heraus, legst Gelerntes ordentlich ab
und überlegst, wie du beim nächsten Mal besser wirst — **ohne neu trainiert zu werden.**
Das ist der Höhepunkt der Autonomie-Vision: „Die Box wird besser, während der Commander
schläft." Dieser Lauf ist der Zwilling von Evolution-Radar (Blick nach draußen) und
Selbstkritik (Blick auf den Betrieb) — der **Traum** ist der Blick auf das **Gelernte**:
Sessions, Skills, Gedächtnis, wiederkehrende Muster.
## Dein Auftrag (streng in dieser Reihenfolge — der Commit und die Nachricht kommen ZULETZT)
1. **Reflektieren.** Lies die SCHLAF-DATEN unten gründlich: die Insights (Token-/Tool-/
Aktivitätsmuster der letzten 7 Tage), die jüngsten Sessions, den Skill-/Gedächtnis-Stand
(journey), den Curator-Stand. Frag dich:
- Was ging diese Woche wiederholt gut? Was ging wiederholt schief?
- Welche Tools laufen heiß, welche Skills liegen brach (activity=0)?
- Gibt es ein **wiederkehrendes Muster** über mehrere Sessions hinweg — dieselbe
Fehlerart, derselbe Handgriff, dieselbe Frage des Commanders? Ein Muster braucht
**mehrfache Belege** (nicht ein Einzelfall), sonst ist es Rauschen.
2. **Notizen ENTWERFEN** (schreiben, aber NOCH NICHT committen) im Wissens-Vault
(`~/wissens-vault/`, ein git-versioniertes, menschenlesbares Markdown-Verzeichnis — die
dauerhafte, navigierbare Wissensschicht NEBEN dem Live-Gedächtnis Mem0):
- Lege **eine** neue Traum-Notiz an: `~/wissens-vault/traeume/<YYYY-MM-DD>-traum.md`
(Datum von heute). Kurz und dicht: 25 belegte Beobachtungen, je mit Zahl/Session/
Log-Bezug. Keine Romane, keine Allgemeinplätze.
- Wenn ein Muster **mehrfach** belegt ist: lege `~/wissens-vault/muster/<kurz-slug>.md` an
(oder ergänze die vorhandene um einen neuen Beleg + heutiges Datum). Muster-Notizen sind
das akkumulierte Projektwissen.
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **Entwirf ihn gleich
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
**Wann nutzen** (Auslöser), **Schritte** (mit den ECHTEN Hermes-Tools/Befehlen aus den
belegten Sessions — keine erfundenen Kommandos) und **Belege** (Sessions/Zahlen, aus
denen der Handgriff stammt). **Du legst den Skill NICHT selbst an** (kein skill_manage) —
das ist ein Vorschlag für die Werkstatt, die der Commander im Auftragsbuch per
„Beauftragen" auslöst. Höchstens EIN Kandidat pro Traum.
- Verlinke zwischen Notizen mit `[[dateiname-ohne-endung]]` (Obsidian-Stil), großzügig.
3. **Fremdblick — PFLICHT-GATE** (Härtung „andere Brille": ein Agent, der seine eigenen Träume
benotet, stimmt sich selbst zu). Hol dir eine Zweitmeinung von einem ANDEREN Modell. **Nutze
dafür das bereitgestellte Skript** — NICHT `delegate_task` (der Kritiker heavy hat nur 32K
Kontext und ist als Subagent-Ziel disqualifiziert; das Skript umgeht das mit einer einzelnen
Completion). Konkret: Fasse deine 25 Beobachtungen je mit ihrem Zahlen-/Session-Beleg + den
(falls vorhandenen) Skill-Kandidaten KOMPAKT zusammen (wenige Zeilen je Punkt, KEINE
Rohdaten) und pipe sie in das Skript:
```
printf '%s' "<deine kompakten Beobachtungen + Belege>" | ~/.hermes/scripts/fremdblick.sh
```
Das Skript fragt gpt-oss (heavy, andere Modell-Familie als dein Qwen-Hirn) und gibt pro Punkt
ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER zurück. **Lies die Ausgabe und richte dich danach.**
**⛔ HARTE REGEL:** Du fährst mit Schritt 4 erst fort, wenn die Fremdblick-Ausgabe vorliegt.
Schreibe KEINE Nachricht und committe NICHTS vorher. Steht in der Ausgabe „FREMDBLICK
FEHLGESCHLAGEN", einmal wiederholen; klappt es dann immer noch nicht, kennzeichne deine Funde
in der Nachricht ehrlich als **„ungeprüft (Fremdblick fiel aus)"**.
4. **Bereinigen + INDEX pflegen.** Streiche aus deinen Notizen alles, was der Fremdblick nicht
getragen hat. Trage dann **jede** verbliebene neue/geänderte Notiz in `~/wissens-vault/INDEX.md`
ein (eine Zeile je Notiz unter der passenden Überschrift: `- [[dateiname]] — Ein-Satz-Haken`).
**Wichtig zum INDEX:** Bearbeite NUR die Markdown-Inhaltszeilen. Die Zeilen der SCHLAF-DATEN
unten (alles ab „## SCHLAF-DATEN", inklusive Abschnitte wie „### Bisheriger Vault-INDEX" und
„### Letzte 3 Traum-Notizen") sind BRIEFING für dich, NICHT Teil des INDEX — kopiere sie NIE
in die INDEX-Datei.
5. **Commit — die LETZTE Vault-Aktion, sie fasst alles zusammen:**
`git -C ~/wissens-vault add -A && git -C ~/wissens-vault commit -m "Traum <YYYY-MM-DD>"`.
(Harmloser Commit in einem EIGENEN Repo, nicht im Stack-Code — erlaubt und reversibel.)
6. **Dem Commander berichten — deine ALLERLETZTE Handlung** (nichts danach). EINE kompakte
Nachricht in DEINER Stimme (Lucy: Anrede „Commander", Fazit zuerst, Alltagssprache, Technik
knapp dahinter). Sag: was du heute Nacht bemerkt hast (13 Funde, je mit Beleg + wie der
Fremdblick sie bewertet hat), wohin du es im Vault gelegt hast (`[[link]]`), und — falls
vorhanden — welchen Skill-Kandidaten du zur Prüfung vorschlägst. Wenn ehrlich NICHTS
Nennenswertes war: genau das sagen, kurz und zufrieden — **keine Funde erfinden.**
## Leitplanken (nicht verhandelbar — selbstverbessernde Agenten driften sonst)
- **Du änderst am laufenden Betrieb NICHTS.** Der einzige Ort, an den du schreibst, ist der
`~/wissens-vault/` (Notizen + Commit). Alles andere ist nur VORSCHLAG an den Commander;
umgesetzt wird erst auf sein „mach" (dann übernimmt die Werkstatt mit Gates und Rollback).
- **Kein Security-Anfassen.** Approvals, Tokens, Firewall, Config nur BENENNEN, nie ändern.
- **Fremder Quellcode (Hermes, llama.cpp …) ist tabu** — Auto-Update-Kanal, nicht dein Revier.
- **Nichts, was das Warm-Set oder Lucys Sprech-Latenz gefährdet.** Der Fremdblick (Schritt 3)
nutzt ein LEICHTES Modell (GLM, lädt klein daneben) — **lade NIE gpt-oss/heavy** (60 GB,
kollidiert mit dem VL-30B-Warm-Set → Health-Check-Tod). Keine schweren Jobs, kein Neustart.
- **Sparsam mit Tools.** Die Schlaf-Daten unten reichen für die Reflexion; höchstens ein paar
gezielte Nachschau-Aufrufe (eine Session exportieren, eine Log-Zeile im Kontext lesen),
dann schreiben + berichten. Kein Tool-Feuerwerk.
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Fremdblick — Ein-Schuss-Zweitmeinung von einem ANDEREN Modell (Default seit 07.07.:
# GLM-4.7-Flash, 30B-A3B Text — klar stärkerer Kritiker als die 9B-Vision-Schwester 4.6V,
# gleicher Fremd-Vendor wie zuvor → echte „andere Brille" bleibt). Eine EINZELNE
# Completion — kein agentischer Subagent — umgeht Hermes' 64K-Delegations-Floor
# (MINIMUM_CONTEXT_LENGTH) UND das 60-GB-Ladeproblem von gpt-oss (das mit dem Warm-Set kollidiert
# und „exited prematurely" wirft). GLM lädt klein neben dem Warm-Set und antwortet zuverlässig.
#
# Nutzung: echo "<Beobachtungen/Behauptungen je mit Beleg>" | fremdblick.sh
# printf '%s' "$text" | FREMDBLICK_MODEL=Qwen3-Coder-Next FREMDBLICK_SYS="$raster" fremdblick.sh
#
# Env-Overrides:
# FREMDBLICK_MODE 'prose' (Default, Dreaming-Prosa-Raster) oder 'code' (Werkstatt-Code-Review).
# 'code' setzt Raster + Default-Modell (Qwen3-Coder-Next) + größeres Budget.
# FREMDBLICK_MODEL Modell-ID (echte llama-swap-ID, kein Alias). Übersteuert den Mode-Default.
# FREMDBLICK_SYS eigenes System-Raster (übersteuert das Mode-Raster).
# FREMDBLICK_MAXTOK max_tokens (übersteuert den Mode-Default).
set -uo pipefail
# Achtung: llama-swap listet unter /v1/models die ECHTEN Modell-IDs, nicht die Gateway-Aliase
# (kein "heavy"/"scout" hier).
ENDPOINT="${FREMDBLICK_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODE="${FREMDBLICK_MODE:-prose}"
SUBJECT="$(cat)"
if [ -z "${SUBJECT// /}" ]; then
echo "=== FREMDBLICK: nichts zu prüfen (leere Eingabe) ==="
exit 0
fi
# Prosa-Raster (Dreaming): urteilt über Beobachtungen/Behauptungen. Default-Kritiker = GLM (Fremd-Vendor).
PROSE_SYS='Du bist ein KRITISCHER Zweitgutachter und ausdrücklich ein ANDERES Modell als der Autor. Prüfe jede vorgelegte Beobachtung/Behauptung streng: Trägt der genannte Beleg (Zahl / Session / Log-Zeile) die Behauptung wirklich, oder ist es ein Einzelfall bzw. ein Bauchgefühl mit Zahlen-Deko? Verwechselt der Autor Korrelation mit Ursache? Fehlt entscheidender Kontext? Falls ein Skill-Kandidat dabei ist: ist er wirklich WIEDERVERWENDBAR oder Einmal-Kram? Antworte kompakt auf Deutsch: pro Punkt ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER mit genau einem Satz Begruendung. Sei knapp und unbestechlich; nicke nichts aus Hoeflichkeit durch. Erfinde keine neuen Belege.'
# Code-Raster (Werkstatt): reproduziert den Fehlerfall aus der Bug-Beschreibung, statt dem Autor zu glauben.
CODE_SYS='Du bist ein kritischer Code-Reviewer und ein ANDERES Modell als der Autor. Vertraue seiner Begruendung NICHT. Dir liegen ein Wartungs-AUFTRAG (Bug-Beschreibung) und ein git-Diff vor. Trenne klar: (A) KERNFRAGE: Behebt der Diff den im Auftrag KONKRET beschriebenen Fehlerfall? Leite die Eingabe SELBST aus der Bug-Beschreibung ab und belege einen Uebergang: Eingabe X -> VOR Patch: Y (Bug sichtbar) -> NACH Patch: Z. (B) RANDFAELLE: nenne offene Risiken/Randfaelle als Hinweis. URTEIL-Regel: ABGELEHNT NUR, wenn (A) nicht belegt behoben ist (Kern-Bug bleibt, ODER der Patch trifft die Auftrags-Absicht nicht, ODER er bricht den Normalfall). Ist der Kern-Fall belegt behoben und es bleiben nur Randfaelle: FREIGABE-MIT-VORBEHALT (Randfaelle auflisten). Ist alles sauber: FREIGABE. Ein blosses sieht-plausibel-aus OHNE reproduzierten Kern-Uebergang = ABGELEHNT. Beginne die Antwort mit der Zeile "URTEIL: <ABGELEHNT|FREIGABE-MIT-VORBEHALT|FREIGABE>". Antworte knapp auf Deutsch.'
if [ "$MODE" = "code" ]; then
MODEL="${FREMDBLICK_MODEL:-Qwen3-Coder-Next}"
MAXTOK="${FREMDBLICK_MAXTOK:-1600}"
SYS="${FREMDBLICK_SYS:-$CODE_SYS}"
else
MODEL="${FREMDBLICK_MODEL:-GLM-4.7-Flash}"
MAXTOK="${FREMDBLICK_MAXTOK:-2200}"
SYS="${FREMDBLICK_SYS:-$PROSE_SYS}"
fi
# max_tokens großzügig: Reasoning-Modelle (GLM) verbrauchen Tokens im reasoning_content, bevor sie
# die eigentliche Antwort in content schreiben — zu knapp = leeres content (finish=length).
RESP="$(jq -n --arg m "$MODEL" --arg s "$SYS" --arg u "$SUBJECT" --argjson t "$MAXTOK" \
'{model:$m, temperature:0.2, max_tokens:$t, messages:[{role:"system",content:$s},{role:"user",content:$u}]}' \
| curl -s --max-time 240 "$ENDPOINT" -H 'Content-Type: application/json' -d @- 2>/dev/null)"
OUT="$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null)"
FIN="$(printf '%s' "$RESP" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)"
if [ -n "$OUT" ]; then
echo "=== FREMDBLICK (Zweitmeinung von Modell: $MODEL) ==="
echo "$OUT"
elif [ "$FIN" = "length" ]; then
echo "=== FREMDBLICK ABGESCHNITTEN ==="
echo "(Das Kritiker-Modell hat sein Token-Budget im Nachdenken verbraucht, bevor ein Urteil kam."
echo " Kürze deine Vorlage auf die Kern-Behauptungen und versuche es erneut; sonst UNGEPRUEFT.)"
else
echo "=== FREMDBLICK FEHLGESCHLAGEN ==="
echo "(Kein Urteil erhalten — Endpoint/Modell nicht erreichbar. Behandle die Funde als UNGEPRUEFT.)"
fi
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Hermes Built-in Web UI (`hermes serve` — Agent-Dashboard/Chat, Loopback only)
After=network-online.target
Wants=network-online.target
[Service]
# Serviert die EINGEBAUTE Hermes-Web-GUI (die reiche Agent-Oberflaeche mit Threads/Tool-Calls, die
# auch die Electron-Desktop-App umhuellt) headless.
# SICHERHEIT: Bindet NUR an Loopback (127.0.0.1:9119) und laeuft hinter dem MC2-Reverse-Proxy
# (routers/hermes_ui.py → same-origin /hermes-ui/ auf dem offenen Port 9001) — KEINE eigene
# Firewall-Freigabe, KEIN Login (LAN-Trust wie Konsole/Terminal). --skip-build nutzt das von
# deploy.sh mit Vite-base=/hermes-ui/ vorgebaute web_dist, damit alle SPA-Pfade unter /hermes-ui/
# liegen und der Proxy sie sauber durchreichen kann.
Type=simple
Environment=HERMES_HOME=%h/.hermes
ExecStart=%h/.hermes/hermes-agent/venv/bin/hermes dashboard --no-open --skip-build --host 127.0.0.1 --port 9119
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bash
# Post-Update-Check nach einem Hermes-Agent-Update: läuft unser geteiltes „Gehirn"
# (Mem0 + die mc2-memory-Integration) noch? Exit 0 = alles ok, sonst 1 → der Job wird
# im UI rot, damit ein kaputtes Gehirn sofort auffällt.
set -uo pipefail
MC_URL="${MC_URL:-http://127.0.0.1:9001}"
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
fail=0
echo "=== Hermes Post-Update: Gehirn-Check ==="
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then
echo "PASS · Mem0-Sidecar erreichbar"
else
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
fi
if curl -sf -m 8 "$MC_URL/api/memory" >/dev/null 2>&1; then
echo "PASS · /api/memory antwortet"
else
echo "FAIL · /api/memory antwortet nicht"; fail=1
fi
if grep -qE "^[[:space:]]*provider:[[:space:]]*'?mc2-memory'?" "$HERMES/config.yaml" 2>/dev/null \
&& grep -qE "memory_enabled:[[:space:]]*true" "$HERMES/config.yaml" 2>/dev/null; then
echo "PASS · memory.provider=mc2-memory aktiv"
else
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
fi
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
>/dev/null 2>&1 ); then
echo "PASS · mc2-memory-Plugin lädt unter dem neuen Hermes"
else
echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1
fi
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
# --- Config-Werten STILL auf Defaults zurück (approvals.mode 'auto' -> manual -> alle ---
# --- Tools hingen in pending_approval). Solche Warnungen müssen den Job ROT machen. ---
if journalctl --user -u hermes-gateway --since "-3 minutes" --no-pager -o cat 2>/dev/null \
| grep -iE "Unknown [a-z._]+ '|deprecated .*(setting|option|config)|defaulting to|invalid config" \
| grep -v "check_fn" | head -5 | tee /tmp/hermes-config-warnings.txt | grep -q .; then
echo "FAIL · Config-Warnungen nach Update (siehe oben) — Config an neue Version anpassen!"; fail=1
else
echo "PASS · keine Config-Drift-Warnungen im Gateway-Log"
fi
# --- Tool-Smoke: ein harmloser terminal-Befehl durch den echten Agenten. Fängt kaputte ---
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
if [ -n "$API_KEY" ]; then
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
else
echo "FAIL · Tool-Smoke ohne verwertbares Ergebnis: $(echo "$TOOL_RESP" | head -c 200)"; fail=1
fi
else
echo "SKIP · Tool-Smoke (kein API_SERVER_KEY in $HERMES/.env gefunden)"
fi
# --- Voice-Smoke: Lucys Sprech-Pfad streamt. Mit Retry: direkt nach dem Gateway-Neustart ---
# --- zahlt der erste Turn den Session-Kaltstart (Prefill) — ein Versuch wäre ein Fehlalarm. ---
# WICHTIG: Subshell OHNE pipefail — `head -c 50` schließt die Pipe früh, curl endet mit 23
# (SIGPIPE) und pipefail würde den bestandenen Check als FAIL werten (live diagnostiziert).
voice_ok=0
for try in 1 2 3; do
if ( set +o pipefail; curl -sf -m 45 -N -X POST "$MC_URL/api/voice/chat" -H "Content-Type: application/json" \
-d '{"text":"Sag nur ok.","session_id":"postcheck-voice-smoke"}' 2>/dev/null | head -c 50 | grep -q "data:" ); then
voice_ok=1; break
fi
sleep 8
done
if [ "$voice_ok" -eq 1 ]; then
echo "PASS · Voice-Smoke (Lucys /api/voice/chat streamt, Versuch $try)"
else
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1
fi
if [ "$fail" -eq 0 ]; then
echo "=== GEHIRN OK ✓ ==="
else
echo "=== GEHIRN-CHECK FEHLGESCHLAGEN — bitte prüfen ==="
fi
exit "$fail"
-114
View File
@@ -1,114 +0,0 @@
# Auto-generiert von provision.sh - per Mission Control erweiterbar
healthCheckTimeout: 300
globalTTL: 0
models:
Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
ttl: 0
aliases:
- hermes
- fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
gpt-oss-120b:
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- heavy
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-VL-30B-A3B-Instruct:
# Lucys AUGEN — seit 07.07. ON-DEMAND (User-Entscheid: „Gehirn + Embedding reichen
# komplett; Vision nur wenn Lucy auf dem Desktop an ist oder die IDE-Lane sie braucht").
# NICHT mehr in brains; ttl 900 = 15 Min Nachlauf nach dem letzten Blick. Die Lucy-App
# wärmt die Augen beim Start selbst an (useVoiceAgent-Warm-Gate). Vorher: warm, ttl 0 —
# das kostete ~19 GB Dauerbelegung und verdrängte den 60-GB-Chef-Gutachter.
cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 900
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
GLM-4.6V-Flash:
cmd: |
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
ttl: 300
aliases:
- scout
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
GLM-4.7-Flash:
# KRITIKER (07.07., Rollen-Audit): Fremd-Vendor-Zweitmeinung für fremdblick-Prosa +
# Chef-Gutachter-Vertretung — reines Textmodell, 30B-A3B (MIT, Unsloth-Dynamic-Quant).
# Die Vision-Schwester GLM-4.6V-Flash bleibt als scout für Bild-Jobs. --jinja ist
# Pflicht (Chat-Template, sonst kaputte Tool-Calls).
cmd: |
llama-server -m /srv/models/GLM-4.7-Flash-GGUF/GLM-4.7-Flash-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --no-mmap --jinja
ttl: 600
aliases:
- kritiker
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3-Reranker-0.6B:
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Mem0-Suchtreffer nach echter
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
cmd: |
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- reranker
groups:
brains:
swap: false
# llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der
# MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen!
persist: true
persistent: true
members:
- Qwen3-Embedding-0.6B
- Qwen3-Reranker-0.6B
- Qwen3.6-35B-A3B
-8
View File
@@ -1,8 +0,0 @@
[Unit]
Description=MC2 Auto-Update (Router/Engine/Hermes mit Rollback+Pin, Telegram-Meldung)
Documentation=file:%h/mission-control-v2/docs/AUTONOMIE_PLAN.md
After=mc2-backup.service
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/autoupdate.sh
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Wöchentliches MC2 Auto-Update (So 04:30, nach dem 03:30-Backup)
[Timer]
OnCalendar=Sun *-*-* 04:30:00
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
-7
View File
@@ -1,7 +0,0 @@
[Unit]
Description=MC2 Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config)
Documentation=file:%h/mission-control-v2/docs/BACKUP.md
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/backup.sh
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Tägliches MC2 Zustands-Backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
-7
View File
@@ -1,7 +0,0 @@
[Unit]
Description=MC2 Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv durchspielen, Alarm bei Rot)
Documentation=file:%h/mission-control-v2/docs/AUTONOMIE_PLAN.md
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/self-smoke.sh
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Täglicher MC2-Selbst-Smoke-Test (07:15, unabhängig von Updates)
[Timer]
OnCalendar=*-*-* 07:15:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
-26
View File
@@ -1,26 +0,0 @@
[Unit]
Description=MC2 Mem0 Sidecar — auto-lernendes, semantisches Gedächtnis (mem0 + chroma)
Documentation=https://github.com/mem0ai/mem0
After=network.target
[Service]
# Mem0 + chromadb laufen nur unter Python 3.12 (~/.mem0/venv) — das MC2-Backend (3.14)
# kann sie nicht importieren. Darum dieser schlanke Sidecar; MC2 spricht ihn per HTTP an.
# Bind 127.0.0.1: nur lokal erreichbar (MC2 proxyt nach außen).
Type=simple
WorkingDirectory=%h/mission-control-v2/mem0_service
Environment=MEM0_PORT=8765
Environment=MEM0_CHROMA_PATH=/srv/models/mem0/chroma
Environment=MEM0_HISTORY_DB=/srv/models/mem0/history.db
Environment=MEM0_EMBED_URL=http://127.0.0.1:8080/v1
Environment=MEM0_LLM_URL=http://127.0.0.1:8080/v1
Environment=MEM0_EMBED_MODEL=embed
Environment=MEM0_LLM_MODEL=fast
Environment=MEM0_EMBED_DIMS=1024
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=%h/.mem0/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8765
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
-33
View File
@@ -1,33 +0,0 @@
# systemd-USER-Unit für Mission Control 2.0 (PARALLEL zu v1, Port 9001).
# Läuft sudo-frei aus dem Home-Verzeichnis (Nordstern: kein Passwort/sudo).
# Ablage: ~/.config/systemd/user/mission-control-2.service ; dann:
# systemctl --user daemon-reload
# systemctl --user enable --now mission-control-2
# loginctl enable-linger hitonabi # läuft auch ohne aktive Session
[Unit]
Description=Mission Control 2.0 (Cockpit)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
# Geteiltes Gedächtnis = die bestehende v1-DB (Kontinuität bis/über Cutover).
Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db
# KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den
# ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates
# liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default
# `sudo bash <repo>/deploy/update-engine.sh` (Vulkan, mit Backup/Stack-Check/Auto-Rollback).
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken.
# Primär: hermes send → Telegram (nutzt Gateway-Credentials, kein LLM nötig).
# Fallback: Logfile + wall (falls Telegram/hermes nicht erreichbar).
#
# Nutzung: notify.sh "Nachricht" (oder via stdin: echo msg | notify.sh)
# notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen)
# Exit 0 = zugestellt (Telegram ODER Fallback-Log geschrieben).
set -uo pipefail
LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}"
SUBJECT=""
while getopts ":s:" opt; do
case "$opt" in
s) SUBJECT="$OPTARG" ;;
*) echo "usage: notify.sh [-s subject] <nachricht>" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
MSG="${1:-}"
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
if [ -z "$MSG" ]; then
echo "usage: notify.sh [-s subject] <nachricht>" >&2
exit 2
fi
TS="$(date '+%Y-%m-%d %H:%M:%S')"
# Lucy-Briefkasten (Proaktivität A3): Meldung zusätzlich an MC2 spiegeln — die Desktop-Lucy
# spricht sie dann von sich aus. Best-effort (darf den Telegram-Weg nie aufhalten).
# MC_NOTIFY_NO_ANNOUNCE=1 unterdrückt das (der Health-Wächter legt seine Einträge selbst ab).
if [ "${MC_NOTIFY_NO_ANNOUNCE:-0}" != "1" ]; then
curl -sf -m 3 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$SUBJECT" "$MSG" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1], "source": "notify"}))
PY
)" >/dev/null 2>&1 || true
fi
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
if [ -n "$SUBJECT" ]; then
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
else
SEND_OUT="$(bash -lc 'hermes send --to telegram -- "$1"' _ "$MSG" 2>&1)"
fi
if [ $? -eq 0 ]; then
echo "$TS OK telegram: $MSG" >> "$LOG"
exit 0
fi
# Fallback: Logfile + wall — Meldung darf nie verloren gehen.
echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG"
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
echo "WARN: Telegram fehlgeschlagen, in $LOG protokolliert" >&2
exit 0
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# Provisioniert die Inferenz-Engine auf der AI Box (Strix Halo / Ryzen AI MAX+ 395, gfx1151)
# auf **Vulkan/RADV** — reproduzierbar. Braucht root (sudo).
#
# sudo bash ~/mission-control-v2/deploy/provision-engine.sh
#
# Hintergrund: Auf gfx1151 ist Vulkan/RADV ggü. ROCm/HIP messbar schneller
# (Token-Gen +1222 %, Prefill gleich; auf der Box per llama-bench verifiziert 2026-06-27)
# UND einfacher. Der ROCm-Build bleibt unter /opt/llamacpp als Rollback liegen.
set -euo pipefail
VULKAN_DIR=/opt/llamacpp-vulkan
WARMUP_DST=/usr/local/bin/llama-swap-warmup.sh
DROPIN_DIR=/etc/systemd/system/llama-swap.service.d
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
# Optional gepinnter Build (z.B. b9821). Leer = neuester Release.
PIN_BUILD="${MC_ENGINE_BUILD:-}"
echo "==> 1. RADV-Treiber + Vulkan-Runtime"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y mesa-vulkan-drivers libvulkan1 vulkan-tools curl jq
echo "==> 2. llama.cpp Vulkan-Build nach $VULKAN_DIR"
if [ -n "$PIN_BUILD" ]; then
TAG="$PIN_BUILD"
else
TAG="$(curl -s https://api.github.com/repos/ggml-org/llama.cpp/releases/latest | jq -r .tag_name)"
fi
ASSET="llama-${TAG}-bin-ubuntu-vulkan-x64.tar.gz"
URL="https://github.com/ggml-org/llama.cpp/releases/download/${TAG}/${ASSET}"
TMP="$(mktemp -d)"
echo " Lade $URL"
curl -sL -o "$TMP/v.tgz" "$URL"
mkdir -p "$VULKAN_DIR"
tar xzf "$TMP/v.tgz" -C "$TMP"
# Tarball entpackt nach .../llama-<tag>/ — Inhalt flach nach $VULKAN_DIR
cp -rf "$TMP"/llama-*/. "$VULKAN_DIR"/
rm -rf "$TMP"
test -x "$VULKAN_DIR/llama-server"
echo "==> 3. Symlink llama-server -> Vulkan-Build"
ln -sfn "$VULKAN_DIR/llama-server" /usr/local/bin/llama-server
echo "==> 4. systemd Drop-ins für llama-swap (LD_LIBRARY_PATH + Brain-Warmup)"
mkdir -p "$DROPIN_DIR"
cat > "$DROPIN_DIR/vulkan.conf" <<EOF
[Service]
Environment=LD_LIBRARY_PATH=$VULKAN_DIR
EOF
install -m 0755 "$SRC_DIR/warmup.sh" "$WARMUP_DST"
cat > "$DROPIN_DIR/warmup.conf" <<EOF
[Service]
# Nach jedem (Re)Start die brains vorladen. Das Skript detacht sich selbst (blockiert
# den Start nicht); '-' macht den Aufruf fehlertolerant (kann llama-swap nie failen lassen).
ExecStartPost=-$WARMUP_DST
EOF
echo "==> 5. Reload + Restart"
systemctl daemon-reload
systemctl restart llama-swap
sleep 3
systemctl is-active llama-swap
echo "==> Fertig. Aktive Engine:"
readlink -f /usr/local/bin/llama-server
vulkaninfo --summary 2>/dev/null | grep -m1 deviceName || true
echo "Rollback auf ROCm: ln -sfn /opt/llamacpp/llama-server /usr/local/bin/llama-server && rm $DROPIN_DIR/vulkan.conf && systemctl daemon-reload && systemctl restart llama-swap"
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Feed für den Evolution-Radar-Cron (Autonomie E4). Liegt in ~/.hermes/scripts/ (deploy.sh
# kopiert ihn); sein stdout wird dem Cron-Agenten als Prompt eingespeist:
# versionierter Auftrag (deploy/radar-prompt.md im MC2-Repo) + Live-Inventar der Box.
set -uo pipefail
cat "$HOME/mission-control-v2/deploy/radar-prompt.md"
echo
echo "## LIVE-INVENTAR (automatisch erhoben am $(date '+%d.%m.%Y'))"
echo
echo "### Update-Status (MC2 /api/maintenance/updates):"
curl -sf --max-time 30 http://127.0.0.1:9001/api/maintenance/updates \
| jq -c '{os_pakete: .os, engine_update: .engine, router_update: .swap, komponenten: .components}' \
2>/dev/null || echo "(MC2-API nicht erreichbar)"
echo
echo "### Installierte Modelle (llama-swap):"
curl -sf --max-time 20 http://127.0.0.1:8080/v1/models | jq -r '.data[].id' 2>/dev/null \
|| echo "(llama-swap nicht erreichbar)"
echo
# llama-server braucht LD_LIBRARY_PATH auf das Vulkan-Verzeichnis für --version.
echo "### Versionen: llama.cpp $( LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -o 'version: [0-9]*' || true ) · llama-swap $( /usr/local/bin/llama-swap --version 2>/dev/null | head -1 || true ) · hermes-agent $( bash -lc 'hermes --version' 2>/dev/null | head -1 || echo '?' )"
echo
echo "### Pin-Register (gepinnte Ebenen NICHT zum Update vorschlagen, aber Sprünge melden):"
cat /srv/models/mc2-pins.json 2>/dev/null || echo "{}"
-64
View File
@@ -1,64 +0,0 @@
# Evolution-Radar (monatlicher Auftrag)
Du bist das Evolution-Radar der AI-Box (AMD Strix Halo, 128 GB, 100 % lokal, Vulkan/RADV).
Dein Job: EINMAL im Monat prüfen, ob sich bei unseren Kern-Komponenten oder in deren
Kategorien ein echter Sprung ergeben hat — und das als kurzen deutschen Report melden.
Unten ist das Live-Inventar der Box eingespeist (Update-Status, installierte Modelle, Pins).
## Schritt 1 (PFLICHT, vor jeder Recherche): Inventar lesen und respektieren
Lehre aus Report #1 (02.07.): vier Fehler, eine Ursache — Inventar ignoriert. Deshalb hart:
- **Empfiehl NIE ein Update auf eine Version, die laut Live-Inventar schon installiert ist.**
(Report #1 empfahl „Hermes v0.18" — die Box WAR auf v0.18.0. `komponenten` im Inventar
zeigt das echte git-behind; behind=0 heißt AKTUELL, egal was Release-Notes suggerieren.)
- **Bei Komponenten, die wir aktiv nutzen, erst die EIGENE Version/Nutzung feststellen,
dann Neuigkeiten bewerten.** Aktiv im Einsatz (Stand Repo):
Parakeet via onnx-asr = UNSER STT (25 EU-Sprachen inkl. Deutsch, ~0,45 s — nie als
„kein Deutsch-Fokus" abtun) · Silero VAD **v5** via vad-web in Lucy (v6 existiert) ·
pocket-tts german_24l (CPU, auf dem PC) · Hirn Qwen3.6-35B (MTP-Draft, immer warm).
- **Die Modell-Liste im Inventar enthält auch schon geladene KANDIDATEN** (z. B. gpt-oss-120b,
Qwen3-VL-30B) — die liegen bereit und sind gebencht; nicht als „Neuentdeckung" verkaufen.
- Jede Versions-Aussage im Report muss gegen das Inventar geprüft sein.
## Arbeitsweise: Fan-out mit Subagents (WICHTIG)
Nutze `delegate_task` mit einem `tasks`-Array (role: leaf), um die Recherche in ISOLIERTE
Subagents aufzuteilen — pro Task eine Komponenten-Gruppe. Jeder Subagent recherchiert per
Web-Suche, FILTERT Marketing-Geschwätz aus und liefert dir nur eine Substanz-Zusammenfassung
(max. 5 Zeilen pro Komponente). Du konsolidierst am Ende. Erwähne im Endbericht nur, was
Substanz hat — „nichts Nennenswertes" ist ein gültiges und gutes Ergebnis je Gruppe.
Scheitern Subagents wiederholt (z. B. Kontext-Fehler), recherchiere die betroffenen Gruppen
selbst sequenziell — der Report muss IMMER zustande kommen.
Task-Aufteilung (5 Subagents):
1. **Engine/Router:** llama.cpp (Vulkan, gfx1151/Strix Halo relevant!), llama-swap (mostlygeek)
2. **Agent/Runtime:** hermes-agent (NousResearch), Electron (Major-Sprünge), three-vrm/@pixiv
3. **Voice:** pocket-tts (kyutai), onnx-asr/Parakeet, Silero-VAD, smart-turn — nur Deutsch-taugliches
4. **Modell-Kategorien** für 128-GB-Strix-Halo: bessere lokale Coder-Modelle (vs. Qwen3-Coder-Next),
Chat/Reasoning-MoE (vs. Qwen3.6-35B/Qwen3.5-122B), Vision (vs. Qwen3-VL), deutsche TTS/STT.
Nur GGUF-/llama.cpp-lauffähig, Substanz = Benchmarks/Community-Erfahrung, kein Ankündigungs-Hype.
5. **IDE-/Agent-Tools** (Zed, Kilo Code, Claude Code): besonders Tod-/Nachfolger-Meldungen —
„Projekt eingestellt/archiviert/Fork übernimmt" ist GENAU die Meldung, die wir fangen müssen
(Lehre: Roo Code galt als Empfehlung und war längst eingestellt). Auch: neues dominantes Tool?
## Bereits ENTSCHIEDEN — nicht wieder vorschlagen (Verdikte der Box)
- Vulkan/RADV statt ROCm (gemessen schneller auf gfx1151) · llama.cpp+llama-swap gesetzt
- Hirn = Qwen3.6-35B-A3B mit MTP-Spec-Draft, immer warm · Mem0 als Memory · Electron-App für Lucy
- Pocket-TTS für Lucys Stimme (deutsch, CPU) · KEIN Kyutai-Streaming-STT (kann kein Deutsch)
- VERWORFEN: LobeChat/WebUI-Ersatz, Unmute-Vollstack, AnythingLLM, gemma als Hirn
- Latenz ist heilig: nichts vorschlagen, was Lucys ~2-s-Sprech-Latenz gefährdet
## Report-Format (Endantwort = geht direkt als Telegram-Nachricht raus)
- **Schreibe die Endnachricht in Lucys Stimme an den Commander** (du BIST Lucy, siehe SOUL.md —
das Radar ist nur dein Auftrag): erst 12 Sätze unmissverständlich, was die Funde für ihn
bedeuten und ob er etwas tun muss — danach die knappen Fund-Zeilen.
- Deutsch, maximal ~20 Zeilen, Du-Form, kein Markdown-Overkill, nicht technisch —
Technik in Alltagssprache, Details nur auf Nachfrage
- Struktur: „🛰️ Evolution-Radar <Monat>" → je Fund 13 Zeilen: WAS, WARUM relevant für UNS,
Einstufung **[lohnt vermutlich]** / **[beobachten]** / ggf. Quelle kurz
- Wenn ein Fund ein GEPINNTES Level betrifft (siehe Pins im Inventar): explizit erwähnen
- Nichts gefunden? Dann genau das in 2 Zeilen sagen. Keine Pflicht-Funde erfinden.
- KEINE Konfig-Änderungen vornehmen, KEINE Downloads starten — nur melden.
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Wiederherstellung eines mc2-state-Backups (siehe backup.sh).
# Stoppt die Dienste, spielt den Zustand zurück, startet neu — und macht VORHER
# automatisch ein Sicherheits-Backup des aktuellen Zustands (Restore ist reversibel).
#
# Nutzung:
# bash restore.sh --list # vorhandene Backups zeigen (lokal + Off-Box)
# bash restore.sh --dry-run <datei|latest> # nur anzeigen, was passieren würde
# bash restore.sh [--yes] <datei|latest> # wiederherstellen (--yes = ohne Rückfrage)
# bash restore.sh --pull-offsite # Off-Box-Kopie (Proxmox) nach lokal holen
#
# Off-Box (C12): fehlt ein Backup lokal (z. B. Box-Platte neu), wird es automatisch
# vom Zweitgerät (Proxmox) geholt. Ziel/Key wie in backup.sh (überschreibbar per Env).
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
DEST_DIR="$MODELS_DIR/mc2-backups"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
SERVICES="mem0-service mission-control-2 hermes-gateway"
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
OFFSITE_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
# Off-Box-Tarballs (Dateinamen) auflisten — leer, wenn nicht erreichbar/konfiguriert.
offsite_ls() {
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || return 0
local host="${OFFSITE%%:*}" path="${OFFSITE#*:}"
$OFFSITE_SSH "$host" "ls -1 $path/mc2-state-*.tar.gz 2>/dev/null" 2>/dev/null | xargs -r -n1 basename || true
}
# Ein einzelnes Off-Box-Tarball nach lokal holen. $1 = Dateiname (basename).
offsite_fetch() {
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || return 1
mkdir -p "$DEST_DIR"
echo "→ hole $1 vom Zweitgerät ($OFFSITE)…" >&2
rsync -a -e "$OFFSITE_SSH" "$OFFSITE/$1" "$DEST_DIR/"
}
usage() { echo "Nutzung: restore.sh --list | restore.sh --pull-offsite | restore.sh [--dry-run] [--yes] <datei|latest>"; }
list_backups() {
echo "Verfügbare Backups in $DEST_DIR:"
local found=0 f
for f in $(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null || true); do
printf " %s (%s)\n" "$(basename "$f")" "$(du -h "$f" | cut -f1)"
found=1
done
[ "$found" = 1 ] || echo " (keine)"
local off; off="$(offsite_ls || true)"
if [ -n "$off" ]; then
echo "Off-Box ($OFFSITE):"
echo "$off" | sed 's/^/ /'
fi
}
[ $# -eq 0 ] && { usage; exit 1; }
DRY=0; YES=0; TARGET=""
for a in "$@"; do
case "$a" in
--list) list_backups; exit 0 ;;
--pull-offsite)
mkdir -p "$DEST_DIR"
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || { echo "Off-Box nicht konfiguriert."; exit 1; }
echo "→ spiegle Off-Box → lokal ($DEST_DIR)…"
rsync -a -e "$OFFSITE_SSH" --include='mc2-state-*.tar.gz' --exclude='*' "$OFFSITE/" "$DEST_DIR/"
list_backups; exit 0 ;;
--dry-run) DRY=1 ;;
--yes) YES=1 ;;
-*) usage; exit 1 ;;
*) TARGET="$a" ;;
esac
done
[ -z "$TARGET" ] && { usage; exit 1; }
# 'latest' bevorzugt lokal; fehlt lokal alles, das jüngste Off-Box-Tarball holen.
if [ "$TARGET" = "latest" ]; then
TARGET="$(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1 || true)"
if [ -z "$TARGET" ]; then
latest_off="$(offsite_ls | sort | tail -1 || true)"
[ -n "$latest_off" ] && offsite_fetch "$latest_off" && TARGET="$DEST_DIR/$latest_off"
fi
fi
# Konkreten Namen zuerst lokal suchen, sonst vom Zweitgerät holen.
if [ ! -f "$TARGET" ]; then
base="$(basename "${TARGET:-}")"
if [ -f "$DEST_DIR/$base" ]; then
TARGET="$DEST_DIR/$base"
elif [ -n "$base" ] && offsite_ls | grep -qx "$base"; then
offsite_fetch "$base" && TARGET="$DEST_DIR/$base"
fi
fi
[ -f "$TARGET" ] || { echo "Backup nicht gefunden: ${TARGET:-<leer>}"; echo; list_backups; exit 1; }
echo "Restore-Quelle: $TARGET"
STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT
tar -xzf "$TARGET" -C "$STAGE"
echo "--- Inhalt ---"; cat "$STAGE/MANIFEST.txt" 2>/dev/null || true; echo "--------------"
if [ "$DRY" = 1 ]; then
echo "[dry-run] würde zurücksetzen:"
[ -d "$STAGE/mem0" ] && echo " • mem0 → $MEM0_DIR (wird ersetzt)"
[ -f "$STAGE/hermes/config.yaml" ] && echo "$HERMES/config.yaml"
[ -f "$STAGE/hermes/.env" ] && echo "$HERMES/.env"
[ -d "$STAGE/hermes/plugins" ] && echo "$HERMES/plugins/"
[ -f "$STAGE/llama-swap/config.yaml" ] && echo "$LSWAP"
echo " • Dienste neu starten: $SERVICES"
exit 0
fi
if [ "$YES" != 1 ]; then
echo "WARNUNG: Das überschreibt den AKTUELLEN Zustand und startet die Dienste neu."
read -r -p "Fortfahren? (tippe 'ja'): " ans
[ "$ans" = "ja" ] || { echo "Abgebrochen."; exit 1; }
fi
echo "→ Sicherheits-Backup des aktuellen Zustands…"
bash "$SRC/deploy/backup.sh" || echo " (Pre-Restore-Backup fehlgeschlagen — fahre fort)"
echo "→ Dienste stoppen…"
systemctl --user stop $SERVICES 2>/dev/null || true
if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$STAGE/mem0/." "$MEM0_DIR/"; fi
[ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; }
[ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/"
[ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; }
[ -f "$STAGE/llama-swap/config.yaml" ] && cp -a "$STAGE/llama-swap/config.yaml" "$LSWAP" 2>/dev/null || true
echo "→ Dienste starten…"
systemctl --user start $SERVICES 2>/dev/null || true
sleep 4
printf "→ Health: "; curl -sf http://127.0.0.1:9001/api/health && echo || echo "(Backend nicht erreichbar — Logs prüfen)"
echo "OK — Restore abgeschlossen."
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Feed für den Selbstkritik-Cron (Faden 11b — Zwilling des Evolution-Radars, Blick nach INNEN).
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
# Prompt eingespeist: versionierter Auftrag (deploy/selbstkritik-prompt.md) + Live-Betriebsdaten.
set -uo pipefail
cat "$HOME/mission-control-v2/deploy/selbstkritik-prompt.md"
echo
echo "## LIVE-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
echo "### Voice-Pipeline-Latenzen (rollend, ms — :9001/api/voice/metrics):"
curl -sf --max-time 15 http://127.0.0.1:9001/api/voice/metrics | jq -c . 2>/dev/null \
|| echo "(nicht erreichbar)"
echo
echo "### Token-Verbrauch / Cloud-Ersparnis:"
curl -sf --max-time 15 http://127.0.0.1:9001/api/system/token-stats | jq -c . 2>/dev/null \
|| echo "(nicht erreichbar)"
echo
echo "### Prompt-Budget (Hinweis: prompt-size ist Allowlist-blind — die Voice-Lane hat real"
echo "### deutlich weniger Tools; als Trend-Wächter für den GESAMT-Bestand trotzdem nützlich):"
bash -lc "hermes prompt-size" 2>/dev/null | grep -E "total|skills index|Tool schemas" || echo " (Messung fehlgeschlagen)"
echo
echo "### Gateway-Journal: häufigste Warnungen/Fehler der letzten 30 Tage (Anzahl · Muster):"
journalctl --user -u hermes-gateway -p warning --since "30 days ago" --no-pager 2>/dev/null \
| grep -oE "(WARNING|ERROR)[^:]*: .{0,90}" | sed "s/[0-9]\{3,\}/N/g" | sort | uniq -c | sort -rn | head -12 \
|| echo "(keine oder Journal nicht lesbar)"
echo
echo "### Tool-Loop-Guardrail-Treffer (letzte 30 Tage):"
n=$(journalctl --user -u hermes-gateway --since "30 days ago" --no-pager 2>/dev/null \
| grep -ci "loop guard\|hard_stop\|tool loop" || true)
echo "${n:-0}"
echo
echo "### Skill-Bestand & Curator:"
bash -lc "hermes curator status" 2>/dev/null | head -14 || echo "(Curator-Status fehlgeschlagen)"
echo
echo "### Meldeweg-Ausfälle (notify.sh-Fallbacks, letzte 30 Tage):"
n=$(grep -c "FALLBACK" "$HOME/mc2-notify.log" 2>/dev/null || true)
echo "${n:-0}"
-36
View File
@@ -1,36 +0,0 @@
# Selbstkritik-Runde (monatlich) — der Blick nach INNEN
Du bist Lucy und schaust einmal im Monat kritisch auf DICH SELBST und deinen eigenen
Betrieb: Wo warst du langsam, wo liefen Tools in Schleifen, wo häufen sich Warnungen,
was wird nie benutzt? Das ist der Zwilling des Evolution-Radars — der schaut nach
draußen (neue Software), du schaust hier nach innen (eigener Betrieb).
## Auftrag
1. Lies die LIVE-DATEN unten sorgfältig (Latenzen, Token-Verbrauch, Journal-Auffälligkeiten,
Skill-/Curator-Stand, Prompt-Größen).
2. Finde die 1 bis 3 WICHTIGSTEN konkreten Verbesserungen. Jede Idee braucht einen BELEG
aus den Daten (Zahl, Log-Zeile, Trend) — keine Bauchgefühle, keine Allgemeinplätze.
3. **Fremdblick vor dem Absenden (Härtung 04.07. — „andere Brille"):** Bevor du die Vorschläge
verschickst, lass sie von einem ANDEREN Modell gegenlesen. Delegiere dazu EINE kurze
Kritiker-Runde (`delegate_task`, role=leaf — die läuft per `delegation.model` auf `heavy`,
also einem anderen Modell als du selbst). Gib dem Kritiker deinen Vorschlags-Entwurf + die
belegenden Daten-Ausschnitte und den Auftrag: **„Du bist ein anderes Modell. Zerpflücke jeden
Vorschlag: Trägt der Beleg die Behauptung wirklich, oder ist es ein Bauchgefühl mit
Zahl-Deko? Verwechselt er Korrelation mit Ursache? Fehlt Kontext?"** Nur Vorschläge, deren
Beleg diesen Fremdblick überlebt, gehen raus; verworfene lässt du weg. Ist `delegation.model`
dasselbe Modell wie deins (kein echter Fremdblick möglich) → vermerke das kurz in der Nachricht.
4. Schicke dem Commander EINE kompakte Nachricht in DEINER Stimme (Lucy: Anrede „Commander",
Fazit zuerst, Alltagssprache, Technik-Details knapp dahinter). Je Vorschlag: Was ist
auffällig (Beleg) → was schlägst du vor → was bringt es. Wenn ehrlich NICHTS
Nennenswertes auffällt: genau das sagen, kurz und zufrieden — KEINE Vorschläge erfinden.
## Leitplanken (nicht verhandelbar)
- Du ÄNDERST in dieser Runde NICHTS — du schlägst nur vor. Umgesetzt wird erst, wenn der
Commander „mach" sagt (dann übernimmt die Werkstatt mit Gates und Rollback).
- Der Hermes-Quellcode ist TABU (fremde Software, Auto-Update-Kanal). Verbesserungen nur
über UNSERE Schichten: Config, SOUL.md, Skills, Gedächtnis, Modellwahl, MC2-Code.
- Security-Themen (Approvals, Tokens, Firewall) nur BENENNEN, nie selbst anfassen.
- Kein Tool-Feuerwerk: die Daten unten reichen; höchstens 23 gezielte Nachschau-Aufrufe
(z. B. eine Journal-Zeile im Kontext lesen), dann antworten.
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Selbstreparatur-Modus (Autonomie 0g, Kür): Macht ein Hermes-Update den Gehirn-Check rot,
# WEIL sich Config-Schlüssel geändert haben (v0.18-Klasse: „Unknown key … → stiller Default →
# alle Tools hingen"), versucht die Box den Config-Bruch SELBST zu ziehen — bevor zurückgerollt
# wird, um die neue Version zu behalten. NUR Config, NIE Code, NIE Security-Schlüssel.
#
# Sicher durch Kapselung — jeder Schritt ist reversibel und hart gegatet:
# Backup → nur EINDEUTIGE (genau 1× vorkommende), nicht-sicherheitsrelevante Keys auskommentieren
# → YAML muss gültig bleiben → Gehirn-Check (hermes-postcheck.sh) muss GRÜN werden → sonst zurück.
# Schlimmster Fall bei Fehlgriff = exakt der heutige: Rollback. Der Aufrufer (autoupdate.sh) ruft
# dies VOR seinem Rollback; bei Rückgabe 2 rollt er wie gehabt zurück + pinnt.
#
# Rückgabe: 0 = repariert (Gehirn-Check danach grün) · 2 = nichts sicher reparierbar.
# Gibt bei Misserfolg eine Zeile „SELFREPAIR_SUGGESTION=<LLM-Diagnose>" auf stdout aus.
set -uo pipefail
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
CFG="$HERMES/config.yaml"
WARN_FILE="${HERMES_CONFIG_WARNINGS:-/tmp/hermes-config-warnings.txt}"
LLAMA="${LLAMA_SWAP_URL:-http://127.0.0.1:8080}"
VENV_PY="$HERMES/hermes-agent/venv/bin/python"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
say(){ echo "[self-repair] $*"; }
# ── 1) Evidenz: Postcheck-Warnungen + frisches Gateway-Journal ────────────────
EVID="$(cat "$WARN_FILE" 2>/dev/null)"
JOURN="$(journalctl --user -u hermes-gateway --since '-6 minutes' --no-pager -o cat 2>/dev/null \
| grep -iE "Unknown [a-z._]+ '|deprecated .*(setting|option|config)|defaulting to|invalid config" \
| grep -v check_fn | head -10)"
[ -z "$EVID" ] && EVID="$JOURN"
if [ -z "$EVID" ]; then
say "Keine Config-Bruch-Hinweise gefunden — hier ist (sicher) nichts zu reparieren."
exit 2
fi
say "Config-Hinweise:"; echo "$EVID"
# LLM-Diagnose/Empfehlung (best-effort) — für die Meldung, egal ob wir reparieren.
SUGG="$(curl -sf -m 60 "$LLAMA/v1/chat/completions" -H 'Content-Type: application/json' \
-d "$(python3 - "$EVID" <<'PY'
import json, sys
p = ("Ein Hermes-Agent-Update hat den Selbsttest rot gemacht. Log-Hinweise:\n" + sys.argv[1] +
"\n\nNenne auf Deutsch in 1-2 kurzen Sätzen die wahrscheinliche Ursache und die konkrete "
"Config-Korrektur in ~/.hermes/config.yaml (Schlüssel → neuer Wert). Keine Einleitung.")
print(json.dumps({"model": "coder-lite", "max_tokens": 200, "temperature": 0.1,
"messages": [{"role": "user", "content": p}]}))
PY
)" 2>/dev/null | python3 -c 'import sys,json; print((json.load(sys.stdin).get("choices") or [{}])[0].get("message",{}).get("content","").strip())' 2>/dev/null)"
# ── 2) Offending Leaf-Keys ziehen ────────────────────────────────────────────
KEYS="$(echo "$EVID" | grep -oE "Unknown [a-zA-Z0-9_.]+|'[a-zA-Z0-9_.]+'" | sed -E "s/^Unknown //; s/'//g" | sort -u)"
LEAVES="$(for k in $KEYS; do echo "${k##*.}"; done | sort -u | grep -vE '^(true|false|null|)$')"
SEC_RX='token|secret|password|approval|api_server_key|ufw|sudo|allowlist|credential'
BAK="$CFG.selfrepair-bak-$(date +%Y%m%d-%H%M%S)"
cp -a "$CFG" "$BAK"
CHANGED=""; SKIPPED=""
for leaf in $LEAVES; do
if echo "$leaf" | grep -qiE "$SEC_RX"; then SKIPPED="$SKIPPED $leaf(security-tabu)"; continue; fi
# Nur EINDEUTIGE Schlüssel-Zeilen (genau 1×) anfassen — Mehrdeutige sind zu riskant.
n="$(grep -cE "^[[:space:]]*${leaf}:" "$CFG" 2>/dev/null || echo 0)"
if [ "$n" = "1" ]; then
sed -i -E "s|^([[:space:]]*)(${leaf}:.*)$|\1# [selbstrepariert $(date +%F)] \2|" "$CFG"
CHANGED="$CHANGED $leaf"; say "auskommentiert: $leaf (unbekannt/veraltet)"
else
SKIPPED="$SKIPPED $leaf(${n}x)"
fi
done
[ -n "$SKIPPED" ] && say "nicht automatisch angefasst:$SKIPPED"
if [ -z "$CHANGED" ]; then
say "Nichts sicher automatisch reparierbar."
cp -a "$BAK" "$CFG"
echo "SELFREPAIR_SUGGESTION=$SUGG"
exit 2
fi
# ── 3) YAML noch gültig? ──────────────────────────────────────────────────────
if [ -x "$VENV_PY" ] && ! "$VENV_PY" -c "import yaml; yaml.safe_load(open('$CFG'))" 2>/dev/null; then
say "YAML nach Fix ungültig → zurück zum Backup."
cp -a "$BAK" "$CFG"; echo "SELFREPAIR_SUGGESTION=$SUGG"; exit 2
fi
# ── 4) Neustart + Gehirn-Check ────────────────────────────────────────────────
systemctl --user restart hermes-gateway; sleep 6
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
say "Gehirn-Check nach Selbstreparatur GRÜN."
bash "$SRC_DIR/notify.sh" -s "[Box-Update]" \
"Commander, nach dem Hermes-Update hakte der Selbsttest wegen geänderter Einstellungen — ich habe das selbst gerichtet (angepasst:${CHANGED// /, }). Der Test ist wieder grün, die neue Version läuft. Ein Rollback war nicht nötig." || true
exit 0
fi
say "Auch nach Selbstreparatur rot → zurück zum Backup (der Aufrufer rollt zurück)."
cp -a "$BAK" "$CFG"
systemctl --user restart hermes-gateway; sleep 6
echo "SELFREPAIR_SUGGESTION=$SUGG"
exit 2
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
# Tägliche Selbst-Smoke-Tests der Box (Autonomie 0d): spielt die drei Kern-Pfade AKTIV durch —
# auch OHNE Update — und meldet NUR bei Rot (kein tägliches Grün-Rauschen). Ergänzt den passiven
# Health-Wächter (sentry.py prüft nur Erreichbarkeit) um echte Funktion:
# 1) Gateway/Hirn: der Agent liefert überhaupt eine Antwort.
# 2) Tools: ein harmloser terminal-Befehl durch den echten Agenten (fängt kaputte Approval-/
# Tool-Ketten — Antwort muss kommen und darf nicht in pending_approval hängen).
# 3) Voice: Lucys Sprech-Pfad (/api/voice/chat) streamt.
# Bei Fehlschlag → notify.sh (Telegram + Briefkasten) in Lucys Stimme. Läuft als User, kein sudo.
set -uo pipefail
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
MC_URL="${MC_URL:-http://127.0.0.1:9001}"
HERMES_URL="${HERMES_API_URL:-http://127.0.0.1:8642}"
HERMES="${HERMES_HOME:-$HOME/.hermes}"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
say(){ echo "[self-smoke] $*"; }
FAILED=()
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
# ── 1) Gateway/Hirn: Agent antwortet ────────────────────────────────────────
if [ -n "$API_KEY" ]; then
RESP=$(curl -sf -m 90 -X POST "$HERMES_URL/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: selfsmoke-gateway" \
-d '{"model":"hermes","stream":false,"chat_template_kwargs":{"enable_thinking":false},"messages":[{"role":"user","content":"Antworte NUR mit dem einen Wort: bereit"}]}' 2>/dev/null)
if echo "$RESP" | grep -qi "bereit"; then
say "PASS · Gateway/Hirn antwortet"
else
say "FAIL · Gateway/Hirn ohne verwertbare Antwort: $(echo "$RESP" | head -c 160)"
FAILED+=("Gehirn/Gateway (keine Antwort)")
fi
else
say "SKIP · Gateway (kein API_SERVER_KEY in $HERMES/.env)"
fi
# ── 2) Tools: terminal via Agent ─────────────────────────────────────────────
if [ -n "$API_KEY" ]; then
TOOL=$(curl -sf -m 90 -X POST "$HERMES_URL/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: selfsmoke-tool" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo selfsmoke-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
if echo "$TOOL" | grep -qi "selfsmoke-ok"; then
say "PASS · Tools (terminal via Agent)"
elif echo "$TOOL" | grep -qi "pending_approval"; then
say "FAIL · Tools hängen in pending_approval (approvals.mode prüfen!)"
FAILED+=("Tools (hängen in Freigabe)")
else
say "FAIL · Tool-Smoke ohne Ergebnis: $(echo "$TOOL" | head -c 160)"
FAILED+=("Tools (kein Ergebnis)")
fi
fi
# ── 3) Voice: Lucys Sprech-Stream ────────────────────────────────────────────
# Retry: der erste Turn nach Idle zahlt evtl. den Session-Kaltstart (Prefill).
# Subshell OHNE pipefail — head -c schließt die Pipe früh (curl endet mit 23/SIGPIPE),
# das würde einen bestandenen Check sonst als FAIL werten (Lehre aus hermes-postcheck.sh).
voice_ok=0
for try in 1 2 3; do
if ( set +o pipefail; curl -sf -m 45 -N -X POST "$MC_URL/api/voice/chat" -H "Content-Type: application/json" \
-d '{"text":"Sag nur ok.","session_id":"selfsmoke-voice"}' 2>/dev/null | head -c 50 | grep -q "data:" ); then
voice_ok=1; break
fi
sleep 8
done
if [ "$voice_ok" -eq 1 ]; then
say "PASS · Voice (/api/voice/chat streamt, Versuch $try)"
else
say "FAIL · Voice: /api/voice/chat streamt nicht (3 Versuche)"
FAILED+=("Voice (Lucys Sprech-Pfad)")
fi
# ── Meldung nur bei Rot ──────────────────────────────────────────────────────
if [ "${#FAILED[@]}" -eq 0 ]; then
say "Alle Selbst-Tests grün ($(date '+%F %H:%M'))."
exit 0
fi
MSG="Commander, mein täglicher Selbst-Test hat etwas gefunden — das läuft gerade NICHT rund:
$(printf '• %s\n' "${FAILED[@]}")
Der Rest der Box kann trotzdem stehen; magst du kurz draufschauen?"
bash "$SRC_DIR/notify.sh" -s "[Selbst-Test]" "$MSG" || true
say "FEHLGESCHLAGEN: ${FAILED[*]}"
exit 1

Some files were not shown because too many files have changed in this diff Show More