Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a46df5e43a |
-27
@@ -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*
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +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
|
|
||||||
|
|
||||||
# Agent-Hooks laufen auf der Box (Shebang!) — .py-Hooks MÜSSEN LF behalten,
|
|
||||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
|
||||||
deploy/agent-hooks/*.py text eol=lf
|
|
||||||
deploy/*.py text eol=lf
|
|
||||||
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
|
|
||||||
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
|
|
||||||
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
|
|
||||||
deploy/**/*.py text eol=lf
|
|
||||||
|
|
||||||
# Windows-Batch-Wrapper bleiben CRLF.
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
*.bat text eol=crlf
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
|
||||||
#
|
|
||||||
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
|
||||||
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
|
||||||
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
|
||||||
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
|
||||||
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
|
||||||
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
|
||||||
#
|
|
||||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
|
||||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
|
||||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
|
||||||
name: Ampel
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ampel:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
|
||||||
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
|
||||||
set -u +e
|
|
||||||
rot=0
|
|
||||||
|
|
||||||
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
|
||||||
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
|
||||||
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
|
||||||
ruff check . || rot=1
|
|
||||||
|
|
||||||
echo "== Python: Import/Syntax (compileall) =="
|
|
||||||
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
|
||||||
|
|
||||||
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
|
||||||
if [ -f frontend/package.json ]; then
|
|
||||||
if [ ! -f frontend/package-lock.json ]; then
|
|
||||||
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
|
||||||
rot=1
|
|
||||||
else
|
|
||||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $rot -ne 0 ]; then
|
|
||||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
|
||||||
else
|
|
||||||
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
|
||||||
fi
|
|
||||||
exit $rot
|
|
||||||
-25
@@ -1,25 +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*
|
|
||||||
|
|
||||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
|
||||||
frontend/tsconfig.tsbuildinfo
|
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# Mission Control 2.0
|
|
||||||
|
|
||||||
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
|
||||||
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
|
|
||||||
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
|
|
||||||
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
|
||||||
|
|
||||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
|
||||||
|
|
||||||
## Die drei Bahnen
|
|
||||||
|
|
||||||
| Bahn | Was sie tut | Wo |
|
|
||||||
|---|---|---|
|
|
||||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
|
||||||
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
|
|
||||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
|
||||||
|
|
||||||
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
|
||||||
Bau-Sitzung und wird nie unterbrochen.
|
|
||||||
|
|
||||||
## Ports & Dienste
|
|
||||||
|
|
||||||
| Port | Dienst | Erreichbar |
|
|
||||||
|---|---|---|
|
|
||||||
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
|
||||||
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
|
|
||||||
| `8080` | **llama-swap** — Modell-Router | LAN |
|
|
||||||
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
|
||||||
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
|
||||||
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
|
||||||
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
|
||||||
|
|
||||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
|
|
||||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
|
||||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
|
||||||
|
|
||||||
## Der Weg einer Idee
|
|
||||||
|
|
||||||
```
|
|
||||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
|
|
||||||
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
|
|
||||||
```
|
|
||||||
|
|
||||||
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
|
||||||
Wächtern geboren**:
|
|
||||||
|
|
||||||
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
|
||||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
|
|
||||||
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
|
|
||||||
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
|
|
||||||
|
|
||||||
## Modelle & Rollen
|
|
||||||
|
|
||||||
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
|
|
||||||
|
|
||||||
| Rolle | Modell | Tempo | Aufgabe |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
|
|
||||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
|
|
||||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
|
|
||||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
|
|
||||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
|
|
||||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
|
||||||
|
|
||||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
|
||||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
|
||||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
|
||||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
|
||||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
|
||||||
|
|
||||||
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
|
||||||
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
|
||||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
|
||||||
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
|
||||||
|
|
||||||
## Qualitäts-Tore
|
|
||||||
|
|
||||||
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
|
|
||||||
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
|
|
||||||
2. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
|
|
||||||
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
|
|
||||||
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
|
|
||||||
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
|
|
||||||
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
|
|
||||||
|
|
||||||
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
|
||||||
Kontext-Komprimierung löscht still die Regeln mit.
|
|
||||||
|
|
||||||
## Selbsterhaltung
|
|
||||||
|
|
||||||
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
|
|
||||||
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
|
|
||||||
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
|
|
||||||
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
|
|
||||||
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
|
|
||||||
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
|
|
||||||
|
|
||||||
## API (Auswahl)
|
|
||||||
|
|
||||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
|
||||||
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
|
||||||
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
|
||||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
|
||||||
|
|
||||||
## Entwickeln
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
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)
|
|
||||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
|
||||||
|
|
||||||
# Frontend (Build → wird vom Backend ausgeliefert)
|
|
||||||
cd frontend && npm run build # → frontend/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
|
||||||
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
|
||||||
|
|
||||||
## Env-Vars (Auswahl)
|
|
||||||
|
|
||||||
| Variable | Default | Zweck |
|
|
||||||
|---|---|---|
|
|
||||||
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
|
||||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
|
||||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
|
||||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
|
||||||
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
|
|
||||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
|
||||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
|
||||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
|
||||||
|
|
||||||
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
|
|
||||||
[`deploy/governor/README.md`](deploy/governor/README.md).
|
|
||||||
|
|
||||||
## Weiterlesen
|
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
|
||||||
|---|---|
|
|
||||||
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
|
||||||
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
|
||||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
|
||||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
|
||||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
|
||||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
|
||||||
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
|
|
||||||
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
|
||||||
@@ -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).
|
|
||||||
-181
@@ -1,181 +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 collections.abc import AsyncGenerator, Awaitable, Callable
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from routers import (
|
|
||||||
agent,
|
|
||||||
auftragsbuch,
|
|
||||||
chronik,
|
|
||||||
connect,
|
|
||||||
console,
|
|
||||||
eigenleben,
|
|
||||||
events,
|
|
||||||
gateway_proxy,
|
|
||||||
governor,
|
|
||||||
health,
|
|
||||||
hermes_ui,
|
|
||||||
ideen,
|
|
||||||
maintenance,
|
|
||||||
memory,
|
|
||||||
models,
|
|
||||||
routing,
|
|
||||||
system,
|
|
||||||
voice,
|
|
||||||
wissen,
|
|
||||||
zeitmaschine,
|
|
||||||
)
|
|
||||||
from routers import reminders as reminders_router
|
|
||||||
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
|
||||||
from services import memory as memory_svc
|
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
# 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 ketten_digest.ENABLED:
|
|
||||||
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
|
|
||||||
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
|
|
||||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
|
||||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
|
||||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
|
||||||
log.info(
|
|
||||||
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
|
||||||
memory_svc.AUTO_DEDUPE_INTERVAL,
|
|
||||||
memory_svc.AUTO_DEDUPE_THRESHOLD,
|
|
||||||
)
|
|
||||||
# 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
|
|
||||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
|
||||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
|
||||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
|
||||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
|
||||||
if V1_UPSTREAM:
|
|
||||||
from routers import gateway_forward
|
|
||||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
|
||||||
else:
|
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
|
||||||
app.include_router(maintenance.router)
|
|
||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
|
||||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
|
||||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
|
||||||
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
|
|
||||||
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
|
||||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
|
||||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
|
||||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
|
||||||
app.include_router(
|
|
||||||
console.router
|
|
||||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
|
||||||
app.include_router(
|
|
||||||
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"}
|
|
||||||
@@ -1,136 +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,5–2× Durchsatz bei null Qualitätsverlust.
|
|
||||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
|
||||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
|
||||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
|
||||||
|
|
||||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
|
||||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
|
||||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
|
||||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
|
||||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
|
||||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
|
||||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
|
||||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
|
||||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
|
||||||
|
|
||||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
|
||||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
|
||||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
|
||||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
|
||||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
|
||||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
|
||||||
def _read_hermes_env(key: str) -> str:
|
|
||||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
|
||||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
|
||||||
try:
|
|
||||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
|
||||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith(f"{key}="):
|
|
||||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
HERMES_API_KEY = (
|
|
||||||
os.environ.get("HERMES_API_KEY")
|
|
||||||
or os.environ.get("API_SERVER_KEY")
|
|
||||||
or _read_hermes_env("API_SERVER_KEY")
|
|
||||||
)
|
|
||||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
|
||||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
|
||||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
|
||||||
|
|
||||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
|
||||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
|
||||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
|
||||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
|
||||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
|
||||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
|
||||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
|
||||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
|
||||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
|
||||||
BOX_CONSOLE_PATH = "/console/"
|
|
||||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
|
||||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
|
||||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
|
||||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
|
||||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
|
||||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
|
||||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
|
||||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
|
||||||
# GitHub-Repo für Update-Checks.
|
|
||||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
|
||||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
|
||||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
|
||||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
|
||||||
|
|
||||||
# --- Server ------------------------------------------------------------------
|
|
||||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
|
||||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
|
||||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
|
||||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
|
||||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
|
||||||
|
|
||||||
# Version (Phase 0 — Greenfield-Skeleton).
|
|
||||||
VERSION = "2.0.0-w8"
|
|
||||||
|
|
||||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
|
||||||
yaml = YAML()
|
|
||||||
yaml.preserve_quotes = True
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""
|
|
||||||
MC2-Gateway — der /v1-Datenpfad als EIGENER Prozess (UMBAU v3, P1).
|
|
||||||
|
|
||||||
Befund der Live-Inspektion 15.07.2026: Jeder LLM-Aufruf der Box (Lucys Haupt-Hirn,
|
|
||||||
Nacht-Crons, Worker-Delegation, Vision, Decomposer/Specifier/Curator) lief durch den
|
|
||||||
Steuerpult-Prozess auf :9001 — den am häufigsten neu gestarteten Dienst des Stacks.
|
|
||||||
Dieser Einstieg hebt denselben Gateway-Router (routers/gateway_proxy.py) UNVERÄNDERT
|
|
||||||
in einen bewusst winzigen, langweiligen Prozess: Unit mc2-gateway.service, Loopback
|
|
||||||
:9010, Restart=always. Das Steuerpult darf beliebig neu starten — die Wirbelsäule steht.
|
|
||||||
|
|
||||||
Bewusst NICHT hier: weitere Router, Hintergrund-Loops, CORS, Frontend-Auslieferung.
|
|
||||||
LAN-Clients (IDE-Lane) erreichen /v1 weiter über MC2 :9001, das roh hierher
|
|
||||||
durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import LLAMA_SWAP_URL, VERSION
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from routers import gateway_proxy
|
|
||||||
|
|
||||||
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]:
|
|
||||||
# Gleiche Client-Parameter wie zuvor in app.py: Keep-Alive/Pooling statt neuer
|
|
||||||
# Client pro Anfrage (Sockets/TIME_WAIT unter parallelen Agent-Strömen).
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
log.info("mc2-gateway bereit (Engine: %s)", LLAMA_SWAP_URL)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
await app.state.gw_client.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MC2 Gateway", version=VERSION, lifespan=lifespan)
|
|
||||||
app.include_router(gateway_proxy.router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/gw/health")
|
|
||||||
async def health(request: Request):
|
|
||||||
"""Eigener Health-Pfad (nicht /api/health — das gehört dem Steuerpult):
|
|
||||||
beweist Prozess UND Engine-Erreichbarkeit, für deploy.sh/stack-postcheck.sh."""
|
|
||||||
engine = False
|
|
||||||
try:
|
|
||||||
r = await request.app.state.gw_client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=5.0)
|
|
||||||
engine = r.status_code == 200
|
|
||||||
except httpx.HTTPError:
|
|
||||||
pass
|
|
||||||
return {"status": "ok", "service": "mc2-gateway", "version": VERSION,
|
|
||||||
"engine_reachable": engine}
|
|
||||||
@@ -1,57 +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 config import CONFIG_PATH
|
|
||||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,71 +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
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
|
||||||
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
|
||||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
|
||||||
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
|
||||||
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
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}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
|
|
||||||
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import auftragsbuch
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class BranchIn(BaseModel):
|
|
||||||
branch: str
|
|
||||||
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
|
|
||||||
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
|
|
||||||
|
|
||||||
|
|
||||||
class KandidatIn(BaseModel):
|
|
||||||
file: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch")
|
|
||||||
def list_proposals() -> dict:
|
|
||||||
return auftragsbuch.list_proposals()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch/diff")
|
|
||||||
def diff(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
res = auftragsbuch.diff_of(branch, repo)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/annehmen")
|
|
||||||
def accept(body: BranchIn) -> dict:
|
|
||||||
res = auftragsbuch.accept(body.branch, body.repo)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/ablehnen")
|
|
||||||
def reject(body: BranchIn) -> dict:
|
|
||||||
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/annehmen")
|
|
||||||
def skill_accept(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_accept(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/ablehnen")
|
|
||||||
def skill_reject(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_reject(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
|
|
||||||
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
|
|
||||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
|
||||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import announce
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
ts: float
|
|
||||||
subject: str
|
|
||||||
text: str
|
|
||||||
source: str
|
|
||||||
priority: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikResponse(BaseModel):
|
|
||||||
items: list[ChronikItem]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chronik", response_model=ChronikResponse)
|
|
||||||
def chronik(limit: int = 150) -> dict:
|
|
||||||
return {"items": announce.list_recent(limit)}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect")
|
|
||||||
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
|
|
||||||
kwargs = {}
|
|
||||||
if mcp_path:
|
|
||||||
kwargs["mcp_script_path"] = mcp_path
|
|
||||||
return build_snippets(host=host, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect/health")
|
|
||||||
def connect_health() -> dict:
|
|
||||||
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
|
|
||||||
return check_health()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
|
|
||||||
|
|
||||||
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
|
|
||||||
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
|
|
||||||
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
|
|
||||||
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
|
|
||||||
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
|
|
||||||
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from config import BOX_CONSOLE_UPSTREAM
|
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _register(base: str, upstream: str) -> None:
|
|
||||||
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
|
|
||||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
|
||||||
|
|
||||||
@router.websocket(f"/{base}/ws")
|
|
||||||
async def _proxy_ws(ws: WebSocket) -> None:
|
|
||||||
await ws.accept(subprotocol="tty")
|
|
||||||
try:
|
|
||||||
async with websockets.connect(
|
|
||||||
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
|
|
||||||
open_timeout=10, max_size=None, ping_interval=None,
|
|
||||||
) as up:
|
|
||||||
async def c2u() -> None:
|
|
||||||
while True:
|
|
||||||
msg = await ws.receive()
|
|
||||||
if msg.get("type") == "websocket.disconnect":
|
|
||||||
return
|
|
||||||
if (t := msg.get("text")) is not None:
|
|
||||||
await up.send(t)
|
|
||||||
elif (b := msg.get("bytes")) is not None:
|
|
||||||
await up.send(b)
|
|
||||||
|
|
||||||
async def u2c() -> None:
|
|
||||||
async for m in up:
|
|
||||||
if isinstance(m, (bytes, bytearray)):
|
|
||||||
await ws.send_bytes(bytes(m))
|
|
||||||
else:
|
|
||||||
await ws.send_text(m)
|
|
||||||
|
|
||||||
_done, pending = await asyncio.wait(
|
|
||||||
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
except Exception:
|
|
||||||
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
|
||||||
url = f"{upstream}/{base}/{path}"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
||||||
r = await c.request(request.method, url, content=await request.body())
|
|
||||||
return Response(content=r.content, status_code=r.status_code,
|
|
||||||
media_type=r.headers.get("content-type"))
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
|
|
||||||
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
|
|
||||||
media_type="text/plain")
|
|
||||||
|
|
||||||
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
|
|
||||||
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
|
|
||||||
name=f"{base}_path")
|
|
||||||
|
|
||||||
|
|
||||||
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
|
|
||||||
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
|
|
||||||
_register("console", BOX_CONSOLE_UPSTREAM)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import eigenleben
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class SkillItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
kategorie: str | None
|
|
||||||
aktiv: bool | None
|
|
||||||
beschreibung: str
|
|
||||||
version: str
|
|
||||||
autor: str
|
|
||||||
herkunft: str
|
|
||||||
herkunft_label: str
|
|
||||||
genutzt: int
|
|
||||||
zuletzt_genutzt: str | None
|
|
||||||
erstellt: str | None
|
|
||||||
status: str
|
|
||||||
geaendert: float
|
|
||||||
|
|
||||||
|
|
||||||
class EigenlebenOverviewResponse(BaseModel):
|
|
||||||
available: bool
|
|
||||||
skills: list[SkillItem]
|
|
||||||
selbst_erstellt: int
|
|
||||||
bilanz: dict
|
|
||||||
|
|
||||||
|
|
||||||
class SkillTextResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
|
|
||||||
def get_overview() -> dict:
|
|
||||||
return eigenleben.overview()
|
|
||||||
|
|
||||||
|
|
||||||
# :path — Kategorie-Skills haben einen Schrägstrich in der ID (autonomous-ai-agents/
|
|
||||||
# hermes-agent). Als normales {skill_id}-Segment matcht die Route dann nicht, der Request
|
|
||||||
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
|
|
||||||
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
|
|
||||||
# services.eigenleben.skill_text.
|
|
||||||
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
|
|
||||||
def get_skill(skill_id: str) -> dict:
|
|
||||||
text = eigenleben.skill_text(skill_id)
|
|
||||||
if text is None:
|
|
||||||
raise HTTPException(404, "Skill nicht gefunden.")
|
|
||||||
return {"id": skill_id, "text": text}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
|
||||||
|
|
||||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
|
||||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
|
||||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
|
||||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
|
||||||
|
|
||||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
|
||||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
|
||||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
|
||||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
|
||||||
invalidate-Event nur Lärm).
|
|
||||||
|
|
||||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
|
||||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
|
||||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
|
||||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
|
||||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
|
||||||
|
|
||||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
|
||||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
|
||||||
selbst und bis dahin pollt die UI wie bisher).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
|
||||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
|
||||||
|
|
||||||
|
|
||||||
def _mtime(p: Path) -> float:
|
|
||||||
try:
|
|
||||||
return p.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprints() -> dict[str, object]:
|
|
||||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
|
||||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
|
||||||
fp: dict[str, object] = {}
|
|
||||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
|
||||||
from services import announce
|
|
||||||
fp["chronik"] = announce.list_after(None)["latest"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
|
||||||
from services import ideen
|
|
||||||
d = ideen.list_queue()
|
|
||||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
|
||||||
from services import llamaswap
|
|
||||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
|
||||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
|
||||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
|
||||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
|
||||||
return fp
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
|
||||||
async def events(request: Request) -> StreamingResponse:
|
|
||||||
async def strom():
|
|
||||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
|
||||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
|
||||||
alt = _fingerprints()
|
|
||||||
yield ": verbunden\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
return
|
|
||||||
await asyncio.sleep(TICK_S)
|
|
||||||
seit_ping += TICK_S
|
|
||||||
neu = _fingerprints()
|
|
||||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
|
||||||
alt.update(neu)
|
|
||||||
if keys:
|
|
||||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
elif seit_ping >= KEEPALIVE_S:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
|
|
||||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""
|
|
||||||
Dünner /v1-Roh-Weiterleiter (UMBAU v3, P1).
|
|
||||||
|
|
||||||
Wenn MC_V1_UPSTREAM gesetzt ist (Unit-Env), reicht das Steuerpult /v1 unangefasst an
|
|
||||||
den eigenständigen mc2-gateway-Prozess (Loopback :9010) durch — LAN-Clients wie die
|
|
||||||
IDE-Lane erreichen den Gateway sonst nicht. Hier passiert BEWUSST nichts: kein
|
|
||||||
Routing, keine Bild-Weiche, keine Token-Zählung — all das macht genau einmal der
|
|
||||||
Gateway-Prozess (routers/gateway_proxy.py). Rollback = Env-Zeile aus der Unit
|
|
||||||
entfernen → app.py bindet wieder den lokalen Gateway ein.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from config import V1_UPSTREAM
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
|
||||||
|
|
||||||
# Hop-by-hop-Header dürfen nicht blind weitergereicht werden; content-length wird von
|
|
||||||
# httpx (Request) bzw. Starlette-Chunking (Response) neu bestimmt.
|
|
||||||
_HOP_HEADERS = {
|
|
||||||
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
|
|
||||||
"upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _clean(headers) -> dict:
|
|
||||||
return {k: v for k, v in headers.items() if k.lower() not in _HOP_HEADERS}
|
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/{path:path}", methods=["GET", "POST"])
|
|
||||||
async def forward(path: str, request: Request):
|
|
||||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (app.py lifespan)
|
|
||||||
url = f"{V1_UPSTREAM}/v1/{path}"
|
|
||||||
if request.url.query:
|
|
||||||
url = f"{url}?{request.url.query}"
|
|
||||||
body = await request.body()
|
|
||||||
req = client.build_request(
|
|
||||||
request.method, url, content=body or None, headers=_clean(request.headers),
|
|
||||||
timeout=None,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
upstream = await client.send(req, stream=True)
|
|
||||||
except Exception as exc: # Gateway-Prozess weg → ehrlicher 502 statt Hänger
|
|
||||||
log.warning("/v1-Weiterleitung an %s fehlgeschlagen: %s", V1_UPSTREAM, exc)
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": {"message": f"mc2-gateway ({V1_UPSTREAM}) nicht erreichbar: {exc}",
|
|
||||||
"type": "gateway_unavailable"}},
|
|
||||||
status_code=502,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def gen():
|
|
||||||
try:
|
|
||||||
async for chunk in upstream.aiter_raw():
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
await upstream.aclose()
|
|
||||||
|
|
||||||
# Streaming-Passthrough für BEIDE Fälle (SSE + normale JSON-Antwort): Starlette
|
|
||||||
# chunkt selbst, Status/Header (inkl. x-mc-routed-to) kommen vom Gateway.
|
|
||||||
return StreamingResponse(
|
|
||||||
gen(), status_code=upstream.status_code, headers=_clean(upstream.headers)
|
|
||||||
)
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import LLAMA_SWAP_URL
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
|
||||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
|
||||||
from services.routing_policy import load_policy
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
|
|
||||||
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
|
|
||||||
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
|
|
||||||
_BILD_BESCHREIB_PROMPT = os.environ.get(
|
|
||||||
"MC_CODER_IMAGE_PROMPT",
|
|
||||||
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
|
|
||||||
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
|
|
||||||
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
|
|
||||||
|
|
||||||
|
|
||||||
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
|
|
||||||
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
|
|
||||||
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
|
|
||||||
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
|
|
||||||
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
|
|
||||||
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
|
|
||||||
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_no_think_marker(body: dict) -> None:
|
|
||||||
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
|
|
||||||
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
|
|
||||||
if not _NO_THINK_MARKER:
|
|
||||||
return
|
|
||||||
found = False
|
|
||||||
for m in body.get("messages") or []:
|
|
||||||
if not isinstance(m, dict):
|
|
||||||
continue
|
|
||||||
c = m.get("content")
|
|
||||||
if isinstance(c, str) and _NO_THINK_MARKER in c:
|
|
||||||
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
|
|
||||||
found = True
|
|
||||||
elif isinstance(c, list):
|
|
||||||
for part in c:
|
|
||||||
if (isinstance(part, dict) and isinstance(part.get("text"), str)
|
|
||||||
and _NO_THINK_MARKER in part["text"]):
|
|
||||||
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
|
|
||||||
found = True
|
|
||||||
if found and "chat_template_kwargs" not in body:
|
|
||||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
|
||||||
|
|
||||||
|
|
||||||
def _ist_coder_alias(alias: str) -> bool:
|
|
||||||
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
|
||||||
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
|
||||||
return "coder" in (alias or "").lower()
|
|
||||||
|
|
||||||
|
|
||||||
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
|
|
||||||
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
|
|
||||||
|
|
||||||
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
|
|
||||||
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
|
|
||||||
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
|
|
||||||
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
|
|
||||||
Vision-Umleitung als Fallback.
|
|
||||||
"""
|
|
||||||
fundstellen: list[tuple[dict, int, dict]] = []
|
|
||||||
for m in body.get("messages") or []:
|
|
||||||
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
|
|
||||||
continue
|
|
||||||
for i, part in enumerate(m["content"]):
|
|
||||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
|
||||||
fundstellen.append((m, i, part))
|
|
||||||
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
|
|
||||||
frage = {
|
|
||||||
"model": vision_alias,
|
|
||||||
"stream": False,
|
|
||||||
"max_tokens": 700,
|
|
||||||
"messages": [{"role": "user",
|
|
||||||
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
|
||||||
}
|
|
||||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
|
|
||||||
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
|
|
||||||
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
|
|
||||||
text = ""
|
|
||||||
for versuch in (1, 2):
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=240.0) as c:
|
|
||||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
|
||||||
if r.status_code == 200:
|
|
||||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
||||||
if text.strip():
|
|
||||||
break
|
|
||||||
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
|
||||||
versuch, r.status_code, r.text)
|
|
||||||
except Exception:
|
|
||||||
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
|
|
||||||
if not text.strip():
|
|
||||||
return False
|
|
||||||
msg["content"][idx] = {"type": "text", "text": (
|
|
||||||
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
|
|
||||||
f"{text.strip()}]")}
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
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})
|
|
||||||
|
|
||||||
@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()
|
|
||||||
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
|
||||||
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
|
||||||
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
|
|
||||||
# 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] = []
|
|
||||||
aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt
|
|
||||||
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
|
|
||||||
aliases = m.get("aliases", [])
|
|
||||||
if aliases:
|
|
||||||
aliased_raw.add(m["name"])
|
|
||||||
for alias in 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)
|
|
||||||
# Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der
|
|
||||||
# Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt.
|
|
||||||
raw = [e for e in data["data"] if e.get("id") not in aliased_raw]
|
|
||||||
data["data"] = alias_entries + raw
|
|
||||||
return JSONResponse(data, status_code=r.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
async def _proxy(path: str, request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
|
||||||
requested = str(body.get("model") or "auto")
|
|
||||||
if requested.lower() in ("auto", "chat", "coding"):
|
|
||||||
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}
|
|
||||||
|
|
||||||
pol = load_policy()
|
|
||||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
|
||||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
|
|
||||||
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
|
|
||||||
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
|
|
||||||
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
|
|
||||||
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
|
|
||||||
vision_alias = pol.get("vision")
|
|
||||||
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
|
|
||||||
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
|
|
||||||
routed = {"x-mc-routed-to": alias,
|
|
||||||
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
|
|
||||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
|
||||||
else:
|
|
||||||
alias = vision_alias
|
|
||||||
body["model"] = alias
|
|
||||||
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
|
|
||||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
|
|
||||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
|
||||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
|
||||||
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}"
|
|
||||||
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, body.get("max_tokens"))
|
|
||||||
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()
|
|
||||||
if isinstance(resp_json, dict):
|
|
||||||
record_usage(resp_json.get("usage"), alias)
|
|
||||||
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
|
||||||
for c in resp_json.get("choices") or []):
|
|
||||||
warn_truncation(alias, body.get("max_tokens"))
|
|
||||||
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)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Governor — Fenster auf den Token-Waechter (:8100).
|
|
||||||
|
|
||||||
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
|
||||||
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
|
||||||
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
|
||||||
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
|
||||||
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
|
||||||
zweiten Port ausweichen muss.
|
|
||||||
|
|
||||||
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
|
||||||
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/governor")
|
|
||||||
async def governor_status() -> dict:
|
|
||||||
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as c:
|
|
||||||
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
data["reachable"] = True
|
|
||||||
return data
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"reachable": False,
|
|
||||||
"error": f"{type(exc).__name__}: {exc}",
|
|
||||||
"url": GOVERNOR_URL,
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
|
||||||
|
|
||||||
from config import VERSION
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import gateway, llamaswap
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
status: str
|
|
||||||
version: str
|
|
||||||
engine_reachable: bool
|
|
||||||
gateway_reachable: bool
|
|
||||||
brain: dict
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
@@ -1,134 +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 config import HERMES_BUILTIN_UI_UPSTREAM
|
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
|
||||||
from starlette.responses import RedirectResponse, Response
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
|
|
||||||
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import ideen
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class IdeeIn(BaseModel):
|
|
||||||
titel: str
|
|
||||||
notiz: str = ""
|
|
||||||
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed
|
|
||||||
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
|
||||||
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
|
|
||||||
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
|
|
||||||
|
|
||||||
class AntwortIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
antwort: str
|
|
||||||
|
|
||||||
|
|
||||||
class ArchivAlleIn(BaseModel):
|
|
||||||
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
|
|
||||||
projekt: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class EinordnenIn(BaseModel):
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen")
|
|
||||||
def list_queue() -> dict:
|
|
||||||
return ideen.list_queue()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen")
|
|
||||||
def add_idea(body: IdeeIn) -> dict:
|
|
||||||
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
|
|
||||||
ziel=body.ziel, art=body.art)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/antwort")
|
|
||||||
def answer(body: AntwortIn) -> dict:
|
|
||||||
res = ideen.answer_task(body.id, body.antwort)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class PrioIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
richtung: str # hoch | runter
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/stopp")
|
|
||||||
def stop(body: TaskIn) -> dict:
|
|
||||||
res = ideen.stop_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/retry")
|
|
||||||
def retry(body: TaskIn) -> dict:
|
|
||||||
res = ideen.retry_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/prio")
|
|
||||||
def prio(body: PrioIn) -> dict:
|
|
||||||
res = ideen.prio_task(body.id, body.richtung)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class WeiterIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
|
|
||||||
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/weiterfuehren")
|
|
||||||
def weiterfuehren(body: WeiterIn) -> dict:
|
|
||||||
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
|
|
||||||
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class NachschaerfenIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
hinweis: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/nachschaerfen")
|
|
||||||
def nachschaerfen(body: NachschaerfenIn) -> dict:
|
|
||||||
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
|
|
||||||
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/archivieren")
|
|
||||||
def archive(body: TaskIn) -> dict:
|
|
||||||
res = ideen.archive_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/einordnen")
|
|
||||||
def einordnen(body: EinordnenIn) -> dict:
|
|
||||||
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
|
|
||||||
und die Oberfläche behält ihre Voreinstellung."""
|
|
||||||
return ideen.einordnen(body.text)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/archivieren-alle")
|
|
||||||
def archive_alle(body: ArchivAlleIn) -> dict:
|
|
||||||
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
|
|
||||||
res = ideen.archive_done(body.projekt)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/log")
|
|
||||||
def get_log(id: str) -> dict:
|
|
||||||
return ideen.log_of(id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/ergebnis")
|
|
||||||
def get_ergebnis(id: str) -> dict:
|
|
||||||
return ideen.ergebnis_of(id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/konzept")
|
|
||||||
def get_konzept(id: str) -> dict:
|
|
||||||
return ideen.konzept_of(id)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
|
||||||
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)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import memory
|
|
||||||
from services.voice_metrics import park, record_stage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class MemIn(BaseModel):
|
|
||||||
content: str
|
|
||||||
category: str = "knowledge"
|
|
||||||
source: str = "manual"
|
|
||||||
|
|
||||||
|
|
||||||
class MemUp(BaseModel):
|
|
||||||
content: str | None = None
|
|
||||||
category: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DedupeIn(BaseModel):
|
|
||||||
apply: bool = False
|
|
||||||
threshold: float = 0.85
|
|
||||||
|
|
||||||
|
|
||||||
class LearnIn(BaseModel):
|
|
||||||
text: str | None = None
|
|
||||||
messages: list[dict] | None = None
|
|
||||||
source: str = "auto"
|
|
||||||
category: str = "knowledge"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory/export")
|
|
||||||
def export() -> dict:
|
|
||||||
return memory.export_text()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory/graph")
|
|
||||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
|
||||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
|
|
||||||
return memory.graph(min_score=min_score, top_k=top_k)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/learn", status_code=201)
|
|
||||||
def learn(body: LearnIn) -> dict:
|
|
||||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
|
||||||
return memory.learn(text=body.text, messages=body.messages,
|
|
||||||
source=body.source, category=body.category)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/dedupe")
|
|
||||||
def dedupe(body: DedupeIn) -> dict:
|
|
||||||
return memory.dedupe(apply=body.apply, threshold=body.threshold)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory")
|
|
||||||
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
|
||||||
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
|
|
||||||
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
|
|
||||||
if q:
|
|
||||||
_t0 = time.perf_counter()
|
|
||||||
res = memory.list_memories(q=q, category=category)
|
|
||||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
|
||||||
record_stage("memory_retrieve", _ms)
|
|
||||||
park("retrieve", _ms)
|
|
||||||
return res
|
|
||||||
return memory.list_memories(q=q, category=category)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory", status_code=201)
|
|
||||||
def add(body: MemIn) -> dict:
|
|
||||||
if body.category not in memory.CATEGORIES:
|
|
||||||
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
|
|
||||||
return memory.add_memory(body.content, body.category, body.source)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/memory/{mid}")
|
|
||||||
def update(mid: str, body: MemUp) -> dict:
|
|
||||||
res = memory.update_memory(mid, content=body.content, category=body.category)
|
|
||||||
if not res:
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/memory/{mid}")
|
|
||||||
def delete(mid: str) -> dict:
|
|
||||||
if not memory.delete_memory(mid):
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
return {"ok": True}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
|
||||||
from services.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 (robust gegen -watch-config) — aber OHNE den Rollen-Alias
|
|
||||||
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
|
|
||||||
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
|
|
||||||
try:
|
|
||||||
model_id = llamaswap.register_model(
|
|
||||||
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
|
|
||||||
set_alias=False)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise HTTPException(500, str(exc))
|
|
||||||
|
|
||||||
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
|
|
||||||
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
|
|
||||||
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
|
|
||||||
role = (req.role or "").strip().lower()
|
|
||||||
|
|
||||||
def _apply_role() -> None:
|
|
||||||
if role == "hermes":
|
|
||||||
from services.agent import set_agent_brain
|
|
||||||
res = set_agent_brain(model_id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
|
|
||||||
else:
|
|
||||||
llamaswap.set_role(model_id, role)
|
|
||||||
|
|
||||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
|
||||||
args = [hf.hf_bin(), "download", repo]
|
|
||||||
args.extend(info["files"])
|
|
||||||
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,
|
|
||||||
on_done=_apply_role if role else None)
|
|
||||||
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:
|
|
||||||
new_role = (body.role or "").strip().lower()
|
|
||||||
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
|
|
||||||
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
|
|
||||||
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
|
|
||||||
prot = llamaswap.protected_alias_of(model_id)
|
|
||||||
if prot and new_role != prot:
|
|
||||||
raise HTTPException(400,
|
|
||||||
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
|
|
||||||
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
|
|
||||||
# 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 new_role == "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}
|
|
||||||
@@ -1,49 +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
|
|
||||||
|
|
||||||
|
|
||||||
class ReminderOut(BaseModel):
|
|
||||||
id: int
|
|
||||||
text: str
|
|
||||||
next_ts: float
|
|
||||||
repeat: str
|
|
||||||
created_ts: float
|
|
||||||
when_local: str
|
|
||||||
|
|
||||||
|
|
||||||
class ReminderListResponse(BaseModel):
|
|
||||||
items: list[ReminderOut]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reminders", response_model=ReminderListResponse)
|
|
||||||
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]))
|
|
||||||
@@ -1,42 +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}
|
|
||||||
@@ -1,175 +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
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system/history")
|
|
||||||
def history(minutes: int = 60) -> dict:
|
|
||||||
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
|
||||||
from services import metrics_history
|
|
||||||
return metrics_history.history(minutes)
|
|
||||||
|
|
||||||
|
|
||||||
def _mem0_reachable() -> bool:
|
|
||||||
try:
|
|
||||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _voice_reachable() -> bool:
|
|
||||||
try:
|
|
||||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _user_unit_active(unit: str) -> bool:
|
|
||||||
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
|
||||||
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
|
||||||
if os.name != "posix":
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
|
||||||
capture_output=True, text=True, timeout=3)
|
|
||||||
return r.stdout.strip() == "active"
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system/services")
|
|
||||||
def services() -> dict:
|
|
||||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
|
||||||
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
|
||||||
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
|
||||||
a = agent_status()
|
|
||||||
gw_url = f"{GATEWAY_URL}/v1"
|
|
||||||
rows = [
|
|
||||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
|
||||||
"ok": engine_reachable(), "scope": "system"},
|
|
||||||
]
|
|
||||||
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
|
||||||
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
|
||||||
if V1_UPSTREAM:
|
|
||||||
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
|
||||||
"ok": gateway_reachable(), "scope": "user"})
|
|
||||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
|
||||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
|
||||||
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
|
||||||
"ok": True, "scope": "user"})
|
|
||||||
else:
|
|
||||||
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
|
||||||
"ok": gateway_reachable(), "scope": "user"})
|
|
||||||
rows += [
|
|
||||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
|
||||||
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
|
||||||
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
|
||||||
"ok": a["gateway_reachable"], "scope": "user"},
|
|
||||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
|
||||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
|
||||||
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
|
|
||||||
"ok": _mem0_reachable(), "scope": "user"},
|
|
||||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
|
||||||
"ok": _voice_reachable(), "scope": "user"},
|
|
||||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
|
||||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
|
||||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
|
||||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
"services": rows,
|
|
||||||
"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:
|
|
||||||
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)
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
|
||||||
import sys as _sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
||||||
from fastapi.responses import Response, StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
_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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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")
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
|
|
||||||
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
|
|
||||||
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
|
||||||
|
|
||||||
|
|
||||||
def _available() -> bool:
|
|
||||||
return VAULT.is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
def _title_of(p: Path) -> str:
|
|
||||||
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
|
|
||||||
try:
|
|
||||||
with p.open(encoding="utf-8", errors="replace") as fh:
|
|
||||||
for line in fh:
|
|
||||||
s = line.strip()
|
|
||||||
if s:
|
|
||||||
return s.lstrip("# ").strip()[:160]
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return p.stem
|
|
||||||
|
|
||||||
|
|
||||||
class VaultFile(BaseModel):
|
|
||||||
path: str
|
|
||||||
name: str
|
|
||||||
dir: str
|
|
||||||
title: str
|
|
||||||
mtime: float
|
|
||||||
neu: bool
|
|
||||||
|
|
||||||
|
|
||||||
class WissenListResponse(BaseModel):
|
|
||||||
available: bool
|
|
||||||
files: list[VaultFile]
|
|
||||||
|
|
||||||
|
|
||||||
class WissenFileResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
content: str
|
|
||||||
mtime: float
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wissen", response_model=WissenListResponse)
|
|
||||||
def list_vault() -> dict:
|
|
||||||
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
|
|
||||||
if not _available():
|
|
||||||
return {"available": False, "files": []}
|
|
||||||
files = []
|
|
||||||
now = time.time()
|
|
||||||
for p in sorted(VAULT.rglob("*.md")):
|
|
||||||
if ".git" in p.parts:
|
|
||||||
continue
|
|
||||||
rel = p.relative_to(VAULT).as_posix()
|
|
||||||
st = p.stat()
|
|
||||||
files.append({
|
|
||||||
"path": rel,
|
|
||||||
"name": p.stem,
|
|
||||||
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
|
|
||||||
"title": _title_of(p),
|
|
||||||
"mtime": st.st_mtime,
|
|
||||||
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
|
|
||||||
})
|
|
||||||
files.sort(key=lambda f: f["mtime"], reverse=True)
|
|
||||||
return {"available": True, "files": files}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wissen/datei", response_model=WissenFileResponse)
|
|
||||||
def read_file(pfad: str) -> dict:
|
|
||||||
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
|
|
||||||
if not _available():
|
|
||||||
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
|
|
||||||
try:
|
|
||||||
target = (VAULT / pfad).resolve()
|
|
||||||
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
|
|
||||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
|
||||||
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
|
|
||||||
"mtime": target.stat().st_mtime}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except (ValueError, OSError):
|
|
||||||
raise HTTPException(404, "Notiz nicht lesbar.")
|
|
||||||
@@ -1,70 +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}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Parse systemd timeout errors for mission-control-2.service from journalctl.
|
|
||||||
|
|
||||||
Captures lines containing "State 'stop-sigterm' timed out" and appends them
|
|
||||||
to ~/logs/mc2-timeout.log with ISO timestamps.
|
|
||||||
|
|
||||||
Permission errors are handled gracefully.
|
|
||||||
|
|
||||||
Uses user journal (systemctl --user) to query logs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
LOG_DIR = Path.home() / "logs"
|
|
||||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
|
||||||
SERVICE_NAME = "mission-control-2.service"
|
|
||||||
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
|
|
||||||
JOURNALCTL_LIMIT = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_log_dir() -> None:
|
|
||||||
"""Create log directory if it doesn't exist."""
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_timeout_entries() -> list[str]:
|
|
||||||
"""Run journalctl --user and return lines matching the timeout pattern."""
|
|
||||||
cmd = [
|
|
||||||
"journalctl",
|
|
||||||
"--user",
|
|
||||||
"-u", SERVICE_NAME,
|
|
||||||
"--no-pager",
|
|
||||||
"-n", str(JOURNALCTL_LIMIT),
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print("journalctl timed out")
|
|
||||||
return []
|
|
||||||
except PermissionError:
|
|
||||||
print("Permission denied: journalctl requires access to user logs")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
if "Permission denied" in result.stderr:
|
|
||||||
print("Permission denied: journalctl requires access to user logs")
|
|
||||||
else:
|
|
||||||
print(f"journalctl failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
line
|
|
||||||
for line in result.stdout.splitlines()
|
|
||||||
if SEARCH_PATTERN in line
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def write_to_log(entries: list[str]) -> int:
|
|
||||||
"""Append entries to log file with ISO timestamps. Returns count written."""
|
|
||||||
ensure_log_dir()
|
|
||||||
|
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
|
||||||
written = 0
|
|
||||||
|
|
||||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
|
||||||
for entry in entries:
|
|
||||||
f.write(f"[{timestamp}] {entry}\n")
|
|
||||||
written += 1
|
|
||||||
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
entries = get_timeout_entries()
|
|
||||||
if not entries:
|
|
||||||
print("No timeout entries found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
count = write_to_log(entries)
|
|
||||||
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,268 +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
|
|
||||||
from config import (
|
|
||||||
BOX_CONSOLE_PATH,
|
|
||||||
BOX_CONSOLE_UPSTREAM,
|
|
||||||
HERMES_API_URL,
|
|
||||||
HERMES_BUILTIN_UI_PATH,
|
|
||||||
HERMES_BUILTIN_UI_UPSTREAM,
|
|
||||||
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"),
|
|
||||||
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
|
||||||
"pc_executor_url": PC_EXECUTOR_URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 DEFAULT_TTL, set_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:
|
|
||||||
from services import 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
|
|
||||||
@@ -1,130 +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
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
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"))
|
|
||||||
|
|
||||||
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
|
|
||||||
# (mc2-steward), darf er den Store NICHT direkt schreiben — Datei + In-Memory-Cache
|
|
||||||
# gehören exklusiv dem Steuerpult. Gesetzt (Unit-Env, z. B. http://127.0.0.1:9001)
|
|
||||||
# liefert add() die Meldung stattdessen per HTTP am /api/voice/announce-Endpunkt ab.
|
|
||||||
ANNOUNCE_HTTP = os.environ.get("MC_ANNOUNCE_HTTP", "").rstrip("/")
|
|
||||||
|
|
||||||
_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.")
|
|
||||||
if ANNOUNCE_HTTP:
|
|
||||||
# Fremdprozess-Modus (mc2-steward): per HTTP beim Steuerpult abliefern.
|
|
||||||
payload = {"text": text[:4000], "subject": (subject or "").strip()[:120],
|
|
||||||
"source": (source or "").strip()[:60], "priority": priority}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5.0) as c:
|
|
||||||
r = c.post(f"{ANNOUNCE_HTTP}/api/voice/announce", json=payload)
|
|
||||||
if r.status_code == 200:
|
|
||||||
return (r.json() or {}).get("item") or payload
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# MC2 down/Endpunkt weg: Alarm ist nicht verloren — der Telegram-Direktweg des
|
|
||||||
# Aufrufers (notify.sh) läuft separat; hier bleibt nur der Log-Nachweis.
|
|
||||||
log.warning("announce: HTTP-Abgabe an %s fehlgeschlagen — nur im Log: [%s] %.120s",
|
|
||||||
ANNOUNCE_HTTP, subject or "-", text)
|
|
||||||
return payload
|
|
||||||
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}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
"""
|
|
||||||
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
|
|
||||||
|
|
||||||
Quellen der Karten:
|
|
||||||
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
|
|
||||||
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
|
|
||||||
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
|
|
||||||
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
|
|
||||||
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
|
|
||||||
|
|
||||||
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
|
|
||||||
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
|
|
||||||
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
|
|
||||||
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
|
|
||||||
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
|
|
||||||
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
|
|
||||||
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
|
|
||||||
|
|
||||||
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
|
|
||||||
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
|
|
||||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
|
||||||
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
|
|
||||||
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
|
|
||||||
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
|
|
||||||
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
|
|
||||||
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
|
|
||||||
|
|
||||||
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
|
|
||||||
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
|
|
||||||
REPOS: dict[str, dict] = {
|
|
||||||
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
|
|
||||||
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
|
|
||||||
# Muster wie mc2-announce.json unter MODELS_DIR.
|
|
||||||
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
|
|
||||||
|
|
||||||
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
|
|
||||||
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
|
|
||||||
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
|
|
||||||
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
|
|
||||||
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
|
|
||||||
|
|
||||||
_fetch_cache: dict = {}
|
|
||||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
|
||||||
|
|
||||||
|
|
||||||
def _available() -> bool:
|
|
||||||
return os.name == "posix" and (REPO / ".git").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_ok(repo: str) -> bool:
|
|
||||||
r = REPOS.get(repo)
|
|
||||||
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
|
|
||||||
capture_output=True, text=True, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _status_key(repo: str, branch: str) -> str:
|
|
||||||
return f"{REPOS[repo]['key']}{branch}"
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_throttled(repo: str) -> None:
|
|
||||||
now = time.time()
|
|
||||||
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
|
|
||||||
return
|
|
||||||
_fetch_cache[repo] = now
|
|
||||||
try:
|
|
||||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_announce_branches() -> set:
|
|
||||||
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
|
|
||||||
try:
|
|
||||||
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
|
|
||||||
return set(data) if isinstance(data, list) else set()
|
|
||||||
except Exception:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _save_announce_branches(branches: set) -> None:
|
|
||||||
"""Speichert die Menge gemelder Branch-Namen."""
|
|
||||||
try:
|
|
||||||
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(ANNOUNCE_BRANCHES_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _statuses() -> dict:
|
|
||||||
try:
|
|
||||||
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _set_status(key: str, state: str, detail: str) -> None:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
|
|
||||||
tmp = STATUS_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(STATUS_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _gutachten() -> dict:
|
|
||||||
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
|
|
||||||
try:
|
|
||||||
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _remote_branches(repo: str) -> list[str]:
|
|
||||||
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
|
|
||||||
out = []
|
|
||||||
for line in (r.stdout or "").splitlines():
|
|
||||||
name = line.strip().removeprefix("origin/")
|
|
||||||
if name and name != "HEAD" and name.startswith(PREFIXES):
|
|
||||||
out.append(name)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_branch(branch: str) -> bool:
|
|
||||||
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
|
|
||||||
items = []
|
|
||||||
for branch in _remote_branches(repo):
|
|
||||||
ref = f"origin/{branch}"
|
|
||||||
status = statuses.get(_status_key(repo, branch))
|
|
||||||
try:
|
|
||||||
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
|
|
||||||
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
|
|
||||||
continue # bereits in main enthalten → keine offene Entscheidung mehr
|
|
||||||
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
|
|
||||||
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
|
|
||||||
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
|
|
||||||
# garantierter Fehllauf („refusing to merge unrelated histories").
|
|
||||||
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
|
|
||||||
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
|
|
||||||
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
|
|
||||||
ts_val = int(cts) if cts.strip().isdigit() else None
|
|
||||||
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
|
|
||||||
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
|
|
||||||
stempel = gutachten.get(f"{repo}:{branch}")
|
|
||||||
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
|
||||||
stempel = None
|
|
||||||
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
|
|
||||||
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
|
|
||||||
files = []
|
|
||||||
for line in files_raw[:60]:
|
|
||||||
parts = line.split("\t")
|
|
||||||
if len(parts) >= 2:
|
|
||||||
files.append({"status": parts[0][:2], "path": parts[-1]})
|
|
||||||
paths = [f["path"] for f in files]
|
|
||||||
frontend_src = any(p.startswith("frontend/src") for p in paths)
|
|
||||||
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
|
|
||||||
items.append({
|
|
||||||
"repo": repo,
|
|
||||||
"branch": branch,
|
|
||||||
"kind": branch.split("/", 1)[0],
|
|
||||||
"subject": subject.strip(),
|
|
||||||
"body": body.strip()[:2000],
|
|
||||||
"author": author.strip(),
|
|
||||||
"ts": ts_val,
|
|
||||||
"empfehlung": stempel,
|
|
||||||
"ahead": ahead,
|
|
||||||
"behind": behind,
|
|
||||||
"verwaist": verwaist,
|
|
||||||
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
|
|
||||||
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
|
|
||||||
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
|
|
||||||
"leer": not verwaist and ahead > 0 and not files_raw,
|
|
||||||
"shortstat": stat,
|
|
||||||
"files": files,
|
|
||||||
"files_truncated": len(files_raw) > 60,
|
|
||||||
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
|
|
||||||
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
|
|
||||||
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
|
|
||||||
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def list_proposals() -> dict:
|
|
||||||
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
|
|
||||||
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
|
|
||||||
|
|
||||||
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
|
|
||||||
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
|
|
||||||
if not _available():
|
|
||||||
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
|
|
||||||
|
|
||||||
statuses = _statuses()
|
|
||||||
gutachten = _gutachten()
|
|
||||||
items = []
|
|
||||||
for repo in REPOS:
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
|
|
||||||
_fetch_throttled(repo)
|
|
||||||
items.extend(_repo_items(repo, statuses, gutachten))
|
|
||||||
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
|
|
||||||
|
|
||||||
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
|
|
||||||
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
|
|
||||||
aktuelle_branches = set()
|
|
||||||
for repo in REPOS:
|
|
||||||
if _repo_ok(repo):
|
|
||||||
aktuelle_branches.update(_remote_branches(repo))
|
|
||||||
|
|
||||||
# Bereits gemeldete Branches laden
|
|
||||||
gemeldet = _load_announce_branches()
|
|
||||||
|
|
||||||
# Nur wirklich NEUE Branches pingen
|
|
||||||
neue = aktuelle_branches - gemeldet
|
|
||||||
for branch in neue:
|
|
||||||
# NotifyTelegram best-effort, niemals crashen
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
subject = "[Auftragsbuch]"
|
|
||||||
text = f"Neue Karte wartet auf deinen Klick: {branch}"
|
|
||||||
announce.notify_telegram(subject, text)
|
|
||||||
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
|
|
||||||
announce.add(text, subject, "auftragsbuch", "normal")
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
|
||||||
|
|
||||||
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
|
|
||||||
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
|
|
||||||
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
|
|
||||||
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
|
|
||||||
if aktuelle_branches != gemeldet:
|
|
||||||
_save_announce_branches(aktuelle_branches)
|
|
||||||
|
|
||||||
kandidaten = list_skill_kandidaten()
|
|
||||||
open_count = sum(1 for i in items
|
|
||||||
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
|
|
||||||
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
|
|
||||||
|
|
||||||
|
|
||||||
def diff_of(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Nur auf der Box verfügbar."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
|
|
||||||
text = r.stdout or ""
|
|
||||||
truncated = len(text) > 200_000
|
|
||||||
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
|
|
||||||
|
|
||||||
|
|
||||||
def accept(branch: str, repo: str = "mc2") -> dict:
|
|
||||||
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
|
|
||||||
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
key = _status_key(repo, branch)
|
|
||||||
state = (_statuses().get(key) or {}).get("state")
|
|
||||||
if state in ("laeuft", "rollback"):
|
|
||||||
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
|
|
||||||
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
|
|
||||||
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
|
|
||||||
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
|
|
||||||
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
|
|
||||||
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
|
|
||||||
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
|
|
||||||
|
|
||||||
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
|
|
||||||
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
|
|
||||||
src = REPO / "deploy" / REPOS[repo]["runner"]
|
|
||||||
if not src.is_file():
|
|
||||||
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
|
|
||||||
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
|
|
||||||
try:
|
|
||||||
shutil.copyfile(src, runner)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
|
|
||||||
|
|
||||||
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
|
|
||||||
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
|
|
||||||
r = subprocess.run(
|
|
||||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
|
||||||
"/bin/bash", str(runner), branch],
|
|
||||||
capture_output=True, text=True, timeout=20)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
|
||||||
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
|
|
||||||
return {"ok": True, "unit": unit}
|
|
||||||
|
|
||||||
|
|
||||||
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
|
|
||||||
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
|
|
||||||
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
|
|
||||||
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
|
|
||||||
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
|
|
||||||
if not _repo_ok(repo):
|
|
||||||
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
grund = (grund or "").strip()[:400]
|
|
||||||
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
|
|
||||||
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
|
|
||||||
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
|
|
||||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
detail = "Vom Commander abgelehnt — Branch gelöscht"
|
|
||||||
if grund:
|
|
||||||
detail += f" (Grund: {grund})"
|
|
||||||
_set_status(_status_key(repo, branch), "abgelehnt", detail)
|
|
||||||
try:
|
|
||||||
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
|
|
||||||
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
|
|
||||||
except OSError:
|
|
||||||
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
wo = "im Lucy-Repo " if repo == "lucy" else ""
|
|
||||||
warum = f" Grund: {grund}" if grund else ""
|
|
||||||
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
|
|
||||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def _kandidaten_dir() -> Path:
|
|
||||||
return VAULT / "skill-kandidaten"
|
|
||||||
|
|
||||||
|
|
||||||
def list_skill_kandidaten() -> list[dict]:
|
|
||||||
d = _kandidaten_dir()
|
|
||||||
if os.name != "posix" or not d.is_dir():
|
|
||||||
return []
|
|
||||||
out = []
|
|
||||||
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
||||||
try:
|
|
||||||
text = p.read_text(encoding="utf-8", errors="replace")
|
|
||||||
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
|
|
||||||
out.append({"file": p.name, "title": first[:160],
|
|
||||||
"preview": text[:3000], "mtime": p.stat().st_mtime})
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _vault_git(args: list[str]) -> None:
|
|
||||||
try:
|
|
||||||
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _kandidat_path(fname: str) -> Path | None:
|
|
||||||
if not _KANDIDAT_RX.match(fname):
|
|
||||||
return None
|
|
||||||
p = (_kandidaten_dir() / fname).resolve()
|
|
||||||
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
|
|
||||||
return None
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def skill_accept(fname: str) -> dict:
|
|
||||||
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
|
|
||||||
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
|
|
||||||
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
|
|
||||||
p = _kandidat_path(fname)
|
|
||||||
if not p:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
|
||||||
|
|
||||||
beauftragt = _kandidaten_dir() / "beauftragt"
|
|
||||||
beauftragt.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = beauftragt / p.name
|
|
||||||
try:
|
|
||||||
content = p.read_text(encoding="utf-8", errors="replace")
|
|
||||||
p.rename(target)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
|
||||||
_vault_git(["add", "-A"])
|
|
||||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
|
|
||||||
|
|
||||||
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
|
|
||||||
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
|
|
||||||
+ content)
|
|
||||||
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
|
|
||||||
try:
|
|
||||||
order_file.write_text(order, encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
|
|
||||||
unit = f"mc2-skill-auftrag-{int(time.time())}"
|
|
||||||
r = subprocess.run(
|
|
||||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
|
||||||
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
|
|
||||||
capture_output=True, text=True, timeout=20)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
|
|
||||||
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
|
|
||||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True, "unit": unit}
|
|
||||||
|
|
||||||
|
|
||||||
def skill_reject(fname: str) -> dict:
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
|
|
||||||
p = _kandidat_path(fname)
|
|
||||||
if not p:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
|
||||||
verworfen = _kandidaten_dir() / "verworfen"
|
|
||||||
verworfen.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
p.rename(verworfen / p.name)
|
|
||||||
except OSError as exc:
|
|
||||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
|
||||||
_vault_git(["add", "-A"])
|
|
||||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
|
|
||||||
return {"ok": True}
|
|
||||||
@@ -1,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:
|
|
||||||
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
|
|
||||||
@@ -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"]}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""
|
|
||||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
|
||||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
|
||||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
|
|
||||||
|
|
||||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
|
||||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.151"
|
|
||||||
|
|
||||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
|
||||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
|
||||||
PRIMARY_MODEL = "coder"
|
|
||||||
|
|
||||||
|
|
||||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
|
||||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
|
||||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
|
||||||
"prompt_cache_key": False, "chat_completions": True,
|
|
||||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
|
||||||
|
|
||||||
|
|
||||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
|
||||||
IDE_MODELS = [
|
|
||||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
|
||||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
|
||||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _gw(host: str) -> str:
|
|
||||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
|
||||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
|
||||||
return f"http://{host}:{PORT}/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def build_snippets(host: str = DEFAULT_HOST,
|
|
||||||
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
|
|
||||||
mcp_python: str = "python") -> dict:
|
|
||||||
gw = _gw(host)
|
|
||||||
mc_url = f"http://{host}:{PORT}"
|
|
||||||
|
|
||||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
|
||||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
|
||||||
kilo = json.dumps({
|
|
||||||
"apiProvider": "openai",
|
|
||||||
"openAiBaseUrl": gw,
|
|
||||||
"openAiApiKey": "local",
|
|
||||||
"openAiModelId": PRIMARY_MODEL,
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
|
||||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
|
||||||
hermes_desktop = (
|
|
||||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
|
||||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
|
||||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
|
||||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
|
||||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
|
||||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
|
||||||
claude_code = (
|
|
||||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
|
||||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
|
||||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
|
||||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
|
||||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
|
||||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
|
||||||
f"\n"
|
|
||||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
|
||||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
|
||||||
f'export ANTHROPIC_MODEL="coder"'
|
|
||||||
)
|
|
||||||
|
|
||||||
memory_mcp = json.dumps({
|
|
||||||
"mcpServers": {
|
|
||||||
"mission-control-memory": {
|
|
||||||
"command": mcp_python,
|
|
||||||
"args": [mcp_script_path],
|
|
||||||
"env": {"MC_URL": mc_url},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"host": host,
|
|
||||||
"gateway_url": gw,
|
|
||||||
"mc_url": mc_url,
|
|
||||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
|
||||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
|
||||||
# Claude Code (starke Sessions).
|
|
||||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
|
||||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
|
||||||
"tools": {
|
|
||||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
|
||||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
|
||||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
|
||||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
|
||||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
|
||||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
|
||||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
|
||||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
|
||||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
|
||||||
},
|
|
||||||
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
|
|
||||||
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
|
|
||||||
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def check_health() -> dict:
|
|
||||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
|
||||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
|
||||||
sonst llama-swap direkt), Leitung 2 = Gedächtnis-Sidecar (Mem0),
|
|
||||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
|
||||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
|
||||||
if r.status_code == 200:
|
|
||||||
n = len(r.json().get("data", []))
|
|
||||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
|
||||||
else:
|
|
||||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
|
||||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
|
||||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
|
||||||
@@ -1,178 +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 logging
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
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
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
|
|
||||||
|
|
||||||
Beantwortet die User-Frage vom 15.07.2026: „Es gibt keinen Viewpoint, welche Skills
|
|
||||||
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
|
|
||||||
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
|
|
||||||
|
|
||||||
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
|
|
||||||
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
|
|
||||||
selbst → weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
|
|
||||||
repo → liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
|
|
||||||
bundled → liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
|
|
||||||
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
|
|
||||||
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
|
|
||||||
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SKILLS_DIR = Path.home() / ".hermes" / "skills"
|
|
||||||
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
|
|
||||||
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
|
|
||||||
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
|
|
||||||
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
|
|
||||||
USAGE_FILE = SKILLS_DIR / ".usage.json"
|
|
||||||
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
|
|
||||||
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
|
|
||||||
|
|
||||||
_HERKUNFT_LABEL = {
|
|
||||||
"selbst": "Selbst erstellt",
|
|
||||||
"repo": "Von uns gebaut",
|
|
||||||
"bundled": "Mit Hermes mitgeliefert",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _frontmatter(text: str) -> dict:
|
|
||||||
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
|
|
||||||
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
|
|
||||||
out: dict = {}
|
|
||||||
if not text.startswith("---"):
|
|
||||||
return out
|
|
||||||
for line in text.split("\n", 1)[1].split("\n"):
|
|
||||||
if line.strip() == "---":
|
|
||||||
break
|
|
||||||
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
val = m.group(2).strip().strip('"').strip("'")
|
|
||||||
if val:
|
|
||||||
out[m.group(1)] = val
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _norm(s: str) -> str:
|
|
||||||
return re.sub(r"[\s_-]+", "", (s or "").lower())
|
|
||||||
|
|
||||||
|
|
||||||
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
|
|
||||||
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
|
|
||||||
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
|
|
||||||
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
|
|
||||||
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
|
|
||||||
_AKTIV_EVERY = 600.0 # s
|
|
||||||
|
|
||||||
|
|
||||||
def _aktive_namen() -> set[str] | None:
|
|
||||||
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
|
|
||||||
if not HERMES_PY.is_file():
|
|
||||||
return None
|
|
||||||
now = time.time()
|
|
||||||
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
|
|
||||||
return _aktiv_cache["namen"]
|
|
||||||
code = ("from hermes_cli.banner import get_available_skills;import json;"
|
|
||||||
"print(json.dumps(get_available_skills()))")
|
|
||||||
try:
|
|
||||||
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
|
|
||||||
text=True, timeout=60, cwd=str(HERMES_SRC))
|
|
||||||
idx = r.stdout.find("{")
|
|
||||||
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
|
|
||||||
namen = {_norm(n) for liste in kategorien.values() for n in liste}
|
|
||||||
except Exception:
|
|
||||||
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
|
|
||||||
_aktiv_cache.update(ts=now, namen=namen)
|
|
||||||
return namen
|
|
||||||
|
|
||||||
|
|
||||||
def list_skills() -> list[dict]:
|
|
||||||
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
|
|
||||||
if not SKILLS_DIR.is_dir():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
usage_raw = {}
|
|
||||||
usage = {_norm(k): v for k, v in usage_raw.items()}
|
|
||||||
aktive = _aktive_namen()
|
|
||||||
|
|
||||||
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
|
|
||||||
try:
|
|
||||||
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
|
|
||||||
except Exception:
|
|
||||||
fm = {}
|
|
||||||
if (BUNDLED_DIR / rel).is_dir():
|
|
||||||
herkunft = "bundled"
|
|
||||||
elif (REPO_DIR / rel).is_dir():
|
|
||||||
herkunft = "repo"
|
|
||||||
else:
|
|
||||||
herkunft = "selbst"
|
|
||||||
kurz = rel.split("/")[-1]
|
|
||||||
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
|
|
||||||
aktiv = None
|
|
||||||
if aktive is not None:
|
|
||||||
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
|
|
||||||
return {
|
|
||||||
"id": rel,
|
|
||||||
"name": fm.get("name") or kurz,
|
|
||||||
"kategorie": kategorie,
|
|
||||||
"aktiv": aktiv,
|
|
||||||
"beschreibung": fm.get("description") or "",
|
|
||||||
"version": fm.get("version") or "",
|
|
||||||
"autor": fm.get("author") or "",
|
|
||||||
"herkunft": herkunft,
|
|
||||||
"herkunft_label": _HERKUNFT_LABEL[herkunft],
|
|
||||||
"genutzt": int(u.get("use_count") or 0),
|
|
||||||
"zuletzt_genutzt": u.get("last_used_at") or None,
|
|
||||||
"erstellt": u.get("created_at") or None,
|
|
||||||
"status": u.get("state") or "active",
|
|
||||||
"geaendert": md.stat().st_mtime,
|
|
||||||
}
|
|
||||||
|
|
||||||
skills: list[dict] = []
|
|
||||||
for d in sorted(SKILLS_DIR.iterdir()):
|
|
||||||
if not d.is_dir() or d.name.startswith("."):
|
|
||||||
continue # .archive (Curator) & Co. sind keine aktiven Skills
|
|
||||||
md = d / "SKILL.md"
|
|
||||||
if md.is_file():
|
|
||||||
skills.append(eintrag(md, d.name, None))
|
|
||||||
continue
|
|
||||||
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
|
|
||||||
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
|
|
||||||
for sub in sorted(d.iterdir()):
|
|
||||||
smd = sub / "SKILL.md"
|
|
||||||
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
|
|
||||||
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
|
|
||||||
# Selbst erstellte zuerst — das ist der Star der Ansicht.
|
|
||||||
order = {"selbst": 0, "repo": 1, "bundled": 2}
|
|
||||||
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
|
|
||||||
return skills
|
|
||||||
|
|
||||||
|
|
||||||
def skill_text(skill_id: str) -> str | None:
|
|
||||||
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
|
|
||||||
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
|
|
||||||
return None
|
|
||||||
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
|
|
||||||
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
|
|
||||||
if any(seg in (".", "..") for seg in skill_id.split("/")):
|
|
||||||
return None
|
|
||||||
md = SKILLS_DIR / skill_id / "SKILL.md"
|
|
||||||
if not md.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return md.read_text(encoding="utf-8", errors="replace")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bilanz() -> dict:
|
|
||||||
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
|
|
||||||
eintraege: list[dict] = []
|
|
||||||
try:
|
|
||||||
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
|
|
||||||
for branch, info in branches.items():
|
|
||||||
eintraege.append({
|
|
||||||
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
|
|
||||||
"titel": branch,
|
|
||||||
"detail": info.get("detail") or "",
|
|
||||||
"ts": float(info.get("ts") or 0),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
j = json.loads(line)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
eintraege.append({
|
|
||||||
"art": "abgelehnt",
|
|
||||||
"titel": j.get("subject") or j.get("branch") or "?",
|
|
||||||
"detail": j.get("grund") or "",
|
|
||||||
"ts": float(j.get("ts") or 0),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
eintraege.sort(key=lambda e: -e["ts"])
|
|
||||||
return {
|
|
||||||
"eintraege": eintraege,
|
|
||||||
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
|
|
||||||
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def overview() -> dict:
|
|
||||||
skills = list_skills()
|
|
||||||
b = bilanz()
|
|
||||||
return {
|
|
||||||
"available": SKILLS_DIR.is_dir(),
|
|
||||||
"skills": skills,
|
|
||||||
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
|
|
||||||
"bilanz": b,
|
|
||||||
}
|
|
||||||
@@ -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)."}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""
|
|
||||||
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
|
|
||||||
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
|
|
||||||
V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import PORT, V1_UPSTREAM
|
|
||||||
|
|
||||||
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": "gateway (eigener Prozess)" if V1_UPSTREAM else "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:
|
|
||||||
"""Erreichbarkeit des ECHTEN Denkpfads. Mit V1_UPSTREAM ist das der eigenständige
|
|
||||||
mc2-gateway-Prozess (:9010) — vorher meldete diese Funktion nur die Engine, d. h.
|
|
||||||
ein toter Gateway blieb in Health/Diensten/Cockpit unsichtbar (Review 15.07.)."""
|
|
||||||
if V1_UPSTREAM:
|
|
||||||
try:
|
|
||||||
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
|
|
||||||
return engine_reachable()
|
|
||||||
@@ -1,78 +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
|
|
||||||
import time
|
|
||||||
|
|
||||||
from services.token_stats import increment_tokens
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
|
|
||||||
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
|
|
||||||
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
|
|
||||||
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
|
|
||||||
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
|
|
||||||
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
|
|
||||||
_trunc_last: dict[str, float] = {}
|
|
||||||
_TRUNC_EVERY = 600.0 # s
|
|
||||||
|
|
||||||
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
|
|
||||||
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
|
|
||||||
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
|
|
||||||
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
|
|
||||||
_PING_MAXTOK = 16
|
|
||||||
|
|
||||||
|
|
||||||
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
|
||||||
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
|
|
||||||
return
|
|
||||||
now = time.time()
|
|
||||||
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
|
||||||
return
|
|
||||||
_trunc_last[model] = now
|
|
||||||
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
announce.add(
|
|
||||||
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
|
||||||
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
|
||||||
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
|
||||||
"[Kontext-Limit]", "gateway", "silent")
|
|
||||||
except Exception:
|
|
||||||
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
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, max_tokens: int | None = None) -> 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'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
|
|
||||||
warn_truncation(model, max_tokens)
|
|
||||||
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])
|
|
||||||
@@ -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)
|
|
||||||
@@ -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}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
|
||||||
_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:
|
|
||||||
_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:
|
|
||||||
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:
|
|
||||||
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()]
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"""
|
|
||||||
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
|
|
||||||
|
|
||||||
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
|
|
||||||
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
|
|
||||||
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
|
|
||||||
|
|
||||||
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
|
|
||||||
einmal pro Karte, mit der Frage im Wortlaut,
|
|
||||||
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
|
|
||||||
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
|
|
||||||
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
|
|
||||||
|
|
||||||
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
|
|
||||||
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
|
|
||||||
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
|
|
||||||
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
|
|
||||||
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
|
|
||||||
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
|
|
||||||
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
|
|
||||||
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
|
|
||||||
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
|
|
||||||
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
|
|
||||||
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
|
|
||||||
|
|
||||||
|
|
||||||
def _kontext_druck(task_id: str) -> int:
|
|
||||||
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
|
|
||||||
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
|
|
||||||
try:
|
|
||||||
p = KANBAN_LOGS / f"{task_id}.log"
|
|
||||||
with p.open("rb") as f:
|
|
||||||
f.seek(max(0, p.stat().st_size - 200_000))
|
|
||||||
tail = f.read().decode("utf-8", errors="replace")
|
|
||||||
return tail.count("Compacting context")
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _load_state() -> dict:
|
|
||||||
try:
|
|
||||||
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
||||||
return d if isinstance(d, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_state(state: dict) -> None:
|
|
||||||
try:
|
|
||||||
tmp = STATE_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(STATE_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _melden(subject: str, text: str) -> None:
|
|
||||||
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
|
|
||||||
from services import announce
|
|
||||||
announce.notify_telegram(subject, text)
|
|
||||||
try:
|
|
||||||
announce.add(text, subject, "ketten-digest", "silent")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _tick() -> None:
|
|
||||||
from services import ideen
|
|
||||||
|
|
||||||
data = ideen.list_queue()
|
|
||||||
if not data.get("available"):
|
|
||||||
return
|
|
||||||
items = data.get("items") or []
|
|
||||||
projekte = data.get("projekte") or []
|
|
||||||
state = _load_state()
|
|
||||||
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
|
||||||
projekt_state: dict = state.setdefault("projekte", {})
|
|
||||||
dirty = False
|
|
||||||
now = int(time.time())
|
|
||||||
|
|
||||||
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
|
|
||||||
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
|
|
||||||
for it in items:
|
|
||||||
if it.get("status") != "blocked":
|
|
||||||
continue
|
|
||||||
frage = (it.get("frage") or "").strip()
|
|
||||||
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
|
|
||||||
if key in gemeldete_fragen:
|
|
||||||
continue
|
|
||||||
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}“\n"
|
|
||||||
+ (f"Frage: {frage[:500]}\n" if frage else "")
|
|
||||||
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
|
|
||||||
_melden("[Werkstatt]", text)
|
|
||||||
gemeldete_fragen[key] = now
|
|
||||||
dirty = True
|
|
||||||
|
|
||||||
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
|
|
||||||
gemeldete_haenger: dict = state.setdefault("haenger", {})
|
|
||||||
for it in items:
|
|
||||||
if it.get("status") != "running":
|
|
||||||
continue
|
|
||||||
puls = it.get("puls_alter")
|
|
||||||
if puls is None or puls < HANG_SEKUNDEN:
|
|
||||||
continue
|
|
||||||
key = f"{it['id']}:{it.get('gestartet') or 0}"
|
|
||||||
if key in gemeldete_haenger:
|
|
||||||
continue
|
|
||||||
minuten = int(puls / 60)
|
|
||||||
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
|
|
||||||
f"seit {minuten} min kein Lebenszeichen vom Worker.")
|
|
||||||
druck = _kontext_druck(it["id"])
|
|
||||||
if druck >= 2:
|
|
||||||
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
|
|
||||||
"die Aufgabe ist womöglich zu groß geschnitten.")
|
|
||||||
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
|
|
||||||
_melden("[Werkstatt]", text)
|
|
||||||
gemeldete_haenger[key] = now
|
|
||||||
dirty = True
|
|
||||||
if len(gemeldete_haenger) > 200:
|
|
||||||
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
|
|
||||||
gemeldete_haenger.pop(k, None)
|
|
||||||
dirty = True
|
|
||||||
|
|
||||||
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
|
|
||||||
if len(gemeldete_fragen) > 200:
|
|
||||||
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
|
|
||||||
gemeldete_fragen.pop(k, None)
|
|
||||||
dirty = True
|
|
||||||
|
|
||||||
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
|
|
||||||
for p in projekte:
|
|
||||||
ps = projekt_state.setdefault(p["key"], {})
|
|
||||||
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
|
|
||||||
|
|
||||||
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
|
|
||||||
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
|
|
||||||
"Ergebnisse liegen im Auftragsbuch.")
|
|
||||||
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
|
|
||||||
dirty = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
aktiv = p.get("laeuft", 0) > 0
|
|
||||||
if not aktiv:
|
|
||||||
continue
|
|
||||||
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
|
|
||||||
continue
|
|
||||||
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
|
|
||||||
naechste = [i for i in items if i.get("projekt") == p["key"]
|
|
||||||
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
|
|
||||||
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
|
|
||||||
for l in laufende[:2]:
|
|
||||||
note = f" — {l['notiz']}" if l.get("notiz") else ""
|
|
||||||
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
|
|
||||||
if naechste:
|
|
||||||
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
|
|
||||||
haengt = p.get("haengt", 0)
|
|
||||||
if haengt:
|
|
||||||
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
|
|
||||||
_melden("[Werkstatt]", "\n".join(zeilen))
|
|
||||||
ps.update(letzter_puls=now, letzter_stand=fertig)
|
|
||||||
dirty = True
|
|
||||||
|
|
||||||
if dirty:
|
|
||||||
_save_state(state)
|
|
||||||
|
|
||||||
|
|
||||||
async def digest_loop() -> None:
|
|
||||||
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
|
|
||||||
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_tick)
|
|
||||||
except Exception:
|
|
||||||
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
|
|
||||||
await asyncio.sleep(INTERVAL)
|
|
||||||
@@ -1,612 +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 config import (
|
|
||||||
CMD_TEMPLATE,
|
|
||||||
CONFIG_PATH,
|
|
||||||
DEFAULT_TTL,
|
|
||||||
DRAFTS_DIR,
|
|
||||||
LLAMA_SWAP_URL,
|
|
||||||
SPEC_DRAFT_MODEL_PATH,
|
|
||||||
SPEC_DRAFT_N_MAX,
|
|
||||||
SPEC_TYPE,
|
|
||||||
)
|
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
||||||
|
|
||||||
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.IGNORECASE):
|
|
||||||
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
|
||||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
|
||||||
if not name:
|
|
||||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
|
||||||
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.IGNORECASE)
|
|
||||||
return name or "modell"
|
|
||||||
|
|
||||||
|
|
||||||
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
|
|
||||||
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
|
|
||||||
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
|
|
||||||
PROTECTED_ALIASES = {"hermes", "embed"}
|
|
||||||
|
|
||||||
|
|
||||||
def protected_alias_of(model_id: str) -> str | None:
|
|
||||||
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
|
|
||||||
spec = (read_config().get("models") or {}).get(model_id)
|
|
||||||
if isinstance(spec, dict):
|
|
||||||
for a in spec.get("aliases") or []:
|
|
||||||
if str(a).lower() in PROTECTED_ALIASES:
|
|
||||||
return str(a).lower()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
|
||||||
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
|
|
||||||
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
|
|
||||||
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
|
|
||||||
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):
|
|
||||||
keep = [a for a in (spec.get("aliases") or [])
|
|
||||||
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
|
|
||||||
new = keep + ([role] if role and role != model_id.lower() else [])
|
|
||||||
if new:
|
|
||||||
spec["aliases"] = new
|
|
||||||
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:
|
|
||||||
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, set_alias: bool = True) -> 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).
|
|
||||||
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
|
|
||||||
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
|
|
||||||
minutenlang auf eine Datei, die noch gar nicht existiert)."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
|
|
||||||
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
|
|
||||||
old_aliases = (cfg.get("models") or {}).get(model_id, {})
|
|
||||||
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
|
|
||||||
entry: dict = {
|
|
||||||
"cmd": LiteralScalarString(cmd + "\n"),
|
|
||||||
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
|
||||||
}
|
|
||||||
if old_aliases:
|
|
||||||
entry["aliases"] = old_aliases
|
|
||||||
cfg.setdefault("models", {})[model_id] = entry
|
|
||||||
if set_alias:
|
|
||||||
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 []
|
|
||||||
@@ -1,801 +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", "mc2-gateway", "mc2-steward", "box-console",
|
|
||||||
"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.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
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.removesuffix("-gguf")
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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)
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
|
||||||
|
|
||||||
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
|
|
||||||
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
|
|
||||||
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
|
||||||
|
|
||||||
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
|
|
||||||
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
|
|
||||||
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
|
|
||||||
|
|
||||||
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import MEM0_SERVICE_URL
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CATEGORIES = ("identity", "knowledge", "rules", "events")
|
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
|
||||||
|
|
||||||
|
|
||||||
def _get(path: str, **params) -> list | dict:
|
|
||||||
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _post(path: str, data: dict) -> dict:
|
|
||||||
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _put(path: str, data: dict) -> dict:
|
|
||||||
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _delete(path: str) -> dict:
|
|
||||||
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def list_memories(q: str = "", category: str = "") -> list[dict]:
|
|
||||||
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
|
|
||||||
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
|
|
||||||
|
|
||||||
|
|
||||||
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
|
|
||||||
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
|
|
||||||
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
|
|
||||||
|
|
||||||
|
|
||||||
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
|
|
||||||
try:
|
|
||||||
return _put(f"/memory/{mid}", {"content": content, "category": category})
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code == 404:
|
|
||||||
return None
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def delete_memory(mid: str) -> bool:
|
|
||||||
try:
|
|
||||||
_delete(f"/memory/{mid}")
|
|
||||||
return True
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code == 404:
|
|
||||||
return False
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def learn(text: str | None = None, messages: list[dict] | None = None,
|
|
||||||
source: str = "auto", category: str = "stable") -> dict:
|
|
||||||
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
|
|
||||||
return _post("/learn", {"text": text, "messages": messages,
|
|
||||||
"source": source, "category": category})
|
|
||||||
|
|
||||||
|
|
||||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
|
||||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
|
||||||
return _get("/graph", min_score=min_score, top_k=top_k)
|
|
||||||
|
|
||||||
|
|
||||||
def export_text() -> dict:
|
|
||||||
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
|
||||||
lines = ["# Mission Control — Gedächtnis\n"]
|
|
||||||
current = ""
|
|
||||||
for r in rows:
|
|
||||||
if r.get("category") != current:
|
|
||||||
current = r.get("category", "")
|
|
||||||
lines.append(f"\n## {current}\n")
|
|
||||||
src = r.get("source", "")
|
|
||||||
when = (r.get("updated_at") or "")[:10]
|
|
||||||
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
|
|
||||||
return {"text": "\n".join(lines), "count": len(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
|
|
||||||
def _norm(s: str) -> str:
|
|
||||||
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
|
||||||
return re.sub(r"\s+", " ", s).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
|
||||||
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
|
|
||||||
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
|
|
||||||
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
|
|
||||||
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
|
|
||||||
used: set[str] = set()
|
|
||||||
groups: list[dict] = []
|
|
||||||
for i, a in enumerate(rows):
|
|
||||||
if a["id"] in used:
|
|
||||||
continue
|
|
||||||
na = _norm(a.get("content", ""))
|
|
||||||
if not na:
|
|
||||||
continue
|
|
||||||
dups = []
|
|
||||||
for b in rows[i + 1:]:
|
|
||||||
if b["id"] in used or b.get("category") != a.get("category"):
|
|
||||||
continue
|
|
||||||
nb = _norm(b.get("content", ""))
|
|
||||||
if not nb:
|
|
||||||
continue
|
|
||||||
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
|
||||||
dups.append(b); used.add(b["id"])
|
|
||||||
if dups:
|
|
||||||
used.add(a["id"])
|
|
||||||
groups.append({
|
|
||||||
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
|
|
||||||
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
|
|
||||||
})
|
|
||||||
dup_count = sum(len(g["remove"]) for g in groups)
|
|
||||||
removed = 0
|
|
||||||
if apply:
|
|
||||||
for g in groups:
|
|
||||||
for d in g["remove"]:
|
|
||||||
if delete_memory(d["id"]):
|
|
||||||
removed += 1
|
|
||||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
|
|
||||||
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
|
|
||||||
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
|
|
||||||
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
|
|
||||||
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
|
|
||||||
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
|
|
||||||
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
|
|
||||||
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
|
|
||||||
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
|
|
||||||
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
|
|
||||||
|
|
||||||
|
|
||||||
async def auto_dedupe_loop() -> None:
|
|
||||||
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
|
|
||||||
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
|
|
||||||
if res.get("removed"):
|
|
||||||
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
|
|
||||||
except Exception:
|
|
||||||
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
|
|
||||||
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
|
|
||||||
|
|
||||||
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
|
|
||||||
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
|
|
||||||
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
|
|
||||||
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
|
|
||||||
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
|
|
||||||
Laden fliegt alles raus, was älter als 24 h ist.
|
|
||||||
|
|
||||||
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
|
|
||||||
Lücke pro Deploy sind egal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SAMPLE_S = 10 # Abtast-Takt
|
|
||||||
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
|
|
||||||
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
|
|
||||||
FLUSH_S = 300 # höchstens alle 5 min auf Platte
|
|
||||||
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
|
|
||||||
|
|
||||||
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
|
|
||||||
|
|
||||||
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
|
|
||||||
_points: deque = deque(maxlen=MAXLEN)
|
|
||||||
_loaded = False
|
|
||||||
_last_flush = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _load() -> None:
|
|
||||||
global _loaded
|
|
||||||
if _loaded:
|
|
||||||
return
|
|
||||||
_loaded = True
|
|
||||||
try:
|
|
||||||
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
|
||||||
cutoff = time.time() - RETENTION_S
|
|
||||||
for p in raw if isinstance(raw, list) else []:
|
|
||||||
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
|
|
||||||
_points.append(p)
|
|
||||||
if _points:
|
|
||||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _flush() -> None:
|
|
||||||
global _last_flush
|
|
||||||
_last_flush = time.time()
|
|
||||||
try:
|
|
||||||
tmp = HISTORY_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
|
|
||||||
tmp.replace(HISTORY_PATH)
|
|
||||||
except OSError:
|
|
||||||
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _sample() -> None:
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
from services.system import _gpu_sysfs
|
|
||||||
from services.token_stats import get_stats
|
|
||||||
|
|
||||||
vm = psutil.virtual_memory()
|
|
||||||
disk = None
|
|
||||||
try:
|
|
||||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
|
||||||
disk = du.percent
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
gpu = None
|
|
||||||
try:
|
|
||||||
g = _gpu_sysfs()
|
|
||||||
gpu = g.get("busy_percent") if g else None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
tp = tc = None
|
|
||||||
try:
|
|
||||||
ts = get_stats()
|
|
||||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
|
||||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
|
||||||
|
|
||||||
|
|
||||||
async def sampler_loop() -> None:
|
|
||||||
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
|
|
||||||
_load()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_sample)
|
|
||||||
except Exception:
|
|
||||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
|
||||||
if time.time() - _last_flush >= FLUSH_S:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_flush)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(SAMPLE_S)
|
|
||||||
|
|
||||||
|
|
||||||
def history(minutes: int = 60) -> dict:
|
|
||||||
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
|
|
||||||
_load()
|
|
||||||
minutes = max(1, min(int(minutes), RETENTION_S // 60))
|
|
||||||
cutoff = time.time() - minutes * 60
|
|
||||||
pts = [p for p in _points if p[0] >= cutoff]
|
|
||||||
stride = max(1, len(pts) // MAX_RETURN_POINTS)
|
|
||||||
pts = pts[::stride]
|
|
||||||
return {
|
|
||||||
"sample_s": SAMPLE_S * stride,
|
|
||||||
"points": [
|
|
||||||
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
|
|
||||||
for p in pts
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -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()},
|
|
||||||
}
|
|
||||||
@@ -1,171 +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)
|
|
||||||
@@ -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) # 7–24B 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}
|
|
||||||
@@ -1,122 +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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
|
|
||||||
VISION_CAPABLE = {"vision", "scout"}
|
|
||||||
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
|
|
||||||
|
|
||||||
|
|
||||||
def has_image(body: dict) -> bool:
|
|
||||||
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
|
|
||||||
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
|
|
||||||
for m in body.get("messages") or []:
|
|
||||||
if not isinstance(m, dict):
|
|
||||||
continue
|
|
||||||
content = m.get("content")
|
|
||||||
if isinstance(content, list):
|
|
||||||
for part in content:
|
|
||||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _text_of(body: dict) -> str:
|
|
||||||
msgs = body.get("messages") or []
|
|
||||||
# Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing
|
|
||||||
# zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen).
|
|
||||||
out = []
|
|
||||||
for m in msgs:
|
|
||||||
if not isinstance(m, dict):
|
|
||||||
continue
|
|
||||||
c = m.get("content")
|
|
||||||
if isinstance(c, str):
|
|
||||||
out.append(c)
|
|
||||||
elif isinstance(c, list):
|
|
||||||
for part in c:
|
|
||||||
if isinstance(part, dict) and part.get("type") == "text":
|
|
||||||
out.append(str(part.get("text") or ""))
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
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 256K–1M 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)
|
|
||||||
@@ -1,131 +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", ""),
|
|
||||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
|
|
||||||
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
|
|
||||||
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
|
|
||||||
"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": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; 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 not in ("coder_lite", "vision") 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}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""
|
|
||||||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
|
||||||
|
|
||||||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
|
|
||||||
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
|
|
||||||
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
|
|
||||||
|
|
||||||
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
|
|
||||||
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
|
|
||||||
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
|
|
||||||
|
|
||||||
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
|
|
||||||
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import psutil
|
|
||||||
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
|
||||||
|
|
||||||
from services import announce, llamaswap
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
|
||||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
|
||||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
|
||||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
|
||||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
|
||||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
|
||||||
|
|
||||||
|
|
||||||
def _reach(url: str, path: str = "/health") -> bool:
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5.0) as c:
|
|
||||||
return c.get(f"{url}{path}").status_code < 500
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _check_engine() -> bool:
|
|
||||||
return llamaswap.engine_reachable()
|
|
||||||
|
|
||||||
|
|
||||||
def _check_brain() -> bool:
|
|
||||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
|
||||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
|
||||||
st = llamaswap.brain_status()
|
|
||||||
if st.get("ready"):
|
|
||||||
return True
|
|
||||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
|
||||||
|
|
||||||
|
|
||||||
def _check_disk() -> bool:
|
|
||||||
try:
|
|
||||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
|
||||||
except Exception:
|
|
||||||
return True # kein Messwert ≠ Alarm
|
|
||||||
|
|
||||||
|
|
||||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
|
||||||
CHECKS: dict[str, tuple] = {
|
|
||||||
"engine": (_check_engine,
|
|
||||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
|
||||||
"Die Modell-Engine ist wieder da."),
|
|
||||||
"brain": (_check_brain,
|
|
||||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
|
||||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
|
||||||
"hermes": (lambda: _reach(HERMES_API_URL),
|
|
||||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
|
||||||
"Der Agent-Dienst läuft wieder."),
|
|
||||||
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
|
|
||||||
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
|
|
||||||
"Mein Gedächtnis ist wieder online."),
|
|
||||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
|
||||||
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
|
|
||||||
"Der Hör-Dienst läuft wieder."),
|
|
||||||
"disk": (_check_disk,
|
|
||||||
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
|
|
||||||
"Die Platte hat wieder genug Luft."),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
|
|
||||||
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
|
|
||||||
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
|
|
||||||
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
|
||||||
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
|
||||||
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
|
||||||
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
|
|
||||||
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
|
|
||||||
"Das Steuerpult ist wieder da.")
|
|
||||||
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
|
|
||||||
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
|
|
||||||
"Der Modell-Gateway läuft wieder.")
|
|
||||||
|
|
||||||
|
|
||||||
class _Watch:
|
|
||||||
__slots__ = ("alert_ts", "alerted", "fails")
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.fails = 0 # Fehl-Ticks in Folge
|
|
||||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
|
||||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
|
||||||
|
|
||||||
|
|
||||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_telegram(subject: str, text: str) -> None:
|
|
||||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
|
||||||
legt der Wächter selbst ab."""
|
|
||||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
|
||||||
|
|
||||||
|
|
||||||
def _tick() -> None:
|
|
||||||
now = time.time()
|
|
||||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
|
||||||
w = _watches[name]
|
|
||||||
try:
|
|
||||||
ok = bool(check())
|
|
||||||
except Exception:
|
|
||||||
ok = False
|
|
||||||
if ok:
|
|
||||||
w.fails = 0
|
|
||||||
if w.alerted:
|
|
||||||
w.alerted = False
|
|
||||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
|
||||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
|
||||||
continue
|
|
||||||
w.fails += 1
|
|
||||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
|
||||||
if due:
|
|
||||||
prefix = "" if not w.alerted else "Immer noch: "
|
|
||||||
w.alerted = True
|
|
||||||
w.alert_ts = now
|
|
||||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
|
||||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
|
||||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
|
||||||
|
|
||||||
|
|
||||||
async def sentry_loop() -> None:
|
|
||||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
|
||||||
await asyncio.sleep(START_DELAY)
|
|
||||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_tick)
|
|
||||||
except Exception:
|
|
||||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
|
||||||
await asyncio.sleep(INTERVAL)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,201 +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(),
|
|
||||||
}
|
|
||||||
@@ -1,123 +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 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
|
|
||||||
# mtime der Datei beim letzten eigenen Laden/Schreiben — seit dem Gateway-Auszug
|
|
||||||
# (UMBAU v3 P1) schreibt der mc2-gateway-Prozess die Datei, das Steuerpult liest nur
|
|
||||||
# noch: ohne mtime-Vergleich zeigte es ab Prozessstart eingefrorene Zahlen.
|
|
||||||
_disk_mtime: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _stat_mtime() -> float | None:
|
|
||||||
try:
|
|
||||||
return STATS_FILE.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_from_disk() -> dict:
|
|
||||||
global _disk_mtime
|
|
||||||
_disk_mtime = _stat_mtime()
|
|
||||||
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:
|
|
||||||
global _disk_mtime
|
|
||||||
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)
|
|
||||||
_disk_mtime = _stat_mtime() # eigener Write ist kein Fremd-Update
|
|
||||||
except OSError:
|
|
||||||
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_stats() -> dict:
|
|
||||||
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.
|
|
||||||
|
|
||||||
Multi-Prozess-fähig: Hat ein ANDERER Prozess (mc2-gateway) die Datei inzwischen
|
|
||||||
geschrieben und liegen hier keine ungeflushten Inkremente, wird frisch geladen.
|
|
||||||
Im Gateway-Prozess selbst ist nach jedem Flush Datei == Speicher → der
|
|
||||||
mtime-Vergleich lädt dort nie unnötig nach."""
|
|
||||||
global _stats
|
|
||||||
with _lock:
|
|
||||||
if _stats is not None and not _dirty:
|
|
||||||
mtime = _stat_mtime()
|
|
||||||
if mtime is not None and mtime != _disk_mtime:
|
|
||||||
_stats = _load_from_disk()
|
|
||||||
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)
|
|
||||||
@@ -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]
|
|
||||||
@@ -1,140 +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. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
|
|
||||||
|
|
||||||
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
|
|
||||||
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
|
|
||||||
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
|
|
||||||
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
|
|
||||||
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
|
|
||||||
nur die erste ausdrücken.
|
|
||||||
"""
|
|
||||||
explizit = os.environ.get("MC_WARMSET", "").split()
|
|
||||||
if explizit:
|
|
||||||
return set(explizit)
|
|
||||||
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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""
|
|
||||||
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
|
||||||
|
|
||||||
Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
|
|
||||||
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
|
|
||||||
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
|
|
||||||
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
|
|
||||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
|
||||||
|
|
||||||
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
|
||||||
In-Process-Nudge aus llamaswap.write_config)
|
|
||||||
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
|
|
||||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
|
||||||
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
|
|
||||||
|
|
||||||
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
|
|
||||||
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
|
|
||||||
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
|
|
||||||
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
|
|
||||||
|
|
||||||
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
|
|
||||||
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
|
|
||||||
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from config import CONFIG_PATH
|
|
||||||
from services import memory as memory_svc
|
|
||||||
from services import sentry, warmer
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
|
||||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
||||||
)
|
|
||||||
log = logging.getLogger("steward")
|
|
||||||
|
|
||||||
# Wie oft nach einer llama-swap-Config-Änderung geschaut wird (mtime-Watch). Ersetzt
|
|
||||||
# warmer.nudge() aus dem MC2-Prozess: ein -watch-config-Reload verwirft das Warm-Set,
|
|
||||||
# wir sehen die Änderung binnen Sekunden statt erst beim nächsten 90s-Tick.
|
|
||||||
CONFIG_WATCH_S = int(os.environ.get("MC_STEWARD_CONFIG_WATCH_S", "5"))
|
|
||||||
|
|
||||||
|
|
||||||
async def config_watch() -> None:
|
|
||||||
last: float | None = None
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
mtime = CONFIG_PATH.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
mtime = None
|
|
||||||
if last is not None and mtime is not None and mtime != last:
|
|
||||||
log.info("steward: %s geändert → Warm-Set-Nudge", CONFIG_PATH)
|
|
||||||
warmer.nudge()
|
|
||||||
last = mtime
|
|
||||||
await asyncio.sleep(CONFIG_WATCH_S)
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
tasks: list[asyncio.Task] = []
|
|
||||||
if warmer.ENABLED:
|
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
|
||||||
tasks.append(asyncio.create_task(config_watch()))
|
|
||||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
|
||||||
warmer.INTERVAL, CONFIG_WATCH_S)
|
|
||||||
if sentry.ENABLED:
|
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
|
||||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
|
||||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
|
||||||
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
|
||||||
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
|
|
||||||
if not tasks:
|
|
||||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
|
||||||
return
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
@@ -1,211 +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",
|
|
||||||
# Der Executor läuft unter pythonw (kein eigenes Konsolenfenster) — ohne dieses
|
|
||||||
# Flag bekäme JEDE gespawnte powershell ein sichtbares Fenster (beim 10-s-Polling
|
|
||||||
# der Lucy-Annahme blitzte das im Sekundentakt auf dem Desktop, 12.07.).
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
||||||
)
|
|
||||||
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://", "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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
name: projekt-uebernehmen
|
|
||||||
description: Ein von der AI-Box vorbereitetes Projekt in Zed uebernehmen und starten - Konzept und Roadmap einlesen, die offenen Punkte mit dem Commander klaeren, dann Etappe 1 planen und bauen. Nutze diesen Skill beim ERSTEN Chat in einem Projekt-Ordner, der KONZEPT.md und ROADMAP.md enthaelt, aber noch keinen (oder kaum) eigenen Code - also bei jedem frisch vorbereiteten Repo aus dem Auftragsbuch.
|
|
||||||
metadata:
|
|
||||||
quelle: mission-control-2/client/ide-skills
|
|
||||||
---
|
|
||||||
|
|
||||||
# Vorbereitetes Projekt übernehmen
|
|
||||||
|
|
||||||
Die Box hat gedacht, du baust. Das Konzept in diesem Ordner ist **bereits gehärtet** — mehrere
|
|
||||||
Fach-Rollen und ein Advocatus Diaboli haben es auf der Box durchgearbeitet. Deine Aufgabe ist
|
|
||||||
nicht, es neu zu denken, sondern es umzusetzen.
|
|
||||||
|
|
||||||
**Zwei Modelle, ein Ablauf:** Stufe 1 und 2 (einlesen, nachfragen, planen) gehören in den
|
|
||||||
**Planer**, Stufe 3 (bauen) in den **Coder**. Du kannst nicht selbst umschalten — wenn der Plan
|
|
||||||
freigegeben ist, bitte den Commander ausdrücklich darum, oben im Modellwähler auf Coder zu
|
|
||||||
wechseln. Läufst du schon im Coder, ist das auch in Ordnung: dann gilt trotzdem erst lesen,
|
|
||||||
fragen, Plan zeigen — und **keine Datei vor seinem „los"**.
|
|
||||||
|
|
||||||
Drei Stufen, in dieser Reihenfolge. **Vor Stufe 3 wird keine Datei angefasst.**
|
|
||||||
|
|
||||||
## 1. Einlesen — still, ohne Rückfragen
|
|
||||||
|
|
||||||
Lies, was da ist: `KONZEPT.md`, `ROADMAP.md`, `AGENTS.md`, `SAVEPOINT.md`, `README.md`.
|
|
||||||
Verschaff dir den Ist-Zustand: `git log --oneline -5` und den Dateibaum.
|
|
||||||
|
|
||||||
Zwei Abbruch-Fälle — prüfe sie ZUERST:
|
|
||||||
|
|
||||||
- **Es gibt schon Code und Historie.** Dann ist das keine Übernahme, sondern Fortsetzung:
|
|
||||||
arbeite nach `SAVEPOINT.md` weiter und sag in zwei Sätzen, wo ihr steht. Dieser Skill ist
|
|
||||||
hier fertig.
|
|
||||||
- **Nur `KONZEPT.md` + `README.md`, keine `ROADMAP.md`.** Das ist ein *geprüfte-Idee*-Repo, kein
|
|
||||||
startklares Projekt — die Box legt bei „Idee prüfen" bewusst kein Gerüst an. Sag dem Commander:
|
|
||||||
*„Das ist bisher nur ein durchdachtes Konzept, noch kein Projekt. Im Auftragsbuch gibt es auf
|
|
||||||
der Karte den Knopf ‚Gefällt mir — Projekt daraus machen'; danach hat das Repo eine Roadmap und
|
|
||||||
ich kann loslegen."* Dann **STOPP**, nichts bauen.
|
|
||||||
|
|
||||||
Sonst: fasse in **höchstens 8 Zeilen** zusammen — was gebaut wird, für wen, welche Technik,
|
|
||||||
und was laut Roadmap **Etappe 1** ist. Alltagssprache, keine Fachbegriffe ohne Übersetzung.
|
|
||||||
|
|
||||||
## 2. Fragen stellen — der eigentliche Wert dieses Schritts
|
|
||||||
|
|
||||||
Stell **3 bis 6 Fragen**. Nicht mehr, und nur solche, die den Start wirklich blockieren.
|
|
||||||
|
|
||||||
- **Nichts fragen, was in den Dokumenten steht.** Wer das Konzept nicht gelesen hat, merkt man
|
|
||||||
genau hier.
|
|
||||||
- Jede Frage: eine Zeile, Alltagssprache, **mit deiner Empfehlung** („ich würde X nehmen, weil …").
|
|
||||||
Der Commander ist kein Entwickler — frag nach Zielen, Vorlieben und Gegebenheiten, nie nach
|
|
||||||
Bau-Prinzipien oder Architektur-Geschmack. Das ist dein Job.
|
|
||||||
- Die üblichen echten Lücken: Wo läuft es am Ende? Welche Zugänge/Geräte/Pfade brauchst du von
|
|
||||||
ihm? Was ist in Etappe 1 ausdrücklich **nicht** drin? Woran merkt ihr beide, dass Etappe 1
|
|
||||||
fertig ist?
|
|
||||||
- Der Abschnitt „offene Punkte" / „Risiken" im Konzept ist deine Fundgrube — genau dafür steht er da.
|
|
||||||
|
|
||||||
Dann **warten**. Keine Annahmen, kein Vorpreschen, kein „ich fange schon mal an".
|
|
||||||
|
|
||||||
## 3. Plan → „los" → bauen
|
|
||||||
|
|
||||||
1. **Etappe-1-Plan** vorlegen: Ziel · Vorgehen in kleinen Schritten · Technik und warum ·
|
|
||||||
Fertig-Kriterien · Risiken. Etappe 1 muss **für sich lauffähig** sein — eine halbe Baustelle ist
|
|
||||||
kein Meilenstein.
|
|
||||||
2. **STOPP**, auf sein „los" warten. Danach der Satz, den er hören muss: *„Bitte oben im
|
|
||||||
Modellwähler auf **Coder** umschalten, dann lege ich los."* Umschalten kannst du nicht selbst.
|
|
||||||
3. Bauen: kleine Schritte, ein Sicherungspunkt (Commit mit klarem Namen) je Meilenstein und vor
|
|
||||||
riskanten Änderungen. Am Ende **live testen** — beweisen statt behaupten.
|
|
||||||
4. Nachführen: `SAVEPOINT.md` (Stand, nächster Schritt, offene Fragen) und die erledigte Etappe in
|
|
||||||
`ROADMAP.md` abhaken. Das ist das Gedächtnis für den nächsten Chat.
|
|
||||||
|
|
||||||
## Merksätze
|
|
||||||
|
|
||||||
- **Du denkst das Konzept nicht neu.** Findest du einen echten Widerspruch oder eine Lücke:
|
|
||||||
benenne sie und frag — bau nicht still etwas anderes.
|
|
||||||
- **Nichts bauen, was nicht in Etappe 1 steht.** Ideen für später gehören in die Roadmap, nicht
|
|
||||||
in den Code.
|
|
||||||
- Geheimnisse (Schlüssel, Passwörter, Tokens) nie in den Code — `.env` plus `.gitignore`.
|
|
||||||
- Klemmt dieselbe Sache zweimal: anhalten, sichern, erklären, fragen.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Security Memo: Gitea Registrierung deaktivieren
|
|
||||||
|
|
||||||
## Status
|
|
||||||
**Aktuell:** Registrierung ist OFFEN (Standard-Gitea-Setup)
|
|
||||||
**Ziel:** `DISABLE_REGISTRATION = true` in `[security]` Abschnitt der app.ini setzen
|
|
||||||
|
|
||||||
## Risiko
|
|
||||||
- **DDNS-Endpunkt** (`git.tobisniceshomelab.ddnsfree.com`) ist öffentlich erreichbar
|
|
||||||
- **Version im Footer** (1.26.2) identifizierbar — potenziell anfällige Version
|
|
||||||
- Jeder kann sich ohne Genehmigung registrieren → potenzielle Angreifer/Spammer
|
|
||||||
|
|
||||||
## Empfohlene Maßnahme
|
|
||||||
1. Auf dem Gitea-Server (Proxmox LXC `192.168.178.153`) in `/etc/gitea/app.ini` im Abschnitt `[security]` die Zeile `DISABLE_REGISTRATION = true` hinzufügen
|
|
||||||
2. Gitea neu starten: `sudo systemctl restart gitea` (oder wie auch der Service heißt)
|
|
||||||
|
|
||||||
## Commander-Approval notwendig
|
|
||||||
- ⚠️ Diese Änderung ist **nur im laufenden Betrieb** auf dem Gitea-Server vorzunehmen
|
|
||||||
- **Branch `wartung/gitea-disable-registration`** enthält eine Beispielkonfiguration und Dokumentation
|
|
||||||
- **Bitte bestätige mit "JA", dass du die Änderung freigibst**, damit sie auf dem Server umgesetzt werden kann
|
|
||||||
|
|
||||||
## Hinweis
|
|
||||||
Ohne Commander-Ja: keine Änderung an der Gitea-Instanz vornehmen. Die Sicherheitslücke bleibt bestehen, bis die Freigabe erfolgt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Erstellt am: 2026-07-17*
|
|
||||||
*Branch: `wartung/gitea-disable-registration`*
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# pre_llm_call-Hook (Faden 5, 13.07.2026): verdrahtet Box-Selbstwissen MECHANISCH in
|
|
||||||
# JEDEN Kanban-Worker — egal welches Profil (werkstatt, betrieb, default-als-Worker).
|
|
||||||
# Der Kontext wird ephemer pro Turn an die User-Message angehaengt (agent/turn_context.py),
|
|
||||||
# NUR wenn die Session in einem Kanban-Task-Workspace laeuft. Lucys interaktiver/Voice-Chat
|
|
||||||
# laeuft NICHT dort → sofortiger No-op, kein Persona- oder Latenz-Eingriff. Loest
|
|
||||||
# Fallstrick #1 (Ops/Mess/Verify-Worker flailen, weil sie llama-swap :8080 nicht kennen).
|
|
||||||
# Ausgabe {"context":"..."} = anhaengen; {} = durchlassen (No-op).
|
|
||||||
set -u
|
|
||||||
payload="$(cat -)"
|
|
||||||
|
|
||||||
# Scope-Gate: NUR echte Kanban-Worker — erkannt an der cwd im Task-Workspace.
|
|
||||||
# ACHTUNG (Bug-Fund Faden 8, 13.07.): Worker tragen als task_id den SESSION-Zeitstempel
|
|
||||||
# (z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check feuerte also
|
|
||||||
# NIE fuer Worker. Der zuverlaessige Signal ist die cwd unter kanban/workspaces/; Lucy/
|
|
||||||
# Voice/CLI laufen nie dort.
|
|
||||||
cwd=$(printf %s "$payload" | jq -r '.cwd // ""' 2>/dev/null || echo "")
|
|
||||||
case "$cwd" in
|
|
||||||
"$HOME/.hermes/kanban/workspaces/"*) : ;; # Kanban-Worker → Orientierung anhaengen
|
|
||||||
*) printf '{}'; exit 0 ;; # Lucy / Voice / CLI / interaktiv → No-op
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Zeiger auf den vollen, naechtlich generierten Selbst-Steckbrief (nur wenn er existiert).
|
|
||||||
STECKBRIEF="$HOME/.hermes/state/selbst-steckbrief.md"
|
|
||||||
hint=""
|
|
||||||
[ -f "$STECKBRIEF" ] && hint=" Dein vollstaendiger Selbst-Steckbrief (aktuelle Versionen/Dienste/Modelle/Crons/offene Karten) liegt in $STECKBRIEF — dort nachlesen statt raten."
|
|
||||||
|
|
||||||
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
|
|
||||||
ctx="$(cat <<EOF
|
|
||||||
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
|
|
||||||
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
|
|
||||||
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
|
|
||||||
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
|
|
||||||
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
|
|
||||||
FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s).
|
|
||||||
WO WAS LAEUFT (20.07. — zwei getrennte Maschinen, NICHT verwechseln): Du laeufst auf der AI-Box (192.168.178.151) — das ist die WERKBANK (Agent/Lucy/MC2/Modelle), NICHT das Ziel-System fuer Projekt-Deployments. Der Proxmox-Host 'pve' (192.168.178.108) ist ein SEPARATER Rechner; dort laufen die LXC-Container. Von hier per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Welche Container es gerade gibt, schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht raten, nicht auswendig annehmen. Soll etwas dauerhaft laufen, gehoert es in einen LXC auf pve, nicht auf die Box.
|
|
||||||
REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<kurze Beschreibung>" (immer privat, letzte Ausgabezeile = CLONE <url>), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern.
|
|
||||||
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
|
|
||||||
jq --null-input --arg c "$ctx" '{context:$c}'
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""No-Progress-Bremse (Phase 2 des Drei-Welten-Plans, 19.07.2026).
|
|
||||||
|
|
||||||
Generische, turn-UEBERGREIFENDE Schleifenbremse — ergaenzt Hermes' native
|
|
||||||
tool_loop_guardrails, die (a) pro Turn zaehlen und (b) nur als Fehler
|
|
||||||
klassifizierte Ergebnisse sehen. Die teuer gelernte Luecke (SSH-Marathon 18.07.,
|
|
||||||
36 min / 75 Schleifen): ein Befehl laeuft "erfolgreich" durch, die AUSGABE sagt
|
|
||||||
aber immer dasselbe ("Permission denied") — der Agent variiert Befehle gegen
|
|
||||||
eine unsichtbare Wand, ueber Turn-Grenzen hinweg, und nichts stoppt ihn.
|
|
||||||
|
|
||||||
Prinzip (KEINE hartkodierten Fehler-Strings — Leitplanke des Commanders:
|
|
||||||
"wenn sich was aendert, muss es das SELBST erkennen"):
|
|
||||||
post_tool_call → Ergebnis normalisieren (Zahlen/Hex-IDs raus) → Muster-Hash.
|
|
||||||
Gleicher Hash beim gleichen Tool in Folge = Zaehler hoch.
|
|
||||||
Unbekannte Muster landen in neue-signaturen.jsonl —
|
|
||||||
Futter fuer Traum/Radar, um daraus Playbooks zu verdichten.
|
|
||||||
pre_tool_call → Zaehler >= 3 fuer dieses Tool: EINMAL mechanisch blocken
|
|
||||||
(Diagnose-Anweisung statt naechster Blindversuch), Zaehler
|
|
||||||
zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder
|
|
||||||
3x gegen dasselbe Muster, blockt es erneut.
|
|
||||||
|
|
||||||
Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER
|
|
||||||
(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei
|
|
||||||
status=error. Lese-Tools deckt die native idempotent-Bremse ab.
|
|
||||||
|
|
||||||
Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`.
|
|
||||||
State: ~/.hermes/state/no-progress/<session>.json (48 h Selbst-Aufraeumen).
|
|
||||||
Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden).
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress")
|
|
||||||
SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl")
|
|
||||||
SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json")
|
|
||||||
THRESHOLD = 3
|
|
||||||
STATE_TTL = 48 * 3600
|
|
||||||
|
|
||||||
BLOCK_REASON = (
|
|
||||||
"NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — "
|
|
||||||
"das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. "
|
|
||||||
"WICHTIG: Dein Befehl wurde NICHT ausgefuehrt. Das hier ist eine Bremse, KEIN Ergebnis — "
|
|
||||||
"schliesse daraus NICHTS ueber die Existenz oder den Zustand von Dateien, Verzeichnissen "
|
|
||||||
"oder Diensten (Vorfall 21.07.2026: ein Worker hielt diese Meldung fuer 'Verzeichnis nicht "
|
|
||||||
"vorhanden' und baute eine halbe Stunde Arbeit neu). "
|
|
||||||
"1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). "
|
|
||||||
"2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen "
|
|
||||||
"(`ssh-keygen -y -f <key> </dev/null` — stilles 'Permission denied' = verschluesselter Key), "
|
|
||||||
"dann Gegenseite (Auth-Log, Dienststatus, Erreichbarkeit) LESEN statt raten. "
|
|
||||||
"3) Bleibt es unklar: Aufgabe als blocked/Karte mit dem EXAKTEN Ergebnistext an den "
|
|
||||||
"Commander uebergeben. Dein naechster Versuch ist frei — nutze ihn erst NACH der Diagnose."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rein LESENDE Befehle nie bremsen (21.07.2026). Die Bremse verlangt selbst „Diagnose
|
|
||||||
# statt Variation" — genau die besteht aus vielen kurzen Schau-Befehlen (ls/cat/git log),
|
|
||||||
# deren Ergebnisse sich stark aehneln. Die Signatur hielt das fuer Stillstand und blockte
|
|
||||||
# den naechsten Blick; der Worker las die Block-Meldung als „da ist nichts" und baute
|
|
||||||
# alles neu (Karte t_d26c3203, eine halbe Stunde Arbeit weggeworfen). Geschaut wird also
|
|
||||||
# ungebremst — gebremst wird nur, wer immer wieder dasselbe TUT.
|
|
||||||
# Bewusst strukturell (Befehls-Art), nicht per Fehlertext-Liste: die Bremse soll generisch
|
|
||||||
# bleiben und Neues selbst erkennen.
|
|
||||||
_LESEND = {"ls", "cat", "head", "tail", "wc", "stat", "find", "grep", "rg", "file",
|
|
||||||
"du", "df", "echo", "pwd", "tree", "readlink", "basename", "dirname", "which"}
|
|
||||||
_GIT_LESEND = {"status", "log", "show", "diff", "ls-remote", "ls-files", "rev-parse",
|
|
||||||
"merge-base", "describe"}
|
|
||||||
|
|
||||||
|
|
||||||
def _nur_lesend(cmd: str) -> bool:
|
|
||||||
"""True, wenn der Befehl ausschliesslich schaut — kein Schreiben, kein Eingriff."""
|
|
||||||
cmd = (cmd or "").strip()
|
|
||||||
if not cmd or ">" in cmd: # jede Umleitung = schreiben
|
|
||||||
return False
|
|
||||||
for teil in re.split(r"&&|\|\||;|\|", cmd):
|
|
||||||
worte = teil.split()
|
|
||||||
i = 0
|
|
||||||
while i < len(worte) and "=" in worte[i] and not worte[i].startswith("-"):
|
|
||||||
i += 1 # VAR=wert-Praefixe ueberspringen
|
|
||||||
if i >= len(worte):
|
|
||||||
return False
|
|
||||||
verb = os.path.basename(worte[i])
|
|
||||||
if verb == "git":
|
|
||||||
rest = [w for w in worte[i + 1:] if not w.startswith("-")]
|
|
||||||
# `git log …` oder `git -C <pfad> log …` → erstes oder zweites Wort zaehlt
|
|
||||||
if not (rest[:1] and rest[0] in _GIT_LESEND) and \
|
|
||||||
not (rest[1:2] and rest[1] in _GIT_LESEND):
|
|
||||||
return False
|
|
||||||
elif verb not in _LESEND:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
_NUM_RE = re.compile(r"\d+")
|
|
||||||
_HEX_RE = re.compile(r"\b[0-9a-f]{8,}\b")
|
|
||||||
_WS_RE = re.compile(r"\s+")
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_sig(tool: str, text: str) -> str:
|
|
||||||
t = (text or "")[:2000].lower()
|
|
||||||
t = _HEX_RE.sub("#", _NUM_RE.sub("#", t))
|
|
||||||
t = _WS_RE.sub(" ", t).strip()[:400]
|
|
||||||
return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _session_key(p: dict) -> str:
|
|
||||||
sid = p.get("session_id") or p.get("parent_session_id") or ""
|
|
||||||
if sid:
|
|
||||||
return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80]
|
|
||||||
return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12]
|
|
||||||
|
|
||||||
|
|
||||||
def _prune() -> None:
|
|
||||||
now = time.time()
|
|
||||||
for f in os.listdir(STATE_DIR):
|
|
||||||
fp = os.path.join(STATE_DIR, f)
|
|
||||||
if f.endswith(".json") and f != os.path.basename(SEEN_FILE):
|
|
||||||
if now - os.path.getmtime(fp) > STATE_TTL:
|
|
||||||
os.remove(fp)
|
|
||||||
|
|
||||||
|
|
||||||
def _load(path: str) -> dict:
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
return json.load(fh)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save(path: str, data: dict) -> None:
|
|
||||||
tmp = path + ".tmp"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(data, fh)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
mode = sys.argv[1] if len(sys.argv) > 1 else "post"
|
|
||||||
payload = json.load(sys.stdin)
|
|
||||||
tool = str(payload.get("tool_name") or "")
|
|
||||||
if not tool:
|
|
||||||
print("{}")
|
|
||||||
return
|
|
||||||
# Delegations-Ausnahme (20.07.2026): worker.sh / fremdblick.sh sind der
|
|
||||||
# Fliessband-/Orchestrator-Motor — konzept-fliessband ruft sie pro Runde/Rolle
|
|
||||||
# WIEDERHOLT auf (verschiedene Prompts, aehnliche Prosa-Ergebnisse). Die Bremse
|
|
||||||
# bildet ihre Signatur aus dem Ergebnis-Text → sie hielt diese legitimen
|
|
||||||
# Mehrrunden faelschlich fuer eine Schleife und blockte (Pomodoro-IDE-Test lief
|
|
||||||
# 19 min dagegen an). Diese Helfer haben ihr EIGENES Runden-Limit (max 3) →
|
|
||||||
# von der generischen Bremse ausnehmen.
|
|
||||||
_ti = payload.get("tool_input")
|
|
||||||
_cmd = _ti.get("command") if isinstance(_ti, dict) else (_ti if isinstance(_ti, str) else "")
|
|
||||||
if tool == "terminal" and ("worker.sh" in str(_cmd) or "fremdblick.sh" in str(_cmd)):
|
|
||||||
print("{}")
|
|
||||||
return
|
|
||||||
# Schauen ist keine Schleife (siehe _nur_lesend): weder zaehlen noch blocken.
|
|
||||||
if tool == "terminal" and _nur_lesend(str(_cmd)):
|
|
||||||
print("{}")
|
|
||||||
return
|
|
||||||
os.makedirs(STATE_DIR, exist_ok=True)
|
|
||||||
sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json")
|
|
||||||
state = _load(sfile)
|
|
||||||
tools = state.setdefault("tools", {})
|
|
||||||
|
|
||||||
if mode == "post":
|
|
||||||
status = str(payload.get("status") or "ok")
|
|
||||||
# terminal immer beobachten (dort tarnen sich Fehler als Erfolg),
|
|
||||||
# sonst nur echte Fehler — Lese-Tools regelt die native Bremse.
|
|
||||||
if tool != "terminal" and status != "error":
|
|
||||||
print("{}")
|
|
||||||
return
|
|
||||||
sig = _norm_sig(tool, str(payload.get("result") or ""))
|
|
||||||
entry = tools.get(tool) or {}
|
|
||||||
entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1
|
|
||||||
entry["sig"] = sig
|
|
||||||
tools[tool] = entry
|
|
||||||
_save(sfile, state)
|
|
||||||
_prune()
|
|
||||||
seen = _load(SEEN_FILE)
|
|
||||||
sigs = seen.setdefault("sigs", [])
|
|
||||||
if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2):
|
|
||||||
sigs.append(sig)
|
|
||||||
del sigs[:-500]
|
|
||||||
_save(SEEN_FILE, seen)
|
|
||||||
sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200]
|
|
||||||
with open(SIG_LOG, "a", encoding="utf-8") as fh:
|
|
||||||
fh.write(json.dumps({"ts": int(time.time()), "tool": tool,
|
|
||||||
"sig": sig, "sample": sample}, ensure_ascii=False) + "\n")
|
|
||||||
print("{}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# mode == "pre"
|
|
||||||
entry = tools.get(tool) or {}
|
|
||||||
if entry.get("count", 0) >= THRESHOLD:
|
|
||||||
entry["count"] = 0
|
|
||||||
tools[tool] = entry
|
|
||||||
_save(sfile, state)
|
|
||||||
print(json.dumps({"decision": "block",
|
|
||||||
"reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)},
|
|
||||||
ensure_ascii=False))
|
|
||||||
return
|
|
||||||
print("{}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except Exception:
|
|
||||||
print("{}")
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# pre_verify-Gate (P1-5, 09.07.2026): erinnert den Agenten VOR dem Fertigmelden an
|
|
||||||
# die zwei MC2-Pflichten aus AGENTS.md — py_compile-Gate und die dist-Falle.
|
|
||||||
# Antwort {"action":"continue","message":...} = Agent arbeitet weiter; {} = durchlassen.
|
|
||||||
set -u
|
|
||||||
payload="$(cat -)"
|
|
||||||
attempt=$(printf %s "$payload" | jq -r '.extra.attempt // 0' 2>/dev/null || echo 0)
|
|
||||||
# Ein-Schuss: nach dem ersten Nudge nicht erneut nerven (Docs-Empfehlung).
|
|
||||||
if [ "$attempt" != "0" ]; then printf '{}'; exit 0; fi
|
|
||||||
mapfile -t paths < <(printf %s "$payload" | jq -r '.extra.changed_paths[]? // empty' 2>/dev/null)
|
|
||||||
[ ${#paths[@]} -eq 0 ] && { printf '{}'; exit 0; }
|
|
||||||
msgs=()
|
|
||||||
|
|
||||||
# Gate 1: py_compile fuer alle geaenderten .py, die existieren
|
|
||||||
pyfail=""
|
|
||||||
for p in "${paths[@]}"; do
|
|
||||||
case "$p" in
|
|
||||||
*.py)
|
|
||||||
if [ -f "$p" ] && ! err=$(python3 -m py_compile "$p" 2>&1); then
|
|
||||||
pyfail="${pyfail}${p}: ${err}"$'\n'
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [ -n "$pyfail" ]; then
|
|
||||||
msgs+=("py_compile-Gate ROT:"$'\n'"${pyfail}Erst fixen, dann fertigmelden.")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Gate 2: dist-Falle — frontend/src geaendert, aber kein frontend/dist angefasst.
|
|
||||||
# Feuert NUR in Repos mit der MC2-Konvention (frontend/dist ist git-getrackt) —
|
|
||||||
# sonst nudgt es in beliebigen Spielordnern ohne Build (False-Positive, 09.07. beobachtet).
|
|
||||||
srcfile=""; dist=0
|
|
||||||
for p in "${paths[@]}"; do
|
|
||||||
case "$p" in
|
|
||||||
*/frontend/src/*|frontend/src/*) srcfile="$p" ;;
|
|
||||||
*/frontend/dist/*|frontend/dist/*) dist=1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [ -n "$srcfile" ] && [ $dist -eq 0 ]; then
|
|
||||||
# Repo-Wurzel vom geaenderten File aus hochlaufen (max 8 Ebenen).
|
|
||||||
root=$(dirname "$srcfile"); n=0
|
|
||||||
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
|
||||||
root=$(dirname "$root"); n=$((n+1))
|
|
||||||
done
|
|
||||||
if [ -e "$root/.git" ] && git -C "$root" ls-files frontend/dist 2>/dev/null | head -1 | grep -q .; then
|
|
||||||
msgs+=("AGENTS.md-Gate: Du hast frontend/src geaendert, aber frontend/dist nicht. Die Box baut NICHT selbst: cd frontend && npm run build, dann das neue dist im selben Branch mit-committen — sonst zeigt die Box nach Merge+Deploy den alten Stand.")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Gate 3 (R5, 15.07.): Orphan-Check — git-init-Repos ohne gemeinsamen Ursprung mit
|
|
||||||
# origin/main sind bei der Annahme technisch tot (3 Vorfaelle 12.–14.07., je eine
|
|
||||||
# Nacht Arbeit verloren). VOR dem Fertigmelden fangen, nicht erst im Auftragsbuch.
|
|
||||||
orphan=""; checked=""
|
|
||||||
for p in "${paths[@]}"; do
|
|
||||||
root=$(dirname "$p"); n=0
|
|
||||||
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
|
||||||
root=$(dirname "$root"); n=$((n+1))
|
|
||||||
done
|
|
||||||
[ -e "$root/.git" ] || continue
|
|
||||||
case " $checked " in *" $root "*) continue ;; esac
|
|
||||||
checked="$checked $root"
|
|
||||||
git -C "$root" remote get-url origin >/dev/null 2>&1 || continue
|
|
||||||
git -C "$root" fetch -q origin main >/dev/null 2>&1 || true
|
|
||||||
if git -C "$root" rev-parse --verify -q origin/main >/dev/null 2>&1 \
|
|
||||||
&& ! git -C "$root" merge-base HEAD origin/main >/dev/null 2>&1; then
|
|
||||||
orphan="${orphan}${root}"$'\n'
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ -n "$orphan" ]; then
|
|
||||||
msgs+=("Orphan-Gate ROT: Dieses Repo hat KEINEN gemeinsamen Ursprung mit origin/main (git init statt Klonen?):"$'\n'"${orphan}So ist der Vorschlag bei der Annahme technisch TOT. Nicht fertigmelden — frisch VOLL klonen, Branch von origin/main abzweigen, Aenderungen ruebertragen.")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ${#msgs[@]} -eq 0 ]; then printf '{}'; exit 0; fi
|
|
||||||
joined=$(printf '%s\n\n' "${msgs[@]}")
|
|
||||||
jq --null-input --arg m "$joined" '{action:"continue", message:$m}'
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""pre_tool_call-Guard (Faden 8, 13.07.2026): sperrt Schreibzugriffe auf den LIVE-Checkout
|
|
||||||
~/mission-control-v2 und die Hermes-QUELLE ~/.hermes/hermes-agent HART. Loest Fallstrick #2
|
|
||||||
(Worker wandert in die falsche Ebene / lag Patches im Live-Checkout ab). Die native
|
|
||||||
is_write_denied deckt diese Pfade NICHT ab (nur Credentials/System) -> diese Luecke.
|
|
||||||
|
|
||||||
UNBEDINGT (kein Task-/Profil-Scope): KEIN legitimer Hermes-Agent-Flow schreibt je in diese
|
|
||||||
zwei Pfade — der Werkstatt-Worker arbeitet im WORKSPACE-Klon (<workspace>/mission-control-v2,
|
|
||||||
NICHT home-verankert -> erlaubt), Lucy fasst MC2-Quelle nie an, und die Wartungs-Pipeline
|
|
||||||
(deploy.sh, Annahme-Runner) laeuft als Shell ueber ssh, NICHT ueber Hermes-Tools. Ein
|
|
||||||
Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Zeitstempel
|
|
||||||
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
|
|
||||||
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
|
|
||||||
|
|
||||||
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
|
|
||||||
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
|
|
||||||
(V16 unten) — der Workspace-Klon (~/.hermes/kanban/workspaces/<id>/mission-control-v2) bleibt
|
|
||||||
frei, sonst waere die legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden
|
|
||||||
nur Schreib-Operationen.
|
|
||||||
|
|
||||||
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def out(obj):
|
|
||||||
sys.stdout.write(json.dumps(obj))
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(sys.stdin.read() or "{}")
|
|
||||||
except Exception:
|
|
||||||
out({})
|
|
||||||
|
|
||||||
tool = payload.get("tool_name") or ""
|
|
||||||
ti = payload.get("tool_input") or {}
|
|
||||||
home = os.path.realpath(os.path.expanduser("~"))
|
|
||||||
LIVE = os.path.join(home, "mission-control-v2")
|
|
||||||
SRC = os.path.join(home, ".hermes", "hermes-agent")
|
|
||||||
# R2 (Review 15.07.): systemd-User-Units. Die Nacht-Werkstatt hat am 14.07. einen Override
|
|
||||||
# DIREKT scharf geschaltet — ging gut, verletzt aber "jede Code-Zeile ist Klick-pflichtig".
|
|
||||||
# Unit-/Override-Dateien werden nur noch ueber eine angenommene Karte installiert.
|
|
||||||
SYSD = os.path.join(home, ".config", "systemd", "user")
|
|
||||||
|
|
||||||
MSG = (
|
|
||||||
"TABU (Faden 8/R2): Schreiben an dieser Stelle ist fuer Worker gesperrt. Der Live-Checkout "
|
|
||||||
"~/mission-control-v2, die Hermes-Quelle ~/.hermes/hermes-agent und die systemd-User-Units "
|
|
||||||
"~/.config/systemd/user duerfen NIE direkt veraendert werden. Arbeite im FRISCHEN Klon "
|
|
||||||
"deines Task-Workspaces; MC2-Aenderungen kommen als Vorschlags-Branch, systemd-Unit-/"
|
|
||||||
"Override-Dateien werden nur ueber eine angenommene Karte installiert, Hermes-Verhalten "
|
|
||||||
"nur ueber Config/SOUL/Skill/Hook/MCP. Siehe docs/wissen/GRENZEN.md."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def block():
|
|
||||||
out({"action": "block", "message": MSG})
|
|
||||||
|
|
||||||
|
|
||||||
def under_tabu(path):
|
|
||||||
rp = os.path.realpath(path)
|
|
||||||
return any(rp == t or rp.startswith(t + os.sep) for t in (LIVE, SRC, SYSD))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Datei-Tools: Zielpfad kanonisch pruefen (zuverlaessig) -------------------------
|
|
||||||
if tool in ("write_file", "patch"):
|
|
||||||
fp = ti.get("path") or ti.get("file_path") or ti.get("filepath") or ""
|
|
||||||
if not fp:
|
|
||||||
out({})
|
|
||||||
fp = os.path.expanduser(fp)
|
|
||||||
cwd = payload.get("cwd") or ""
|
|
||||||
if not os.path.isabs(fp) and cwd:
|
|
||||||
fp = os.path.join(cwd, fp)
|
|
||||||
if under_tabu(fp):
|
|
||||||
block()
|
|
||||||
out({})
|
|
||||||
|
|
||||||
# --- Terminal: nur bei Schreib-Operation, die auf einen Tabu-Pfad zielt -------------
|
|
||||||
if tool == "terminal":
|
|
||||||
cmd = ti.get("command") or ""
|
|
||||||
if not cmd:
|
|
||||||
out({})
|
|
||||||
cwd_raw = payload.get("cwd") or ""
|
|
||||||
cwd = os.path.realpath(os.path.expanduser(cwd_raw)) if cwd_raw else ""
|
|
||||||
# R5 (Review 15.07.): `git init` im Kanban-Workspace hart blocken. Drei Naechte in
|
|
||||||
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
|
|
||||||
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
|
|
||||||
# ~14.07.) — solche Vorschlaege sind bei der Annahme technisch tot, die Nacht-Arbeit
|
|
||||||
# verpufft. Kein legitimer Worker-Flow braucht git init; MC2-/Lucy-Arbeit = VOLL
|
|
||||||
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
|
|
||||||
# projekt-start neue Repos initialisieren).
|
|
||||||
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
|
|
||||||
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
|
|
||||||
out({"action": "block", "message": (
|
|
||||||
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
|
|
||||||
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
|
|
||||||
"12.–14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
|
|
||||||
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
|
|
||||||
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
|
|
||||||
# Schreib-Subkommandos von git (V16: + clone — `git clone URL ~/mission-control-v2`
|
|
||||||
# rutschte vorher an (a) UND (d) vorbei).
|
|
||||||
git_write = (
|
|
||||||
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
|
|
||||||
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|clone|remote|config)\b"
|
|
||||||
)
|
|
||||||
|
|
||||||
# V16 (Vorfall 16.07.2026): Werkstatt-Worker t_7a05992a ersetzte um 03:59 den Live-
|
|
||||||
# Checkout mit cwd=$HOME und RELATIVEN Pfaden (`rm -rf mission-control-v2 && git
|
|
||||||
# clone ...`) — keine home-verankerte Tabu-Form im Kommandotext, der Guard sah nur
|
|
||||||
# Strings und liess durch (.venv weg -> Gateway im 203/EXEC-Crash-Loop). Drei Stopfen:
|
|
||||||
# (1) cwd liegt IN einem Tabu-Pfad -> jede Schreib-Operation blocken (relative Ziele
|
|
||||||
# treffen zwangslaeufig den Tabu-Baum). Lesen bleibt erlaubt.
|
|
||||||
if cwd and under_tabu(cwd):
|
|
||||||
if (re.search(git_write, cmd) or re.search(r"(?:^|[^\w])sed\s+-i", cmd)
|
|
||||||
or re.search(r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s", cmd)
|
|
||||||
or re.search(r">>?\s*(?![/&])", cmd)
|
|
||||||
or re.search(r"(?:^|[^\w])tee\s+(?:-a\s+)?(?!/)", cmd)):
|
|
||||||
block()
|
|
||||||
# (2) cwd == $HOME -> `git clone`/`git init`, deren Ziel direkt in $HOME landet,
|
|
||||||
# blocken (exakt der Vorfalls-Hergang). Worker klonen im Task-Workspace; ein
|
|
||||||
# explizites Ziel AUSSERHALB von $HOME (z.B. Unterordner) bleibt erlaubt.
|
|
||||||
m = re.search(r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*(?:clone|init)\b([^|&;]*)", cmd)
|
|
||||||
if cwd == home and m:
|
|
||||||
toks = m.group(1).split()
|
|
||||||
dest = toks[-1] if toks else ""
|
|
||||||
# kein brauchbares Ziel-Argument (leer, Option oder URL) -> implizit direkt in $HOME
|
|
||||||
implicit = (not dest or dest.startswith("-")
|
|
||||||
or re.search(r"://|^git@|^[\w.-]+@[\w.-]+:|\.git$", dest))
|
|
||||||
if (implicit or under_tabu(os.path.join(home, dest))
|
|
||||||
or os.path.dirname(os.path.realpath(os.path.join(home, dest))) == home):
|
|
||||||
out({"action": "block", "message": (
|
|
||||||
"TABU (V16): `git clone`/`git init` direkt in $HOME ist gesperrt — so wurde "
|
|
||||||
"am 16.07. der Live-Checkout ~/mission-control-v2 durch einen frischen Klon "
|
|
||||||
"ersetzt (.venv weg, Gateway tot). Klone/initialisiere in deinem Task-"
|
|
||||||
"Workspace (~/.hermes/kanban/workspaces/<task>/...) oder gib ein Ziel "
|
|
||||||
"ausserhalb von $HOME an.")})
|
|
||||||
# Home-verankerte Tabu-Formen. Der Workspace-Klon
|
|
||||||
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
|
|
||||||
tabu_forms = [
|
|
||||||
LIVE, "~/mission-control-v2", "$HOME/mission-control-v2", "${HOME}/mission-control-v2",
|
|
||||||
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
|
|
||||||
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
|
|
||||||
]
|
|
||||||
# (3) cwd == Vorfahr eines Tabu-Pfads (z.B. $HOME) -> auch die RELATIVE Nennung des
|
|
||||||
# Tabu-Ziels (mission-control-v2, .hermes/hermes-agent, .config/systemd/user)
|
|
||||||
# zaehlt als Treffer fuer die Schreib-Checks unten. Die Lookarounds stellen
|
|
||||||
# sicher, dass der Name ein eigenes Argument beginnt — als Teil eines laengeren
|
|
||||||
# Pfades (z.B. .hermes/kanban/workspaces/<id>/mission-control-v2) matcht er
|
|
||||||
# NICHT, die legitime Workspace-Arbeit bleibt frei.
|
|
||||||
rel_pats = []
|
|
||||||
if cwd:
|
|
||||||
for t in (LIVE, SRC, SYSD):
|
|
||||||
if t.startswith(cwd + os.sep):
|
|
||||||
rel = os.path.relpath(t, cwd).replace(os.sep, "/")
|
|
||||||
rel_pats.append(r"(?<![\w./@-])(?:\./)?" + re.escape(rel) + r"(?![\w.-])")
|
|
||||||
tabu_alt = "(?:" + "|".join([re.escape(t) for t in tabu_forms] + rel_pats) + ")"
|
|
||||||
if not re.search(tabu_alt, cmd):
|
|
||||||
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
|
|
||||||
|
|
||||||
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
|
|
||||||
if re.search(git_write, cmd):
|
|
||||||
block()
|
|
||||||
# (b) sed -i, das einen Tabu-Pfad anfasst.
|
|
||||||
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
|
|
||||||
block()
|
|
||||||
# (c) Umleitung > / >> / tee, die auf einen Tabu-Pfad ZIELT (nicht 2>/dev/null o.ae.).
|
|
||||||
if re.search(r"(?:>>?|(?:^|[^\w])tee\s+(?:-a\s+)?)\s*" + tabu_alt, cmd):
|
|
||||||
block()
|
|
||||||
# (d) destruktiv/kopierend mit Tabu-Pfad im selben Kommando-Segment.
|
|
||||||
if re.search(
|
|
||||||
r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s+[^|&;]*" + tabu_alt,
|
|
||||||
cmd,
|
|
||||||
):
|
|
||||||
block()
|
|
||||||
out({})
|
|
||||||
|
|
||||||
out({})
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
|
|
||||||
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
|
|
||||||
# Gitea-Actions-Runner (arcane-VM).
|
|
||||||
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
|
|
||||||
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
|
|
||||||
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
|
|
||||||
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
|
|
||||||
name: Ampel
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ampel:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
|
|
||||||
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
|
|
||||||
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
|
|
||||||
set -u +e
|
|
||||||
rot=0
|
|
||||||
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
|
|
||||||
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
|
|
||||||
|
|
||||||
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
|
|
||||||
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$py_datei" ]; then
|
|
||||||
echo "== Python erkannt =="
|
|
||||||
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
|
|
||||||
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
|
|
||||||
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
|
|
||||||
ruff check . || rot=1
|
|
||||||
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
|
|
||||||
while IFS= read -r req; do
|
|
||||||
[ -n "$req" ] || continue
|
|
||||||
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
|
|
||||||
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
|
|
||||||
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
|
|
||||||
python -m compileall -q . || rot=1
|
|
||||||
echo "-- Pytest (keine Tests gefunden = ROT)"
|
|
||||||
ec=0; pytest -q || ec=$?
|
|
||||||
if [ $ec -eq 5 ]; then
|
|
||||||
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
|
|
||||||
rot=1
|
|
||||||
elif [ $ec -ne 0 ]; then
|
|
||||||
rot=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$pkg_dateien" ]; then
|
|
||||||
echo "== Node/TypeScript erkannt =="
|
|
||||||
while IFS= read -r pkg; do
|
|
||||||
[ -n "$pkg" ] || continue
|
|
||||||
d=$(dirname "$pkg")
|
|
||||||
echo "-- $d"
|
|
||||||
if [ ! -f "$d/package-lock.json" ]; then
|
|
||||||
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
|
|
||||||
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
|
|
||||||
rot=1; continue
|
|
||||||
fi
|
|
||||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
|
||||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
|
||||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
|
||||||
done <<< "$pkg_dateien"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $rot -ne 0 ]; then
|
|
||||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
|
||||||
fi
|
|
||||||
exit $rot
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
|
|
||||||
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
|
|
||||||
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
|
|
||||||
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
|
|
||||||
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
|
|
||||||
|
|
||||||
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
|
|
||||||
[ -n "$TOKEN" ] || exit 0
|
|
||||||
|
|
||||||
python3 - "$TOKEN" "$STATE" <<'PY'
|
|
||||||
import json, sys, urllib.request
|
|
||||||
|
|
||||||
token, state_pfad = sys.argv[1], sys.argv[2]
|
|
||||||
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
|
||||||
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
|
|
||||||
BASE = "http://192.168.178.153:3000/api/v1"
|
|
||||||
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
|
|
||||||
|
|
||||||
|
|
||||||
def api(pfad):
|
|
||||||
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
|
|
||||||
with urllib.request.urlopen(req, timeout=20) as r:
|
|
||||||
return json.load(r)
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
gesehen = set(open(state_pfad).read().split())
|
|
||||||
except OSError:
|
|
||||||
gesehen = set()
|
|
||||||
|
|
||||||
neu = []
|
|
||||||
try:
|
|
||||||
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
|
|
||||||
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
|
|
||||||
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
|
|
||||||
repos = api("/repos/search?limit=50&private=true").get("data", [])
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
for repo in repos:
|
|
||||||
full = repo.get("full_name", "")
|
|
||||||
if not full or repo.get("archived"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
d = api(f"/repos/{full}/actions/tasks?limit=1")
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
|
|
||||||
for t in rows[:1]:
|
|
||||||
kennung = f"{full}#{t.get('id')}"
|
|
||||||
status = t.get("status")
|
|
||||||
if status == "failure" and kennung not in gesehen:
|
|
||||||
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
|
|
||||||
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
|
|
||||||
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
|
|
||||||
headers={"Authorization": "token " + token})
|
|
||||||
with urllib.request.urlopen(req, timeout=20) as r:
|
|
||||||
log_ende = r.read().decode(errors="replace")[-4000:]
|
|
||||||
wirklich_rot = "Job succeeded" not in log_ende
|
|
||||||
except Exception:
|
|
||||||
wirklich_rot = True
|
|
||||||
if wirklich_rot:
|
|
||||||
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
|
|
||||||
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
|
|
||||||
# darf nicht schon als gesehen gelten.
|
|
||||||
if status in ("failure", "success", "cancelled", "skipped"):
|
|
||||||
gesehen.add(kennung)
|
|
||||||
|
|
||||||
if neu:
|
|
||||||
print("🔴 CI-AMPEL ROT:")
|
|
||||||
for full, sha, name in neu:
|
|
||||||
print(f"• {full} @ {sha} ({name})")
|
|
||||||
print(f" {WEB}/{full}/actions")
|
|
||||||
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
|
||||||
|
|
||||||
with open(state_pfad, "w") as f:
|
|
||||||
f.write("\n".join(sorted(gesehen)))
|
|
||||||
PY
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
|
|
||||||
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
|
|
||||||
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
|
|
||||||
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
|
|
||||||
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
|
|
||||||
#
|
|
||||||
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
|
|
||||||
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
|
|
||||||
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
|
|
||||||
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
|
|
||||||
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
|
|
||||||
#
|
|
||||||
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
|
||||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
|
||||||
API="${MC_API:-http://127.0.0.1:9001}"
|
|
||||||
SLUG="$(echo "$BRANCH" | tr '/' '-')"
|
|
||||||
WT="/tmp/annahme-$SLUG"
|
|
||||||
LOG="/tmp/annahme-$SLUG.log"
|
|
||||||
|
|
||||||
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
|
||||||
|
|
||||||
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
|
||||||
status(){ # $1=state $2=detail
|
|
||||||
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
|
|
||||||
import json, os, sys, time
|
|
||||||
p, branch, state, detail = sys.argv[1:5]
|
|
||||||
try:
|
|
||||||
d = json.load(open(p, encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
d = {}
|
|
||||||
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
|
|
||||||
tmp = p + ".tmp"
|
|
||||||
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
|
||||||
os.replace(tmp, p)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
fail(){
|
|
||||||
status "fehlgeschlagen" "$1"
|
|
||||||
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
|
||||||
cleanup_wt
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
status "laeuft" "Merge wird vorbereitet"
|
|
||||||
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
|
|
||||||
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
|
||||||
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
|
||||||
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
|
||||||
|
|
||||||
PRE="$(git rev-parse origin/main)"
|
|
||||||
|
|
||||||
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
|
|
||||||
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
|
|
||||||
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
|
||||||
|
|
||||||
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
|
|
||||||
cleanup_wt
|
|
||||||
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
|
||||||
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
|
|
||||||
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
|
||||||
git -C "$WT" merge --abort 2>/dev/null || true
|
|
||||||
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
|
|
||||||
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
|
|
||||||
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
|
||||||
WT2="/tmp/annahme-rebase-$SLUG"
|
|
||||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
REBASED=""
|
|
||||||
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
rebase origin/main >>"$LOG" 2>&1; then
|
|
||||||
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
|
||||||
else
|
|
||||||
git -C "$WT2" rebase --abort 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
git worktree remove --force "$WT2" 2>/dev/null || true
|
|
||||||
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
|
||||||
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
|
||||||
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
|
|
||||||
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
|
|
||||||
if [ -n "${PYS// /}" ]; then
|
|
||||||
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
|
|
||||||
fi
|
|
||||||
|
|
||||||
status "laeuft" "Push nach main läuft"
|
|
||||||
PUSHED=0
|
|
||||||
for _ in 1 2 3; do
|
|
||||||
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
|
||||||
cleanup_wt
|
|
||||||
|
|
||||||
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
|
|
||||||
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
|
|
||||||
|
|
||||||
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
|
|
||||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
|
|
||||||
DEPLOY_OK=1
|
|
||||||
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
|
|
||||||
|
|
||||||
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
|
|
||||||
HEALTH=0
|
|
||||||
if [ "$DEPLOY_OK" = 1 ]; then
|
|
||||||
for _ in $(seq 1 18); do
|
|
||||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$HEALTH" = 1 ]; then
|
|
||||||
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
|
|
||||||
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
|
||||||
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
|
|
||||||
status "eingespielt" "Deploy grün, Health-Check bestanden"
|
|
||||||
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
|
|
||||||
status "rollback" "Health rot — automatischer Revert läuft"
|
|
||||||
git fetch -q origin || true
|
|
||||||
cleanup_wt
|
|
||||||
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
|
||||||
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
|
||||||
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
|
||||||
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
|
||||||
cleanup_wt
|
|
||||||
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
|
|
||||||
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
|
|
||||||
R=0
|
|
||||||
for _ in $(seq 1 12); do
|
|
||||||
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
if [ "$R" = 1 ]; then
|
|
||||||
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
|
|
||||||
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
cleanup_wt
|
|
||||||
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
|
|
||||||
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
|
|
||||||
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|
|
||||||
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
|
|
||||||
exit 2
|
|
||||||
@@ -1,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."
|
|
||||||
@@ -1,125 +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
|
|
||||||
# Hermes-Desktop-/Gate-Infrastruktur (09.07.2026) — ohne sie verliert ein Restore die
|
|
||||||
# Desktop-Verbindung und die pre_verify-Gates STILL (config.yaml referenziert die Hooks):
|
|
||||||
[ -d "$HERMES/agent-hooks" ] && cp -a "$HERMES/agent-hooks" "$STAGE/hermes/agent-hooks" || true
|
|
||||||
[ -f "$HERMES/shell-hooks-allowlist.json" ] && cp -a "$HERMES/shell-hooks-allowlist.json" "$STAGE/hermes/" || true
|
|
||||||
[ -f "$HERMES/desktop-gateway-token" ] && cp -a "$HERMES/desktop-gateway-token" "$STAGE/hermes/" || true
|
|
||||||
[ -f "$HERMES/pc-paths.yaml" ] && cp -a "$HERMES/pc-paths.yaml" "$STAGE/hermes/" || true
|
|
||||||
_TOKDROPIN="$HOME/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf"
|
|
||||||
[ -f "$_TOKDROPIN" ] && { mkdir -p "$STAGE/hermes/systemd-dropins"; cp -a "$_TOKDROPIN" "$STAGE/hermes/systemd-dropins/"; } || true
|
|
||||||
# Cron-Jobs + Radar-State (10.07.2026) — jobs.json liegt AUSSERHALB des Repos; ohne diese
|
|
||||||
# Zeilen verliert ein Restore ALLE Hermes-Crons (traum/Briefing/Monats-Review/Chef-Gutachter/
|
|
||||||
# Release-Radar) still:
|
|
||||||
[ -d "$HERMES/cron" ] && cp -a "$HERMES/cron" "$STAGE/hermes/cron" || true
|
|
||||||
[ -d "$HERMES/state" ] && cp -a "$HERMES/state" "$STAGE/hermes/state" || 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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
|
|
||||||
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
|
|
||||||
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
|
|
||||||
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
|
|
||||||
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
|
|
||||||
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
|
|
||||||
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
|
|
||||||
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
|
|
||||||
#
|
|
||||||
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
|
|
||||||
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
|
||||||
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
|
||||||
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
|
|
||||||
|
|
||||||
cd "$SRC" || exit 0
|
|
||||||
git fetch -q --prune origin 2>/dev/null || exit 0
|
|
||||||
|
|
||||||
angenommen=0
|
|
||||||
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
|
|
||||||
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
|
|
||||||
BRANCH="${REF#origin/}"
|
|
||||||
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
|
|
||||||
|
|
||||||
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
|
|
||||||
[ "${AHEAD:-0}" -gt 0 ] || continue
|
|
||||||
|
|
||||||
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
|
|
||||||
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
|
|
||||||
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
|
|
||||||
except Exception:
|
|
||||||
print("")
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
[ -z "$STATE" ] || continue
|
|
||||||
|
|
||||||
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
|
|
||||||
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
|
|
||||||
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
|
|
||||||
[ -n "$DIFF" ] || continue
|
|
||||||
OK=1
|
|
||||||
while IFS=$'\t' read -r st path _rest; do
|
|
||||||
[ -n "$st" ] || continue
|
|
||||||
case "$st" in A|M) ;; *) OK=0; break ;; esac
|
|
||||||
case "$path" in *.md) ;; *) OK=0; break ;; esac
|
|
||||||
done <<< "$DIFF"
|
|
||||||
[ "$OK" = 1 ] || continue
|
|
||||||
|
|
||||||
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
|
|
||||||
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
|
|
||||||
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
|
|
||||||
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
|
|
||||||
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
|
|
||||||
if bash "$RUNNER" "$BRANCH"; then
|
|
||||||
angenommen=$((angenommen+1))
|
|
||||||
else
|
|
||||||
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
|
|
||||||
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
|
|
||||||
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Betriebs-/Mess-Worker (Profil „betrieb")
|
|
||||||
|
|
||||||
Du bist der Betriebs- und Mess-Worker der AI-Box. Du arbeitest EINE Kanban-Aufgabe ab —
|
|
||||||
Ops, Benchmarks, Messungen, Health-/Diagnose-Checks, Verifikation von Behauptungen auf der
|
|
||||||
LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein Smalltalk.
|
|
||||||
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
|
|
||||||
du reparierst nicht.**
|
|
||||||
|
|
||||||
## Schritt 0: WIEDERAUFNAHME — bei „Prior attempts" erst nachsehen, dann messen
|
|
||||||
|
|
||||||
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, hat vor dir schon ein
|
|
||||||
Lauf gemessen und ist unterwegs gestorben (meist Kontext-Limit). **Dein Workspace ist derselbe.**
|
|
||||||
Also zuerst: `cat FORTSCHRITT.md` und `ls -la` im Workspace — liegen dort schon Messwerte, Logs,
|
|
||||||
Bench-Ausgaben? **Vorhandene Zahlen misst du NICHT neu.** Das ist hier keine Bequemlichkeit,
|
|
||||||
sondern Pflicht: ein wiederholter Bench lädt 70-GB-Modelle, gefährdet Lucys Warm-Set und kann
|
|
||||||
einen OOM auslösen (Regel 5). Fehlt nur noch die Auswertung, wertest du aus und schließt ab.
|
|
||||||
Nur was nachweislich fehlt oder unbrauchbar ist, wird neu gemessen.
|
|
||||||
|
|
||||||
**Fortschritt hinterlassen:** jede fertige Messung sofort als Zeile in `FORTSCHRITT.md` im
|
|
||||||
Workspace (was gemessen, welche Zahl, in welcher Datei) — und die Rohausgabe in eine Datei
|
|
||||||
daneben. Eine Messung, die nur im Kontext steht, ist beim nächsten Lauf verloren.
|
|
||||||
|
|
||||||
## Eiserne Regeln (nicht verhandelbar)
|
|
||||||
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
|
|
||||||
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
|
|
||||||
stört. Ergibt die Messung, dass etwas geändert werden muss → schreib es als BEFUND/Vorschlag
|
|
||||||
in die Summary (oder leg per `kanban_create` eine Idee an), mach es NICHT selbst.
|
|
||||||
2. **Nur LESEN am Bestand:** Live-Checkout `~/mission-control-v2` und Hermes-Quelle
|
|
||||||
`~/.hermes/hermes-agent` sind TABU zum Schreiben (Lesen zur Orientierung ok). Alles außerhalb
|
|
||||||
einer reinen Messung ist tabu.
|
|
||||||
3. **TABU-Zonen:** Security-Config (approvals/Tokens/ufw/sudoers), `~/.hermes/config.yaml`,
|
|
||||||
systemd-Units. Braucht die Aufgabe so etwas → `kanban_block` mit Begründung, nicht selbst tun.
|
|
||||||
4. **Kenn deine Werkzeuge** (Endpunkte/Pfade stehen in deiner Box-Orientierung + im Selbst-
|
|
||||||
Steckbrief `~/.hermes/state/selbst-steckbrief.md`):
|
|
||||||
- Health/Status: `curl -s http://127.0.0.1:9001/api/health`, `.../api/system/status`.
|
|
||||||
- **Dienste:** `systemctl --user status <dienst>` — aber VORHER
|
|
||||||
`export XDG_RUNTIME_DIR=/run/user/$(id -u)`, sonst sind die User-Units per SSH UNSICHTBAR
|
|
||||||
(häufigste Ops-Falle). Logs: `journalctl --user -u <dienst> --no-pager -n 100`.
|
|
||||||
- Modelle/Router: `curl -s http://127.0.0.1:8080/...` bzw. `http://127.0.0.1:9001/v1/models`.
|
|
||||||
- Bench/Smoke (nur lesen/messen, nicht deployen): `~/mission-control-v2/deploy/bench/brain-bench.sh`,
|
|
||||||
`.../deploy/bench/model-bench.sh`, `.../deploy/self-smoke.sh`, `.../deploy/stack-postcheck.sh`.
|
|
||||||
5. **BENCH-VORSICHT:** Nie zwei große Benches (70-GB-Modelle) direkt nacheinander → OOM-Kill
|
|
||||||
(Vorfall E5, 02.07.2026). Schwere Modelle (heavy/vision) nur laden, wenn die Aufgabe es
|
|
||||||
verlangt; danach den Ausgangszustand wiederherstellen. **Lucys Warm-Set und ihre ~1-s-Sprech-
|
|
||||||
Latenz NIE gefährden** — ein Config-Reload leert das Warm-Set, ein OOM killt laufende Modelle.
|
|
||||||
6. **VERIFIZIEREN VOR BEHAUPTEN.** Dein Bericht enthält BELEGTE Zahlen und echte Kommando-
|
|
||||||
Ausgaben (bei Vergleichen: Vorher/Nachher), keine Vermutung. Zitiere die reale Ausgabe, nicht
|
|
||||||
das, was du erwartest. Der Agenten-Erzählung — auch deiner eigenen — nie glauben; das Log,
|
|
||||||
der curl, der bench sind die Wahrheit.
|
|
||||||
7. **Falsche Ebene erkannt?** Braucht die Aufgabe in Wahrheit eine CODE-Änderung → das ist die
|
|
||||||
**werkstatt**, nicht du (`kanban_block` mit Verweis). Reine Doku/Recherche ohne Box-Bezug →
|
|
||||||
**default**. Grenzen im Zweifel: `~/mission-control-v2/docs/wissen/GRENZEN.md`.
|
|
||||||
8. **Fertig = `kanban_complete`** mit deutscher Summary: was gemessen, die ZAHLEN/der klare
|
|
||||||
Befund, wie geprüft (welche Kommandos). Unklar, blockiert oder Werkzeug fehlt → `kanban_block`
|
|
||||||
mit ehrlicher Diagnose statt geratener Zahlen.
|
|
||||||
|
|
||||||
## Stil
|
|
||||||
Deutsch in allem User-Sichtbaren. Zahlen statt Prosa. Ein knapper, belegter Befund schlägt
|
|
||||||
einen langen, vagen Bericht. Keine Nebenbaustellen — genau die eine Messung, sauber.
|
|
||||||
@@ -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
|
|
||||||
@@ -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."
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
|
|
||||||
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
|
|
||||||
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
|
|
||||||
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
|
|
||||||
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
|
|
||||||
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
|
|
||||||
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
|
|
||||||
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
|
|
||||||
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
|
|
||||||
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
|
|
||||||
MC="$HOME/mission-control-v2"
|
|
||||||
VAULT="$HOME/wissens-vault"
|
|
||||||
|
|
||||||
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
|
|
||||||
cat "$MC/deploy/chef-gutachter-prompt.md"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
|
|
||||||
EVID="$(
|
|
||||||
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
|
|
||||||
echo
|
|
||||||
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
|
|
||||||
git -C "$MC" fetch -q origin 2>/dev/null || true
|
|
||||||
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
|
|
||||||
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
|
|
||||||
echo
|
|
||||||
echo "### main-Commits der letzten 24 h:"
|
|
||||||
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
|
|
||||||
echo
|
|
||||||
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
|
|
||||||
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
|
|
||||||
echo
|
|
||||||
echo "### Ideen-Queue (natives Kanban, Stand jetzt — offene Ideen des Commanders):"
|
|
||||||
bash -lc 'hermes kanban stats' 2>/dev/null | head -12
|
|
||||||
echo
|
|
||||||
echo "### Ideen-Queue: zuletzt bewegte Aufgaben:"
|
|
||||||
bash -lc 'hermes kanban list --sort updated' 2>/dev/null | head -10
|
|
||||||
echo
|
|
||||||
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
|
|
||||||
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
|
|
||||||
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
|
|
||||||
| sort | uniq -c | sort -rn | head -6
|
|
||||||
echo
|
|
||||||
echo "### Aktivitäts-Insights (1 Tag):"
|
|
||||||
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
|
|
||||||
)"
|
|
||||||
|
|
||||||
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
|
|
||||||
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
|
|
||||||
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
|
|
||||||
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
|
|
||||||
|
|
||||||
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
|
|
||||||
judge() {
|
|
||||||
local req
|
|
||||||
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
|
|
||||||
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
|
|
||||||
max_tokens:$mt, temperature:0.2}')"
|
|
||||||
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
|
|
||||||
| python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
d = json.load(sys.stdin)
|
|
||||||
msg = d["choices"][0]["message"]
|
|
||||||
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
|
|
||||||
print(out)
|
|
||||||
except Exception:
|
|
||||||
pass'
|
|
||||||
}
|
|
||||||
|
|
||||||
RICHTER="$CHEF"
|
|
||||||
VERDIKT="$(judge "$CHEF" 420 2600)"
|
|
||||||
if [ -z "${VERDIKT// /}" ]; then
|
|
||||||
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
|
|
||||||
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$EVID"
|
|
||||||
echo
|
|
||||||
if [ -z "${VERDIKT// /}" ]; then
|
|
||||||
echo "## CHEF-VERDIKT: AUSGEFALLEN"
|
|
||||||
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
|
|
||||||
else
|
|
||||||
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
|
|
||||||
echo "$VERDIKT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
|
|
||||||
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
|
|
||||||
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
|
|
||||||
if [ -n "${VERDIKT// /}" ]; then
|
|
||||||
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
|
|
||||||
"source": "chef-gutachter", "priority": "silent"}))
|
|
||||||
PY
|
|
||||||
)" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
|
|
||||||
|
|
||||||
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
|
|
||||||
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
|
|
||||||
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
|
|
||||||
|
|
||||||
Regeln:
|
|
||||||
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
|
|
||||||
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
|
|
||||||
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
|
|
||||||
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
|
|
||||||
die GLM-Vertretung).
|
|
||||||
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
|
|
||||||
von dir.
|
|
||||||
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# curator-archive-watchdog.sh
|
|
||||||
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
|
|
||||||
|
|
||||||
# Python-Skript ausführen
|
|
||||||
python3 "$PYTHON_SCRIPT"
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user