Compare commits
370 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d2d7c74b | |||
| 7638f0244d | |||
| 4985977492 | |||
| f65ff48599 | |||
| 87f446c5ae | |||
| f524ef4375 | |||
| 605c9d7dd1 | |||
| 9791f44644 | |||
| 1e3e0066af | |||
| 2365f7aac6 | |||
| 56b374a0aa | |||
| eef7fdb4df | |||
| cd60974ff9 | |||
| 20d4f40ed9 | |||
| cb649aac34 | |||
| d18aac0e1b | |||
| 6c14705798 | |||
| 991c9a2f82 | |||
| ad49dd65ca | |||
| c28aa4a6a2 | |||
| 8b76c0f749 | |||
| a934deee41 | |||
| 543eee95d0 | |||
| 59d6be8c4a | |||
| 7fac17ed9a | |||
| 00f2b71a63 | |||
| 2cecd06c5b | |||
| 775e862652 | |||
| 47f7a85510 | |||
| e6502f676a | |||
| 554c87aaee | |||
| 798de0be8f | |||
| c13cfd2bd0 | |||
| 080eaa5ff5 | |||
| 7dd44a3630 | |||
| 554db10a6f | |||
| ce030d6817 | |||
| 8996c0d012 | |||
| a1de1ec2ac | |||
| 8e735df32d | |||
| 057cc2bb47 | |||
| e686f95545 | |||
| 8a90b7507e | |||
| 59b09e4a9a | |||
| 5894c24703 | |||
| 3f279fc7f2 | |||
| af28a75338 | |||
| a121ca7dc0 | |||
| cce32810fc | |||
| 682be9b63b | |||
| 96c4e417ec | |||
| 90ab873924 | |||
| 6d3a0bf3a6 | |||
| c3fe6dbe8d | |||
| 584f257853 | |||
| 73513f5e5b | |||
| 18f5522612 | |||
| 0cea01c699 | |||
| 94dc3fba9b | |||
| 3cdd5cdd10 | |||
| 9307c8b752 | |||
| 204238925a | |||
| a16cff39ce | |||
| 11c3416571 | |||
| 7389386791 | |||
| 40c8ed4754 | |||
| 656c5045da | |||
| 828dbe13b7 | |||
| 82a3bc3c59 | |||
| d5da853db2 | |||
| 7b9752261a | |||
| f77c09295a | |||
| cfcb5d2db5 | |||
| efca31c21f | |||
| a57e971607 | |||
| bf9c22ab78 | |||
| ff8125d2ac | |||
| 4bb6b28297 | |||
| e3b3725b6f | |||
| 77ae504b58 | |||
| 8153d14d9f | |||
| a91d70d9b7 | |||
| eb1f333bba | |||
| 24e2017d5b | |||
| 2383dd26fa | |||
| aebe10306d | |||
| 6b03a2c3cf | |||
| a1d7a4b6b9 | |||
| 903f3bc074 | |||
| 331aafa25d | |||
| cab748f32e | |||
| d91bd5a139 | |||
| f21fe90090 | |||
| 7b826f66c4 | |||
| e556dcc853 | |||
| b26fb40cbc | |||
| bc60c6fefc | |||
| de21e1ac68 | |||
| 9c03316755 | |||
| f5e362e902 | |||
| 2be599bfe9 | |||
| b0dce954e9 | |||
| 8579fe9934 | |||
| 32c24d7b60 | |||
| 547851f1c3 | |||
| 1daacea468 | |||
| 5aded967ef | |||
| a22f27bc60 | |||
| 31fde2a68e | |||
| 0890698750 | |||
| 77dfac79ee | |||
| fa22435d7c | |||
| 4df6a3ecf9 | |||
| d697f3cc0d | |||
| fd241aca51 | |||
| d2d726c105 | |||
| 71192b3db0 | |||
| 26224804ed | |||
| bc60f9254b | |||
| 0c69c491e5 | |||
| b36561990e | |||
| 41b0edba53 | |||
| b7e6cf4acc | |||
| f271b04754 | |||
| 78bff28acf | |||
| ff6ef3eb1f | |||
| 21ea7e1dd3 | |||
| e91f6b7ba8 | |||
| d0a967e765 | |||
| 87670dd3a6 | |||
| 41e569cf99 | |||
| d2398b3624 | |||
| 202e97333a | |||
| 0eb2b37abc | |||
| 0f13a4f18d | |||
| 5d0a61c38e | |||
| 5dde4c9f5c | |||
| bc69cf5cc9 | |||
| 7223ca96c1 | |||
| 3cf111d973 | |||
| 3468bc9c80 | |||
| ba0458c0af | |||
| fdabe1768e | |||
| bf9fd808d8 | |||
| a9c0239f8a | |||
| ccc9a25a25 | |||
| a3d9c745cd | |||
| b0eec715c8 | |||
| 86bd72838a | |||
| 734cafd98f | |||
| abc8a365d2 | |||
| 1cd492eb33 | |||
| 3abc133118 | |||
| 0354ce999f | |||
| 960bda6021 | |||
| 5f6e3c77d4 | |||
| 85a744ac7b | |||
| faf25bf76c | |||
| 5d02e7af91 | |||
| 913256e241 | |||
| 5a236b4b28 | |||
| 967225b1d7 | |||
| e7f143823e | |||
| 4e8f0b91dd | |||
| 69537de304 | |||
| c81523c0f4 | |||
| e5f546a72f | |||
| 5436e4df16 | |||
| a1957d7094 | |||
| 5690217d84 | |||
| 0a9293f669 | |||
| 5496159b8b | |||
| 9a41cce99b | |||
| 1427048f38 | |||
| 82453e9f12 | |||
| a2bfe0e480 | |||
| c71a0a1abd | |||
| 94444f4dcb | |||
| f88f8e8bf9 | |||
| 52f25432b8 | |||
| 2caecb0a79 | |||
| 552299a761 | |||
| 15c137e849 | |||
| fc591da024 | |||
| 9872c1b3f6 | |||
| 1570b2fc71 | |||
| 961b003df4 | |||
| 90d9b68d3d | |||
| b4c405a127 | |||
| 3fa2fbb9d8 | |||
| 6d5b09a592 | |||
| e951db36c5 | |||
| 73a20daa52 | |||
| e1ca7f6a5e | |||
| ae4b0f229e | |||
| 7f224ec98e | |||
| 2d32d21742 | |||
| ae523a171b | |||
| 3f32a9a746 | |||
| 6e093ed163 | |||
| 73f593b1a1 | |||
| 83cca21113 | |||
| 07be502e4e | |||
| 042c3f16d9 | |||
| 35be5e13f6 | |||
| dfcf86902a | |||
| 6ad6c09cc0 | |||
| c452b873b7 | |||
| c0586c3fad | |||
| 1983d70805 | |||
| 65d5a49411 | |||
| 83e5a04735 | |||
| 082f4a659d | |||
| 103b277643 | |||
| 64f45ed0ff | |||
| 90d8a7d56d | |||
| dec4922171 | |||
| 2ed7100f90 | |||
| 895eb9cac7 | |||
| a261462857 | |||
| 35e8951a0d | |||
| 520cfd9fe4 | |||
| 9375b8eea8 | |||
| 3ea0e2d540 | |||
| c20c070a6b | |||
| 5c279ccc38 | |||
| 7fab2cbcaa | |||
| c2fe13857c | |||
| 5016ceef06 | |||
| bbed8211f8 | |||
| 51c0c868b2 | |||
| b703361a1d | |||
| 0dc9caa955 | |||
| eb3caf6476 | |||
| 36c5cd1632 | |||
| 47ec64f61b | |||
| 3572667e08 | |||
| 13810b6e74 | |||
| 15d5249458 | |||
| 91e1f1ec9a | |||
| 974e81fe49 | |||
| 19ab42da1a | |||
| 616518e2fc | |||
| 592057c53f | |||
| c0a97b1004 | |||
| e078e37a87 | |||
| 6e3696563b | |||
| b4a54304a4 | |||
| 041ac70a80 | |||
| 5f81ae0731 | |||
| a3068c1b08 | |||
| 531a10f8e5 | |||
| e504037770 | |||
| 12b7e00f7d | |||
| cb9de01feb | |||
| d9920fba8e | |||
| 6704de3a69 | |||
| ba238ee7bd | |||
| 00000548b4 | |||
| 54be1fb1f6 | |||
| 0249b2600c | |||
| 1985a692bf | |||
| 4dc5ca7343 | |||
| 8b6dbd4aa1 | |||
| 2d95b0a334 | |||
| f2d357c5d8 | |||
| 49807ace7b | |||
| 1b35f70dc6 | |||
| 88ec8b9121 | |||
| f88ed4e5df | |||
| d68ee60182 | |||
| f65f9ca2d0 | |||
| 092dcc2ada | |||
| 989b6de850 | |||
| 6f77dbb9d9 | |||
| edf1614e66 | |||
| 97be0f1d00 | |||
| c92f238d2d | |||
| 3fba487963 | |||
| f49e5ecf9d | |||
| cb3a5adaf9 | |||
| 87c479323c | |||
| b95530ee70 | |||
| 42e45b4b50 | |||
| 3a6e3c5432 | |||
| 001ebb7742 | |||
| 26deb1cec1 | |||
| 93f447327b | |||
| 12c387a6de | |||
| ff1b9a085f | |||
| 42f0497253 | |||
| a45bd586b6 | |||
| 032b9a8ee9 | |||
| eff23643e6 | |||
| 00fc7d6d81 | |||
| 18afa37412 | |||
| 854393ce5f | |||
| f6029e25fc | |||
| 51552ae836 | |||
| 78448220b2 | |||
| 58021331e1 | |||
| ab63b05b9c | |||
| 8e4aca9f5c | |||
| 494037dee5 | |||
| 8a2db70f3c | |||
| 5ee3f8f73d | |||
| ce55752249 | |||
| d0688a7b64 | |||
| 4e5a7eed35 | |||
| 612ce127fa | |||
| 357285e798 | |||
| b21794a750 | |||
| 97344f1ad2 | |||
| a682e3c4e7 | |||
| 8b0e14db7c | |||
| c8e408cdd8 | |||
| fa8f776910 | |||
| 2b124d5b0a | |||
| 019bd6d18b | |||
| a23f4c0652 | |||
| a024ea1b2f | |||
| d2ab662146 | |||
| 50867b2781 | |||
| a2091b4f5f | |||
| dd3d66156b | |||
| 18f36f52ce | |||
| 02fac7f55f | |||
| 9e5392fa4f | |||
| ff6c1daf6c | |||
| 14b5c6d6a0 | |||
| 2c8d4a081e | |||
| ab8d651efc | |||
| dd947298d0 | |||
| 2c95f1c5bc | |||
| cd3c5b5eed | |||
| 2db0ce080f | |||
| df8ec46018 | |||
| a8d6f3f598 | |||
| 2263e298fd | |||
| 1adbb19572 | |||
| 41aeb111d7 | |||
| d887a6c248 | |||
| 4eaa11c32a | |||
| 8c7dd5b30f | |||
| 49bf11886e | |||
| 996fea224a | |||
| 9822af7640 | |||
| 9e7cc6612f | |||
| 4a680b863e | |||
| d7de1f47d4 | |||
| 646031e5c0 | |||
| 195b07deef | |||
| 2a4eb51bd7 | |||
| 7704ffddab | |||
| 588d032335 | |||
| e84386b03e | |||
| a16287e19c | |||
| f88c2a95c1 | |||
| ed55612cfa | |||
| 47ade040e6 | |||
| 852e723cc8 | |||
| f90e5f4519 | |||
| c3d1cc87e2 | |||
| 88c2659a9c | |||
| 6f7498a956 | |||
| a1cea1a1ea | |||
| 60765469aa | |||
| 7e857a546b | |||
| a341d254e1 | |||
| 6db1224d95 |
+27
@@ -0,0 +1,27 @@
|
||||
# .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*
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
*.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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
+3
-21
@@ -20,24 +20,6 @@ frontend/dist/avatar.vrm
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# Lucy-TTS/F5-Experimente: nur Code/Batch committen — venvs, Modelle, Audio, Logs bleiben lokal
|
||||
client/lucy-tts/ptts-venv/
|
||||
client/lucy-tts/venv/
|
||||
client/lucy-tts/llamacpp-vulkan/
|
||||
client/lucy-tts/llamacpp-vulkan.zip
|
||||
client/lucy-tts/models/
|
||||
client/lucy-tts/out_*/
|
||||
client/lucy-tts/*.wav
|
||||
client/lucy-tts/*.mp3
|
||||
client/lucy-tts/*.log
|
||||
client/lucy-tts/*.safetensors
|
||||
client/lucy-tts/*.json
|
||||
client/lucy-f5/.cache/
|
||||
client/lucy-f5/F5-TTS-ONNX/
|
||||
client/lucy-f5/onnx_de/
|
||||
client/lucy-f5/onnx_f32/
|
||||
client/lucy-f5/vocos-mel-24khz/
|
||||
client/lucy-f5/*.onnx
|
||||
client/lucy-f5/*.safetensors
|
||||
client/lucy-f5/*.wav
|
||||
client/lucy-f5/*.zip
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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,68 +1,150 @@
|
||||
# Mission Control 2.0
|
||||
|
||||
Komponierbarer Local-AI-Stack für den Bosgame M5. Greenfield-Neuaufbau —
|
||||
siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
|
||||
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.
|
||||
|
||||
**Schichten:** Engine (llama-swap, **Vulkan/RADV** auf Strix Halo) · **Builtin-Routing-Gateway**
|
||||
in MC2 (`model: auto`, kein externer LiteLLM-Dienst — scheitert auf Python 3.14) ·
|
||||
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
|
||||
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
|
||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||
|
||||
## Status: Phasen 0–5 ✅ · MC2 **live auf der Box** (:9001) · Modelle/Hermes-Wiring + Cutover offen
|
||||
## Die drei Bahnen
|
||||
|
||||
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
|
||||
| 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` |
|
||||
|
||||
- **Phase 0** — FastAPI-Skeleton + React/shadcn-Shell (Cmd+K, Dark, PWA).
|
||||
- **Phase 1** — Compute-Module (fit/caps/sources, portiert), **Discover** (live HF + Fit + Caps),
|
||||
**Engine-Write** (register + `groups`/Ko-Residenz + vocab-geprüfte Spec-Drafts),
|
||||
**Builtin-Gateway** (`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
|
||||
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
||||
Bau-Sitzung und wird nie unterbrochen.
|
||||
|
||||
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei),
|
||||
**Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override).
|
||||
- **Phase 3** — Geteiltes **Gedächtnis** (SQLite/WAL, 5 Kategorien, Dedupe-Kurator) + **MCP-Server**
|
||||
(`mcp/mcp_memory.py` shared, `mcp/mcp_mc.py` Stack-Management für Hermes), MemoryView.
|
||||
## Ports & Dienste
|
||||
|
||||
- **Phase 4** — Hermes-**Agent-Status** (`/api/agent/status`, AgentView mit Tiles + „Hermes öffnen"),
|
||||
`deploy/hermes-webui.service`, **Box-Runbook** [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md)
|
||||
(hermes-webui, Brain=`auto`, Tools/MCP-Verdrahtung). Box-Ausführung steht noch aus.
|
||||
| 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 |
|
||||
|
||||
- **Phase 5** — **Backup** (Memory + Configs), **Services-Health** + Observability-Links,
|
||||
**Theme-Toggle** (Hell/Dunkel). Box-Deploy/-Wiring + Cutover (Phase 6) brauchen die Box.
|
||||
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.
|
||||
|
||||
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
|
||||
memory/* · agent/status` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
|
||||
Box-Runbooks: `docs/HERMES_SETUP.md` + `deploy/` (Units, deploy.sh, backup.sh).
|
||||
## 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
|
||||
|
||||
**Backend:**
|
||||
```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):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
# Frontend (Dev, proxyt /api → :9000)
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
|
||||
**Frontend (Build → wird vom Backend ausgeliefert):**
|
||||
```bash
|
||||
# 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_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port |
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
|
||||
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
|
||||
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
|
||||
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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).
|
||||
+108
-19
@@ -9,18 +9,43 @@ 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
|
||||
|
||||
from config import FRONTEND_DIST, VERSION
|
||||
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system, voice
|
||||
from services import warmer
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
logging.basicConfig(
|
||||
@@ -29,17 +54,45 @@ logging.basicConfig(
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn."""
|
||||
task = asyncio.create_task(warmer.rewarm_loop()) if warmer.ENABLED else None
|
||||
if task:
|
||||
log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL)
|
||||
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:
|
||||
if task:
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
_ = task.cancel()
|
||||
await app.state.gw_client.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
||||
@@ -54,7 +107,9 @@ app.add_middleware(
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache(request: Request, call_next):
|
||||
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"
|
||||
@@ -68,25 +123,59 @@ 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(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
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):
|
||||
# Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus
|
||||
target = FRONTEND_DIST / full_path
|
||||
if target.is_file():
|
||||
# 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 FileResponse(
|
||||
index, headers={"Cache-Control": "no-cache, must-revalidate"}
|
||||
)
|
||||
return {"detail": "frontend not built"}
|
||||
|
||||
|
||||
+21
-3
@@ -61,6 +61,11 @@ HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
# 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`).
|
||||
@@ -94,9 +99,22 @@ 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("/")
|
||||
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat).
|
||||
# Wird in MC2 per iframe eingebettet (Terminal-Seite). Siehe deploy/hermes-terminal.service.
|
||||
HERMES_TERMINAL_URL = os.environ.get("MC_HERMES_TERMINAL_URL", "http://192.168.178.151:7681").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")))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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}
|
||||
@@ -4,8 +4,9 @@ from pathlib import Path
|
||||
# Add backend directory to sys.path so we can import services
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
|
||||
from config import CONFIG_PATH
|
||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_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-06-27",
|
||||
"version": "2026-07-03",
|
||||
"models": [
|
||||
{
|
||||
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
|
||||
@@ -35,6 +35,11 @@
|
||||
"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,
|
||||
@@ -47,14 +52,20 @@
|
||||
},
|
||||
|
||||
{
|
||||
"role": "scout", "name": "gemma-4-26B-A4B-it", "repo": "google/gemma-4-26B-A4B-it-GGUF",
|
||||
"family": "gemma", "generation": 4.0, "total_params_b": 26, "active_params_b": 4,
|
||||
"moe": true, "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": "scout", "name": "gemma-4-31B-it", "repo": "google/gemma-4-31B-it-GGUF",
|
||||
"family": "gemma", "generation": 4.0, "total_params_b": 31, "active_params_b": 31,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
||||
"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,7 +1,9 @@
|
||||
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,10 +1,7 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
|
||||
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import auftragsbuch
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BranchIn(BaseModel):
|
||||
branch: str
|
||||
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
|
||||
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
|
||||
|
||||
|
||||
class KandidatIn(BaseModel):
|
||||
file: str
|
||||
|
||||
|
||||
@router.get("/auftragsbuch")
|
||||
def list_proposals() -> dict:
|
||||
return auftragsbuch.list_proposals()
|
||||
|
||||
|
||||
@router.get("/auftragsbuch/diff")
|
||||
def diff(branch: str, repo: str = "mc2") -> dict:
|
||||
res = auftragsbuch.diff_of(branch, repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/annehmen")
|
||||
def accept(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.accept(body.branch, body.repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/ablehnen")
|
||||
def reject(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/annehmen")
|
||||
def skill_accept(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_accept(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/ablehnen")
|
||||
def skill_reject(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_reject(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
||||
return res
|
||||
@@ -0,0 +1,28 @@
|
||||
"""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,7 +1,6 @@
|
||||
"""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")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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"})
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
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,33 +1,194 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from config import LLAMA_SWAP_URL
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from config import LLAMA_SWAP_URL
|
||||
from services.gateway_stream import record_stream_chunk, record_usage
|
||||
from services.router_logic import LANES, choose_for_lane
|
||||
from services.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")
|
||||
|
||||
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
|
||||
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
|
||||
# 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():
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{LLAMA_SWAP_URL}/v1/models")
|
||||
async def models(request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
data = r.json()
|
||||
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
|
||||
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
|
||||
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
|
||||
# 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):
|
||||
data["data"] = lanes + data["data"]
|
||||
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()
|
||||
@@ -37,25 +198,58 @@ async def _proxy(path: str, request: Request):
|
||||
else:
|
||||
alias = requested
|
||||
routed = {"x-mc-routed-to": requested}
|
||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
||||
|
||||
pol = load_policy()
|
||||
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():
|
||||
async with httpx.AsyncClient(timeout=None) as c:
|
||||
async with c.stream("POST", url, json=body) as r:
|
||||
try:
|
||||
async for chunk in r.aiter_raw():
|
||||
record_stream_chunk(chunk, alias)
|
||||
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)
|
||||
|
||||
async with httpx.AsyncClient(timeout=600) as c:
|
||||
r = await c.post(url, json=body)
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
resp_json = r.json()
|
||||
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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,14 +1,22 @@
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from config import VERSION
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import gateway, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""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,8 +1,7 @@
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import maintenance
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -65,6 +64,11 @@ 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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""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")
|
||||
|
||||
@@ -56,6 +58,15 @@ def dedupe(body: DedupeIn) -> dict:
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""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 config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from services import budget, discover, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
|
||||
@@ -114,24 +113,41 @@ def install(req: InstallReq) -> dict:
|
||||
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
|
||||
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
|
||||
|
||||
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
|
||||
# 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)
|
||||
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]
|
||||
for f in info["files"]:
|
||||
args.append(f)
|
||||
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)
|
||||
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"])}
|
||||
@@ -161,9 +177,18 @@ def recommend_role(role: str) -> dict:
|
||||
|
||||
@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 (body.role or "").strip().lower() == "hermes":
|
||||
if new_role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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]))
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
|
||||
+68
-21
@@ -9,13 +9,12 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import httpx
|
||||
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
||||
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
|
||||
@@ -27,8 +26,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Nur diese User-Dienste dürfen neugestartet werden.
|
||||
ALLOWED_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-webui", "mem0-service", "voice-service"}
|
||||
# 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"))
|
||||
|
||||
@@ -38,6 +35,13 @@ 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
|
||||
@@ -52,24 +56,64 @@ def _voice_reachable() -> bool:
|
||||
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 (für die Health-Anzeige)."""
|
||||
"""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": [
|
||||
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
|
||||
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
|
||||
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
||||
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
|
||||
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
||||
{"name": "Voice (STT/TTS)", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
|
||||
],
|
||||
"services": rows,
|
||||
"links": {
|
||||
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
||||
"gateway": gw_url,
|
||||
"hermes_terminal": a["terminal_url"],
|
||||
"hermes_ui": a["hermes_ui_url"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -89,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
@@ -99,9 +143,12 @@ class RestartReq(BaseModel):
|
||||
|
||||
@router.post("/system/restart")
|
||||
def restart(req: RestartReq) -> dict:
|
||||
if req.service not in ALLOWED_SERVICES:
|
||||
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.")
|
||||
return _run(["systemctl", "--user", "restart", req.service])
|
||||
"""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")
|
||||
|
||||
+127
-23
@@ -11,18 +11,26 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from services.voice_metrics import Timer, get_metrics, record_stage # Per-Stage-Latenz (C2)
|
||||
|
||||
# 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)
|
||||
@@ -90,11 +98,53 @@ class ChatIn(BaseModel):
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
|
||||
|
||||
@router.get("/voice/metrics")
|
||||
def voice_metrics() -> dict:
|
||||
"""Per-Stage-Latenz (STT/Vision/Chat-TTFB/TTS) — rollende Statistik, macht die Voice-Pipeline
|
||||
messbar (C2). Anzeige im Frontend-Overhaul (E)."""
|
||||
return get_metrics()
|
||||
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")
|
||||
@@ -105,18 +155,32 @@ def voice_health() -> dict:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
out["sidecar"] = r.status_code == 200
|
||||
out["detail"] = r.json() if r.status_code == 200 else None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@@ -129,14 +193,36 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
with Timer("stt"):
|
||||
_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."""
|
||||
@@ -159,7 +245,7 @@ def voice_get_reference() -> dict:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"active": False, "error": str(exc)}
|
||||
|
||||
|
||||
@@ -203,18 +289,31 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
t0 = time.perf_counter()
|
||||
# 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'
|
||||
with Timer("vision"):
|
||||
_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}"
|
||||
@@ -222,6 +321,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
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).
|
||||
@@ -235,19 +335,23 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
) 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)
|
||||
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0)
|
||||
trace.note_ttfb()
|
||||
first = False
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + LLM-TTFT) —
|
||||
# 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:
|
||||
record_stage("chat_first_content", (time.perf_counter() - t0) * 1000.0)
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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.")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/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()
|
||||
+30
-14
@@ -9,9 +9,15 @@ import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
|
||||
from config import HERMES_TERMINAL_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL
|
||||
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__)
|
||||
|
||||
@@ -65,7 +71,7 @@ def hermes_brain_info() -> dict:
|
||||
groups = llamaswap.list_groups()
|
||||
persist = set()
|
||||
for g in groups.values():
|
||||
if isinstance(g, dict) and g.get("persist"):
|
||||
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
|
||||
@@ -75,15 +81,16 @@ def hermes_brain_info() -> dict:
|
||||
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()
|
||||
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen?
|
||||
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.)
|
||||
# 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": (brain_gb + largest_od) <= gtt,
|
||||
"free_after_gb": round(gtt - brain_gb - 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)
|
||||
@@ -138,10 +145,16 @@ def agent_status() -> dict:
|
||||
|
||||
return {
|
||||
"gateway_url": HERMES_API_URL,
|
||||
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2.
|
||||
"terminal_url": HERMES_TERMINAL_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"),
|
||||
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"),
|
||||
# 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).
|
||||
@@ -152,6 +165,8 @@ def agent_status() -> dict:
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
||||
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||
set_ttl(model_id, 0)
|
||||
if old:
|
||||
set_ttl(old, DEFAULT_TTL)
|
||||
@@ -197,7 +212,8 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
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) — heavy/coder würden das Hirn verdrängen.")
|
||||
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}
|
||||
@@ -241,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
import services.maintenance as maintenance
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
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}
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
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}
|
||||
@@ -27,6 +27,11 @@ def _latest() -> Path | None:
|
||||
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:
|
||||
@@ -46,7 +51,7 @@ def backup_now() -> dict:
|
||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||
|
||||
latest = _latest()
|
||||
|
||||
+84
-22
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
|
||||
|
||||
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, Ein-Gruppen-
|
||||
Residenz, GTT ~124 GB):
|
||||
• Das Agent-Hirn (Rolle `hermes`) ist IMMER resident.
|
||||
• Weitere persist-Mitglieder (fast/vision) dürfen verdrängt werden, wenn ein großes
|
||||
on-demand-Modell lädt.
|
||||
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).
|
||||
|
||||
@@ -52,15 +54,45 @@ def params_of_model(model: dict) -> float:
|
||||
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 + kalibrierter KV-Anteil (bei seinem
|
||||
aktuellen ctx)."""
|
||||
"""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)
|
||||
|
||||
@@ -77,9 +109,10 @@ def params_b_for(name: str) -> float:
|
||||
|
||||
def _coresident_members(groups: dict) -> set:
|
||||
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
|
||||
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Ein Modell AUSSERHALB dieser Gruppen
|
||||
ist on-demand und verdrängt beim Laden die GANZE Gruppe — llama-swap swappt Gruppen
|
||||
(live verifiziert: heavy laden → brains-Gruppe komplett raus, heavy läuft allein)."""
|
||||
(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:
|
||||
@@ -89,14 +122,14 @@ def _coresident_members(groups: dict) -> set:
|
||||
|
||||
def reserved_gb(role: str | None) -> dict:
|
||||
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
|
||||
VERIFIZIERTEN llama-swap-Gruppen-Swap-Semantik (NICHT der früheren Annahme „Hirn bleibt
|
||||
immer"). Nur die ko-residente `swap:false`-Gruppe läuft gemeinsam; ein on-demand-Modell
|
||||
verdrängt die Gruppe und läuft ALLEIN mit dem vollen GTT.
|
||||
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/fast/scout): läuft allein (Gruppe wird beim
|
||||
Laden rausgeswappt) → reserviert NICHTS, darf den vollen GTT für Kontext nutzen.
|
||||
- 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()
|
||||
@@ -115,8 +148,9 @@ def reserved_gb(role: str | None) -> dict:
|
||||
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: verdrängt die Ko-Residenz-Gruppe → läuft allein, voller GTT für Kontext.
|
||||
return {"reserved_gb": 0.0, "mode": "ondemand-alone", "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:
|
||||
@@ -135,9 +169,37 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
|
||||
}
|
||||
|
||||
|
||||
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 (aus seiner Rolle,
|
||||
Params & Quant). Für den 'Auto'-Button an der Modellkarte."""
|
||||
return setup_aware_ctx(params_of_model(model),
|
||||
model.get("quant") or "Q4_K_M",
|
||||
role=model.get("role"))
|
||||
"""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"]}
|
||||
|
||||
+74
-72
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
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
|
||||
(Lanes `coding`/`chat`, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
|
||||
(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.
|
||||
@@ -10,18 +10,33 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
|
||||
# IDEs bekommen NUR die 'coding'-Lane zu sehen — die Lane routet intern selbst auf das
|
||||
# passende Modell (Coder/Heavy/…). Ein einziger Eintrag, kein manuelles Modell-Wählen mehr.
|
||||
IDE_MODEL = "coding"
|
||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||
PRIMARY_MODEL = "coder"
|
||||
|
||||
|
||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
||||
"prompt_cache_key": False, "chat_completions": True,
|
||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
||||
|
||||
|
||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
||||
IDE_MODELS = [
|
||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
||||
]
|
||||
|
||||
|
||||
def _gw(host: str) -> str:
|
||||
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -31,56 +46,31 @@ def build_snippets(host: str = DEFAULT_HOST,
|
||||
gw = _gw(host)
|
||||
mc_url = f"http://{host}:{PORT}"
|
||||
|
||||
cline = json.dumps({
|
||||
# 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": "coding",
|
||||
"openAiModelId": PRIMARY_MODEL,
|
||||
}, indent=2)
|
||||
|
||||
opencode = json.dumps({
|
||||
"provider": {
|
||||
"bosgame": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Bosgame Gateway",
|
||||
"options": {"baseURL": gw, "apiKey": "local"},
|
||||
"models": {IDE_MODEL: {"name": IDE_MODEL}},
|
||||
}
|
||||
}
|
||||
}, indent=2)
|
||||
|
||||
cursor = json.dumps({
|
||||
"Base URL": gw,
|
||||
"API Key": "local",
|
||||
"Active Model": "coding"
|
||||
}, indent=2)
|
||||
|
||||
zed = json.dumps({
|
||||
"language_models": {
|
||||
"openai_compatible": {
|
||||
"bosgame": {
|
||||
"api_url": gw,
|
||||
"available_models": [
|
||||
{"name": IDE_MODEL, "display_name": IDE_MODEL, "max_tokens": 131072,
|
||||
"capabilities": {"tools": True}}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
"default_model": {
|
||||
"provider": "openai_compatible",
|
||||
"model": IDE_MODEL
|
||||
}
|
||||
}
|
||||
}, indent=2)
|
||||
|
||||
cont = json.dumps({
|
||||
"models": [
|
||||
{"title": f"Bosgame / {IDE_MODEL}", "provider": "openai", "model": IDE_MODEL,
|
||||
"apiBase": gw, "apiKey": "local"}
|
||||
]
|
||||
}, 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
|
||||
@@ -90,12 +80,12 @@ def build_snippets(host: str = DEFAULT_HOST,
|
||||
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=coding, apiKey=local.\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="coding"'
|
||||
f'export ANTHROPIC_MODEL="coder"'
|
||||
)
|
||||
|
||||
memory_mcp = json.dumps({
|
||||
@@ -112,20 +102,21 @@ def build_snippets(host: str = DEFAULT_HOST,
|
||||
"host": host,
|
||||
"gateway_url": gw,
|
||||
"mc_url": mc_url,
|
||||
# Leitung 1 — das MODELL. Alle Snippets zeigen auf den OpenAI-kompatiblen Gateway.
|
||||
# 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": {
|
||||
"cline": {"label": "Roo Code / Cline", "lang": "json", "snippet": cline,
|
||||
"note": "OpenAI-Provider → Gateway. Nur Lane 'coding' — die Box routet intern selbst."},
|
||||
"cursor": {"label": "Cursor", "lang": "json", "snippet": cursor,
|
||||
"note": "Einstellungen ➔ Models ➔ OpenAI API key + Base URL."},
|
||||
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
|
||||
"note": "Datei opencode.jsonc, Key 'provider'."},
|
||||
"zed": {"label": "Zed", "lang": "json", "snippet": zed,
|
||||
"note": "settings.json → language_models.openai_compatible."},
|
||||
"continue": {"label": "Continue", "lang": "json", "snippet": cont,
|
||||
"note": "~/.continue/config.json (oder config.yaml mit identischen Keys)."},
|
||||
"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": "Braucht einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway."},
|
||||
"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,
|
||||
@@ -134,18 +125,20 @@ def build_snippets(host: str = DEFAULT_HOST,
|
||||
|
||||
|
||||
def check_health() -> dict:
|
||||
"""Live-Erreichbarkeit der beiden Leitungen, aus Sicht der Box:
|
||||
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0)."""
|
||||
"""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"{LLAMA_SWAP_URL}/v1/models")
|
||||
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: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
@@ -154,7 +147,16 @@ def check_health() -> dict:
|
||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory}
|
||||
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}
|
||||
|
||||
@@ -9,16 +9,15 @@ 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
|
||||
|
||||
import logging
|
||||
|
||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||
|
||||
from services import catalog
|
||||
from services.caps import capabilities
|
||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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,10 +1,12 @@
|
||||
"""
|
||||
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
|
||||
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
|
||||
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
||||
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).
|
||||
"""
|
||||
|
||||
from config import PORT
|
||||
import httpx
|
||||
from config import PORT, V1_UPSTREAM
|
||||
|
||||
from services.llamaswap import engine_reachable
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
|
||||
p = load_policy()
|
||||
coding_default = p["coder_lite"] or p["coder"]
|
||||
return {
|
||||
"mode": "builtin",
|
||||
"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": [
|
||||
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
|
||||
|
||||
|
||||
def gateway_reachable() -> bool:
|
||||
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
|
||||
"""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()
|
||||
|
||||
@@ -7,11 +7,46 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
|
||||
|
||||
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)."""
|
||||
@@ -23,9 +58,11 @@ def record_usage(usage: dict | None, model: str) -> None:
|
||||
increment_tokens(prompt, completion, model=model)
|
||||
|
||||
|
||||
def record_stream_chunk(chunk: bytes, model: str) -> None:
|
||||
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")
|
||||
|
||||
@@ -49,6 +49,14 @@ class _Reader:
|
||||
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."""
|
||||
@@ -159,3 +167,100 @@ def compatible(target_path: str, draft_path: str) -> bool | None:
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
||||
log_str = "\n".join(job["log"])
|
||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||
job["sudo_failed"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["returncode"] = -1
|
||||
@@ -101,7 +101,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
||||
if cb and job["state"] == "done":
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
j["rate_bps"] = rate
|
||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||
prev_t, prev_b = now, cur
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
j = JOBS.get(job_id)
|
||||
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
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)
|
||||
@@ -11,19 +11,24 @@ import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from config import (
|
||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
||||
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"}
|
||||
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]+)")
|
||||
@@ -143,19 +148,36 @@ def model_id_from_path(model_path: str) -> str:
|
||||
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
||||
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
||||
d = os.path.basename(os.path.dirname(model_path))
|
||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
|
||||
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.I).strip("-_")
|
||||
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.I)
|
||||
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.I)
|
||||
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 eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
|
||||
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
|
||||
"""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:
|
||||
@@ -169,8 +191,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
||||
spec.pop("aliases", None)
|
||||
spec = models.get(model_id)
|
||||
if isinstance(spec, dict):
|
||||
if role and role != model_id.lower():
|
||||
spec["aliases"] = [role]
|
||||
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)
|
||||
|
||||
@@ -197,6 +222,13 @@ def write_config(cfg: dict) -> None:
|
||||
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. "
|
||||
@@ -207,9 +239,12 @@ def write_config(cfg: dict) -> None:
|
||||
|
||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
ttl: int | None = None, mmproj_path: str | None = None,
|
||||
jinja: bool = False) -> str:
|
||||
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)."""
|
||||
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))
|
||||
@@ -235,10 +270,18 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
||||
cmd += spec_draft_flags(model_path)
|
||||
|
||||
cfg.setdefault("models", {})[model_id] = {
|
||||
# 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
|
||||
@@ -392,10 +435,27 @@ def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
|
||||
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 automatisch entladen."""
|
||||
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, "members": list(members)}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+266
-33
@@ -19,7 +19,8 @@ from services import catalog, discover, jobengine, llamaswap, system
|
||||
|
||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||
SYSTEM_SERVICES = {"llama-swap"}
|
||||
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-terminal", "mem0-service", "voice-service"}
|
||||
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__), "..", ".."))
|
||||
@@ -30,6 +31,24 @@ 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(
|
||||
@@ -86,14 +105,13 @@ def _engine_update_available() -> bool:
|
||||
return _engine_cache["avail"]
|
||||
avail = False
|
||||
try:
|
||||
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest",
|
||||
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
||||
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
|
||||
else: # Fallback: Release-Datum vs. Engine-mtime
|
||||
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:
|
||||
@@ -271,7 +289,7 @@ def model_upgrades() -> list[dict]:
|
||||
continue
|
||||
|
||||
base = rec.split("/")[-1].lower()
|
||||
stem = base[:-5] if base.endswith("-gguf") else base
|
||||
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})
|
||||
@@ -298,8 +316,42 @@ def updates() -> dict:
|
||||
|
||||
# ── 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)."""
|
||||
"""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
|
||||
@@ -314,8 +366,9 @@ def os_update_details() -> dict:
|
||||
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}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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)}
|
||||
|
||||
|
||||
@@ -325,8 +378,7 @@ def engine_update_details() -> dict:
|
||||
"latest_build": None, "latest_tag": None, "name": None,
|
||||
"url": None, "body": None}
|
||||
try:
|
||||
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest",
|
||||
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
|
||||
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
|
||||
@@ -334,7 +386,14 @@ def engine_update_details() -> dict:
|
||||
info["url"] = rel.get("html_url")
|
||||
body = (rel.get("body") or "").strip()
|
||||
info["body"] = body[:2000] if body else None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 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
|
||||
|
||||
@@ -354,13 +413,104 @@ def swap_update_details() -> dict:
|
||||
info["url"] = rel.get("html_url")
|
||||
body = (rel.get("body") or "").strip()
|
||||
info["body"] = body[:2000] if body else None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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>)."""
|
||||
"""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"):
|
||||
@@ -382,7 +532,9 @@ def hermes_update_details() -> dict:
|
||||
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
||||
info["commits"] = commits
|
||||
info["behind"] = len(commits)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if commits:
|
||||
info.update(_summarize_hermes_commits(commits))
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -423,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||
|
||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
@@ -490,7 +642,10 @@ def os_update_job(sudo_password: str | None = None) -> dict:
|
||||
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).
|
||||
cmd = ("sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y "
|
||||
# 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)
|
||||
@@ -502,8 +657,9 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
return err
|
||||
# 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
|
||||
@@ -513,7 +669,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
# 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, sudo_password=sudo_password)
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
@@ -522,8 +678,7 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
return err
|
||||
# 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
|
||||
@@ -533,35 +688,113 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
# 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, sudo_password=sudo_password)
|
||||
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
|
||||
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 → update → Gateway-Neustart → Gehirn-Check (Job wird rot, wenn Mem0/Plugin kaputt).
|
||||
cmd = (f"bash {backup} || true; "
|
||||
f"cd {path} && {py} -m hermes_cli.main update --yes "
|
||||
f"&& systemctl --user restart hermes-gateway "
|
||||
f"&& sleep 4 && bash {postcheck}")
|
||||
|
||||
def on_done():
|
||||
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
|
||||
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update",
|
||||
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
|
||||
|
||||
@@ -12,13 +12,17 @@ UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
||||
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
|
||||
@@ -145,3 +149,28 @@ def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
||||
if delete_memory(d["id"]):
|
||||
removed += 1
|
||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
||||
|
||||
|
||||
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
|
||||
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
|
||||
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
|
||||
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
|
||||
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
|
||||
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
|
||||
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
|
||||
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
|
||||
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
|
||||
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
|
||||
|
||||
|
||||
async def auto_dedupe_loop() -> None:
|
||||
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
|
||||
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
|
||||
while True:
|
||||
try:
|
||||
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
|
||||
if res.get("removed"):
|
||||
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
|
||||
except Exception:
|
||||
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
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)
|
||||
@@ -50,9 +50,41 @@ _CODE_HINT = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# 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 []
|
||||
return "\n".join(str(m.get("content") or "") for m in msgs)
|
||||
# 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]:
|
||||
|
||||
@@ -29,7 +29,10 @@ 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", "").strip(),
|
||||
"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"),
|
||||
@@ -41,6 +44,7 @@ FIELDS: list[dict] = [
|
||||
{"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"},
|
||||
@@ -77,7 +81,7 @@ def _coerce(patch: dict) -> dict:
|
||||
out[k] = bool(v)
|
||||
else: # str
|
||||
sv = str(v).strip()
|
||||
if k != "coder_lite" and not sv:
|
||||
if k not in ("coder_lite", "vision") and not sv:
|
||||
raise ValueError(f"{k} darf nicht leer sein")
|
||||
out[k] = sv
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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)
|
||||
@@ -10,7 +10,7 @@ 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"]
|
||||
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".
|
||||
|
||||
@@ -9,9 +9,9 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
import psutil
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ def find_hermes_agent_git() -> dict | None:
|
||||
candidates = [
|
||||
"~/hermes-agent",
|
||||
"~/.hermes/hermes-agent",
|
||||
"~/hermes-webui/hermes-agent",
|
||||
"~/.hermes"
|
||||
]
|
||||
for c in candidates:
|
||||
@@ -157,6 +156,7 @@ def get_engine_version() -> dict:
|
||||
|
||||
|
||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||
_VERSION_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def check_versions_cached() -> dict:
|
||||
@@ -165,12 +165,18 @@ def check_versions_cached() -> dict:
|
||||
if now - _VERSION_CACHE["ts"] < 30.0:
|
||||
return _VERSION_CACHE["data"]
|
||||
|
||||
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
# 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_ui": get_git_info("~/hermes-webui"),
|
||||
"hermes_agent": find_hermes_agent_git()
|
||||
}
|
||||
_VERSION_CACHE["ts"] = now
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import HERMES_HOME
|
||||
|
||||
@@ -26,9 +25,22 @@ _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:
|
||||
@@ -51,19 +63,31 @@ def _ensure_loaded() -> dict:
|
||||
|
||||
|
||||
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."""
|
||||
"""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()))
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
"""
|
||||
Per-Stage-Latenz-Metriken für die Voice/Lucy-Pipeline.
|
||||
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
|
||||
|
||||
Misst die Server-seitige Dauer jeder Stufe (STT, Vision-Beschreibung, Chat-TTFB, TTS) und hält
|
||||
rollende Statistiken (avg/p50/p95/last) im Speicher. Macht aus Latenz-VERMUTUNGEN gemessene Fakten
|
||||
— die eigentliche Voraussetzung, um gezielt zu optimieren (Stufe 5/C2 des Reviews). Anzeige im
|
||||
Frontend-Overhaul (E) analog zur TokenPerformanceCard.
|
||||
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", "chat_ttfb", "tts")
|
||||
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:
|
||||
@@ -32,6 +52,36 @@ def record_stage(stage: str, ms: float) -> None:
|
||||
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)."""
|
||||
@@ -48,6 +98,68 @@ class Timer:
|
||||
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}
|
||||
@@ -66,3 +178,10 @@ 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]
|
||||
|
||||
+105
-42
@@ -1,22 +1,29 @@
|
||||
"""
|
||||
Hält das Agent-Hirn (Rolle `hermes`) dauerhaft warm.
|
||||
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 (inkl. Hirn) verdrängt. `persist: true`
|
||||
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst
|
||||
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle
|
||||
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält
|
||||
er sich raus, verdrängt also nie ein gerade genutztes Modell.
|
||||
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.
|
||||
|
||||
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL.
|
||||
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__)
|
||||
@@ -24,54 +31,110 @@ 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 _brain_model() -> str:
|
||||
"""Aktives Agent-Hirn = Hermes' model.default (sonst model.model). So wärmt der Wächter
|
||||
immer das WIRKLICH genutzte Hirn (passt sich Hirn-Wechseln an). Override: MC_REWARM_MODEL."""
|
||||
forced = os.environ.get("MC_REWARM_MODEL")
|
||||
if forced:
|
||||
return forced
|
||||
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:
|
||||
from ruamel.yaml import YAML
|
||||
from config import HERMES_HOME
|
||||
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 {}
|
||||
v = m.get("default") or m.get("model")
|
||||
if v:
|
||||
return str(v)
|
||||
_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: Hirn-Lookup fehlgeschlagen", exc_info=True)
|
||||
return "fast"
|
||||
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def _running_empty() -> bool:
|
||||
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 {}
|
||||
return not (data.get("running") or [])
|
||||
|
||||
|
||||
async def _warm(model: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=180.0) as c:
|
||||
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": model, "max_tokens": 1,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
})
|
||||
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): prüft periodisch, wärmt bei Idle vor."""
|
||||
"""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:
|
||||
if await _running_empty():
|
||||
model = _brain_model()
|
||||
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", model)
|
||||
await _warm(model)
|
||||
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)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
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())
|
||||
@@ -51,6 +51,10 @@ async def run_shell(req: dict):
|
||||
["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(),
|
||||
@@ -108,13 +112,49 @@ async def press_keys(req: dict):
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── Medien & Lautstärke (A3: Lucy steuert den PC per Sprache) ──────────────
|
||||
# Media-Keys wirken auf den AKTIVEN Player (Spotify, YouTube, VLC …) — genau wie
|
||||
# die Tasten auf einer Multimedia-Tastatur. Lautstärke = System-Master (2 % je Schritt).
|
||||
MEDIA_KEYS = {"play_pause": "playpause", "next": "nexttrack", "prev": "prevtrack", "stop": "stop"}
|
||||
VOLUME_KEYS = {"up": "volumeup", "down": "volumedown", "mute": "volumemute"}
|
||||
|
||||
|
||||
@app.post("/media", dependencies=[Depends(require_auth)])
|
||||
async def media_control(req: dict):
|
||||
action = (req.get("action") or "").strip().lower()
|
||||
key = MEDIA_KEYS.get(action)
|
||||
if not key:
|
||||
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(MEDIA_KEYS)}")
|
||||
try:
|
||||
import pyautogui
|
||||
pyautogui.press(key)
|
||||
return {"ok": True, "action": action}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/volume", dependencies=[Depends(require_auth)])
|
||||
async def volume_control(req: dict):
|
||||
action = (req.get("action") or "").strip().lower()
|
||||
key = VOLUME_KEYS.get(action)
|
||||
if not key:
|
||||
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(VOLUME_KEYS)}")
|
||||
steps = 1 if action == "mute" else max(1, min(25, int(req.get("steps", 5))))
|
||||
try:
|
||||
import pyautogui
|
||||
pyautogui.press(key, presses=steps, interval=0.02)
|
||||
return {"ok": True, "action": action, "steps": steps}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── Apps / Browser ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/open", dependencies=[Depends(require_auth)])
|
||||
async def open_target(req: dict):
|
||||
target = req.get("target", "")
|
||||
try:
|
||||
if target.startswith("http://") or target.startswith("https://"):
|
||||
if target.startswith(("http://", "https://")):
|
||||
webbrowser.open(target)
|
||||
else:
|
||||
os.startfile(target)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Spricht mit dem Hermes Agent Daemon auf der AI Box."""
|
||||
import httpx
|
||||
|
||||
TIMEOUT = 120
|
||||
|
||||
|
||||
def ask_agent(text: str, screenshot_b64: str | None, settings: dict) -> tuple[str, list]:
|
||||
"""Schickt Nachricht an den Daemon, gibt (response, tool_calls) zurück."""
|
||||
daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765")
|
||||
try:
|
||||
with httpx.Client(timeout=TIMEOUT) as c:
|
||||
resp = c.post(f"{daemon_url}/chat", json={
|
||||
"message": text,
|
||||
"screenshot": screenshot_b64,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("response", ""), data.get("tool_calls", [])
|
||||
except httpx.TimeoutException:
|
||||
return "Antwort hat zu lange gedauert.", []
|
||||
except Exception as e:
|
||||
return f"Agent nicht erreichbar: {e}", []
|
||||
|
||||
|
||||
def get_agent_status(settings: dict) -> dict:
|
||||
daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765")
|
||||
try:
|
||||
with httpx.Client(timeout=5) as c:
|
||||
return c.get(f"{daemon_url}/status").json()
|
||||
except Exception:
|
||||
return {"alive": False}
|
||||
@@ -1,47 +0,0 @@
|
||||
import httpx
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"Du bist Hermes, ein hilfreicher KI-Assistent. "
|
||||
"Antworte kurz und präzise, da deine Antwort vorgelesen wird. "
|
||||
"Maximal 3-4 Sätze, kein Markdown."
|
||||
)
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
def ask(text: str, screenshot_b64: str | None, settings: dict) -> str:
|
||||
"""Schickt Text (+ optionalen Screenshot) an MC2 und gibt die Antwort zurück."""
|
||||
use_vision = screenshot_b64 is not None
|
||||
model = settings.get("vision_model", "Qwen3-VL-2B-Instruct") if use_vision \
|
||||
else settings.get("chat_model", "Hermes-4-14B")
|
||||
base_url = settings.get("mc2_url", "http://192.168.178.151:9001/v1")
|
||||
max_tokens = int(settings.get("max_response_tokens", 200))
|
||||
|
||||
if use_vision:
|
||||
user_content = [
|
||||
{"type": "text", "text": text},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:image/jpeg;base64,{screenshot_b64}"
|
||||
}},
|
||||
]
|
||||
else:
|
||||
user_content = text
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
|
||||
response = client.post(f"{base_url}/chat/completions", json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"].strip()
|
||||
except httpx.TimeoutException:
|
||||
return "Entschuldigung, die Antwort hat zu lange gedauert."
|
||||
except Exception as e:
|
||||
return f"Verbindungsfehler: {e}"
|
||||
@@ -1,91 +0,0 @@
|
||||
"""
|
||||
Audio-Input: Aufnahme + Whisper STT.
|
||||
Kein Wake-Word-Loop — Aufnahme startet direkt auf Hotkey-Signal.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import scipy.io.wavfile as wav
|
||||
from faster_whisper import WhisperModel
|
||||
from pathlib import Path
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK = 1024
|
||||
ENERGY_THRESHOLD = 0.008
|
||||
|
||||
_model: WhisperModel | None = None
|
||||
_model_size: str = ""
|
||||
_recording = False
|
||||
_frames: list[np.ndarray] = []
|
||||
_stream: sd.InputStream | None = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def load_model(model_size: str):
|
||||
global _model, _model_size
|
||||
if _model is None or _model_size != model_size:
|
||||
model_dir = Path.home() / ".hermes-voice" / "whisper-models"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
_model = WhisperModel(
|
||||
model_size,
|
||||
device="cpu",
|
||||
compute_type="int8",
|
||||
download_root=str(model_dir),
|
||||
)
|
||||
_model_size = model_size
|
||||
|
||||
|
||||
def start_recording():
|
||||
global _recording, _frames, _stream
|
||||
with _lock:
|
||||
_recording = True
|
||||
_frames = []
|
||||
|
||||
def callback(indata, frames, time, status):
|
||||
if _recording:
|
||||
_frames.append(indata[:, 0].copy())
|
||||
|
||||
_stream = sd.InputStream(
|
||||
samplerate=SAMPLE_RATE,
|
||||
channels=1,
|
||||
dtype="float32",
|
||||
blocksize=CHUNK,
|
||||
callback=callback,
|
||||
)
|
||||
_stream.start()
|
||||
|
||||
|
||||
def stop_recording() -> np.ndarray:
|
||||
global _recording, _stream
|
||||
with _lock:
|
||||
_recording = False
|
||||
if _stream:
|
||||
_stream.stop()
|
||||
_stream.close()
|
||||
_stream = None
|
||||
if not _frames:
|
||||
return np.array([], dtype=np.float32)
|
||||
return np.concatenate(_frames)
|
||||
|
||||
|
||||
def transcribe(audio: np.ndarray, model_size: str, language: str) -> str:
|
||||
if len(audio) < SAMPLE_RATE * 0.3:
|
||||
return ""
|
||||
load_model(model_size)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
tmp = f.name
|
||||
try:
|
||||
wav.write(tmp, SAMPLE_RATE, (audio * 32767).astype(np.int16))
|
||||
lang = language if language != "auto" else None
|
||||
segments, _ = _model.transcribe(
|
||||
tmp,
|
||||
language=lang,
|
||||
beam_size=3,
|
||||
vad_filter=True,
|
||||
)
|
||||
return " ".join(s.text for s in segments).strip()
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
@@ -1,70 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import pygame
|
||||
import edge_tts
|
||||
|
||||
pygame.mixer.init()
|
||||
_stop_event = threading.Event()
|
||||
_speak_lock = threading.Lock()
|
||||
|
||||
|
||||
def stop_speaking():
|
||||
_stop_event.set()
|
||||
pygame.mixer.music.stop()
|
||||
|
||||
|
||||
def speak(text: str, voice: str = "de-DE-SeraphinaMultilingualNeural", rate: str = "+0%"):
|
||||
"""Text → Edge TTS → Lautsprecher. Blockiert bis fertig oder unterbrochen."""
|
||||
with _speak_lock:
|
||||
_stop_event.clear()
|
||||
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
# Edge TTS in eigenem Event-Loop (Thread-sicher)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
||||
loop.run_until_complete(communicate.save(tmp_path))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
if _stop_event.is_set():
|
||||
return
|
||||
|
||||
pygame.mixer.music.load(tmp_path)
|
||||
pygame.mixer.music.play()
|
||||
|
||||
# Polling ohne pygame.time.wait (blockiert Event-Loop nicht)
|
||||
while pygame.mixer.music.get_busy():
|
||||
if _stop_event.is_set():
|
||||
pygame.mixer.music.stop()
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def play_ding():
|
||||
"""Kurzer Aktivierungs-Ton (440 Hz, 120ms)."""
|
||||
import numpy as np
|
||||
sample_rate = 44100
|
||||
duration = 0.12
|
||||
t = np.linspace(0, duration, int(sample_rate * duration), False)
|
||||
wave = (np.sin(2 * np.pi * 440 * t) * 0.3 * 32767).astype(np.int16)
|
||||
stereo = np.column_stack([wave, wave])
|
||||
sound = pygame.sndarray.make_sound(stereo)
|
||||
sound.play()
|
||||
time.sleep(duration + 0.02)
|
||||
@@ -1,46 +0,0 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ========================================
|
||||
echo Hermes Voice Client -- Build (.exe)
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Venv pruefen
|
||||
if not exist ".venv\Scripts\activate.bat" (
|
||||
echo [FEHLER] Bitte erst setup.bat ausfuehren.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: PyInstaller installieren falls noetig
|
||||
.venv\Scripts\pip install pyinstaller -q
|
||||
|
||||
echo [1/2] Baue HermesVoice.exe ...
|
||||
.venv\Scripts\pyinstaller ^
|
||||
--onefile ^
|
||||
--windowed ^
|
||||
--name HermesVoice ^
|
||||
--add-data "assets;assets" ^
|
||||
--hidden-import "customtkinter" ^
|
||||
--hidden-import "pynput.keyboard._win32" ^
|
||||
--hidden-import "pynput.mouse._win32" ^
|
||||
--collect-all "customtkinter" ^
|
||||
main.py
|
||||
|
||||
echo.
|
||||
if exist "dist\HermesVoice.exe" (
|
||||
echo [2/2] Fertig!
|
||||
echo.
|
||||
echo dist\HermesVoice.exe ^(%.0f MB^)
|
||||
echo.
|
||||
echo Die .exe benoetigt beim ersten Start Internet fuer:
|
||||
echo - Whisper-Modell-Download ^(~150MB fuer "small"^)
|
||||
echo - Edge TTS ^(online^)
|
||||
echo.
|
||||
for %%I in ("dist\HermesVoice.exe") do echo Groesse: %%~zI Bytes
|
||||
) else (
|
||||
echo [FEHLER] Build fehlgeschlagen. Siehe build-Log oben.
|
||||
)
|
||||
echo ========================================
|
||||
pause
|
||||
@@ -1,62 +0,0 @@
|
||||
# Hermes Voice Client — Konfiguration
|
||||
# Alle Einstellungen hier anpassen, kein Code-Edit nötig.
|
||||
|
||||
# ── AI-Box ──────────────────────────────────────────────────────────────
|
||||
MC2_BASE_URL = "http://192.168.178.151:9001/v1"
|
||||
|
||||
# Modell für reine Text-Anfragen (kein Screenshot)
|
||||
CHAT_MODEL = "Hermes-4-14B"
|
||||
|
||||
# Modell wenn ein Screenshot mitgeschickt wird
|
||||
VISION_MODEL = "Qwen3-VL-2B-Instruct"
|
||||
|
||||
# ── Wake Word ────────────────────────────────────────────────────────────
|
||||
# Einfach den gewünschten Trigger-Text hier eintragen — kein Account, kein Download.
|
||||
# Whisper transkribiert Sprache und prüft ob eines der Wörter enthalten ist.
|
||||
# Mehrere Varianten möglich: ["hey hermes", "hermes", "hey jarvis"]
|
||||
WAKE_WORDS = ["hey hermes", "hermes"]
|
||||
|
||||
# Whisper-Modell für die schnelle Wake-Detection (tiny = 39MB, sehr schnell)
|
||||
WAKE_WHISPER_MODEL = "tiny"
|
||||
|
||||
# ── Spracheingabe ────────────────────────────────────────────────────────
|
||||
# faster-whisper Modellgröße: "tiny", "base", "small", "medium"
|
||||
# "small" läuft gut auf CPU (~244 MB), "base" ist schneller aber ungenauer
|
||||
WHISPER_MODEL_SIZE = "small"
|
||||
WHISPER_LANGUAGE = "de" # "de" für Deutsch, "en" für Englisch, None = auto
|
||||
|
||||
# Sekunden Stille bis Aufnahme endet
|
||||
SILENCE_TIMEOUT = 1.5
|
||||
# Maximale Aufnahmedauer in Sekunden (Sicherheitsnetz)
|
||||
MAX_RECORD_SECONDS = 20
|
||||
|
||||
# ── Sprachausgabe ────────────────────────────────────────────────────────
|
||||
# Edge TTS Stimmen: https://speech.microsoft.com/portal/voicegallery
|
||||
# Deutsch: "de-DE-KillianNeural", "de-DE-SeraphinaMultilingualNeural"
|
||||
# Englisch: "en-US-AndrewNeural", "en-US-AriaNeural"
|
||||
TTS_VOICE = "de-DE-KillianNeural"
|
||||
TTS_RATE = "+0%" # Geschwindigkeit: "+10%" schneller, "-10%" langsamer
|
||||
TTS_PITCH = "+0Hz" # Tonhöhe
|
||||
|
||||
# ── Screen Capture ───────────────────────────────────────────────────────
|
||||
# Screenshot bei jeder Anfrage mitschicken?
|
||||
SCREENSHOT_ON_QUERY = True
|
||||
# Monitor-Index (0 = alle, 1 = primär, 2 = zweiter Monitor)
|
||||
SCREENSHOT_MONITOR = 1
|
||||
# Auflösung für Screenshot (kleinere = schnellere Übertragung)
|
||||
SCREENSHOT_WIDTH = 1280
|
||||
SCREENSHOT_HEIGHT = 720
|
||||
|
||||
# ── Agent-Verhalten ──────────────────────────────────────────────────────
|
||||
MAX_RESPONSE_TOKENS = 200 # kurze gesprochene Antworten
|
||||
REQUEST_TIMEOUT = 30 # Sekunden bis Timeout
|
||||
|
||||
SYSTEM_PROMPT = """Du bist Hermes, ein KI-Assistent der direkt in den lokalen AI-Homelab integriert ist.
|
||||
Du hörst die Stimme des Nutzers und siehst seinen Bildschirm.
|
||||
|
||||
Regeln für Antworten:
|
||||
- Kurz und präzise (2–4 Sätze maximum)
|
||||
- Kein Markdown, keine Aufzählungen, keine Codeblöcke — du wirst gesprochen
|
||||
- Wenn du einen offensichtlichen Fehler auf dem Bildschirm siehst, weise kurz darauf hin
|
||||
- Antworte auf Deutsch wenn der Nutzer Deutsch spricht, auf Englisch wenn Englisch
|
||||
- Sei direkt und hilfreich, kein unnötiges Smalltalk"""
|
||||
@@ -1,39 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path.home() / ".hermes-voice"
|
||||
SETTINGS_FILE = CONFIG_DIR / "settings.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"mc2_url": "http://192.168.178.151:9001/v1",
|
||||
"chat_model": "Hermes-4-14B",
|
||||
"vision_model": "Qwen3-VL-2B-Instruct",
|
||||
"hotkey": "ctrl_r",
|
||||
"tts_voice": "de-DE-SeraphinaMultilingualNeural",
|
||||
"tts_rate": "+0%",
|
||||
"whisper_model": "small",
|
||||
"language": "de",
|
||||
"screenshot_enabled": True,
|
||||
"screenshot_monitor": 1,
|
||||
"max_response_tokens": 200,
|
||||
"silence_timeout": 1.5,
|
||||
}
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
CONFIG_DIR.mkdir(exist_ok=True)
|
||||
if SETTINGS_FILE.exists():
|
||||
try:
|
||||
saved = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
return {**DEFAULTS, **saved}
|
||||
except Exception:
|
||||
pass
|
||||
return dict(DEFAULTS)
|
||||
|
||||
|
||||
def save(settings: dict):
|
||||
CONFIG_DIR.mkdir(exist_ok=True)
|
||||
SETTINGS_FILE.write_text(
|
||||
json.dumps(settings, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
"""
|
||||
Globaler Push-to-Talk Hotkey via pynput.
|
||||
Funktioniert auch wenn das Fenster minimiert/im Hintergrund ist.
|
||||
"""
|
||||
import threading
|
||||
from pynput import keyboard
|
||||
|
||||
# Mapping: config-String → pynput Key/KeyCode
|
||||
_SPECIAL = {
|
||||
"ctrl_r": keyboard.Key.ctrl_r,
|
||||
"ctrl_l": keyboard.Key.ctrl_l,
|
||||
"alt_r": keyboard.Key.alt_r,
|
||||
"alt_l": keyboard.Key.alt_l,
|
||||
"shift_r": keyboard.Key.shift_r,
|
||||
"shift_l": keyboard.Key.shift_l,
|
||||
"f13": keyboard.Key.f13,
|
||||
"f14": keyboard.Key.f14,
|
||||
"f15": keyboard.Key.f15,
|
||||
"f16": keyboard.Key.f16,
|
||||
"caps_lock": keyboard.Key.caps_lock,
|
||||
"scroll_lock": keyboard.Key.scroll_lock,
|
||||
"pause": keyboard.Key.pause,
|
||||
}
|
||||
|
||||
DISPLAY_NAMES = {
|
||||
"ctrl_r": "Rechte Strg",
|
||||
"ctrl_l": "Linke Strg",
|
||||
"alt_r": "Rechte Alt",
|
||||
"alt_l": "Linke Alt",
|
||||
"shift_r": "Rechte Shift",
|
||||
"shift_l": "Linke Shift",
|
||||
"f13": "F13", "f14": "F14", "f15": "F15", "f16": "F16",
|
||||
"caps_lock": "Caps Lock",
|
||||
"scroll_lock": "Scroll Lock",
|
||||
"pause": "Pause",
|
||||
}
|
||||
|
||||
|
||||
def key_to_str(key) -> str:
|
||||
"""Konvertiert pynput Key → config-String."""
|
||||
for name, k in _SPECIAL.items():
|
||||
if key == k:
|
||||
return name
|
||||
if hasattr(key, "char") and key.char:
|
||||
return key.char.lower()
|
||||
return str(key).replace("Key.", "")
|
||||
|
||||
|
||||
def str_to_display(key_str: str) -> str:
|
||||
return DISPLAY_NAMES.get(key_str, key_str.upper())
|
||||
|
||||
|
||||
class HotkeyListener:
|
||||
def __init__(self, key_str: str, on_press_cb, on_release_cb):
|
||||
self._key_str = key_str
|
||||
self._on_press = on_press_cb
|
||||
self._on_release = on_release_cb
|
||||
self._pressed = False
|
||||
self._listener: keyboard.Listener | None = None
|
||||
|
||||
def _target_key(self):
|
||||
return _SPECIAL.get(self._key_str) or keyboard.KeyCode.from_char(self._key_str)
|
||||
|
||||
def _on_press_raw(self, key):
|
||||
if not self._pressed and key == self._target_key():
|
||||
self._pressed = True
|
||||
self._on_press()
|
||||
|
||||
def _on_release_raw(self, key):
|
||||
if self._pressed and key == self._target_key():
|
||||
self._pressed = False
|
||||
self._on_release()
|
||||
|
||||
def start(self):
|
||||
self._listener = keyboard.Listener(
|
||||
on_press=self._on_press_raw,
|
||||
on_release=self._on_release_raw,
|
||||
)
|
||||
self._listener.start()
|
||||
|
||||
def stop(self):
|
||||
if self._listener:
|
||||
self._listener.stop()
|
||||
|
||||
def update_key(self, key_str: str):
|
||||
self._key_str = key_str
|
||||
self._pressed = False
|
||||
|
||||
|
||||
class KeyCapturer:
|
||||
"""Einmalig den nächsten Tastendruck abfangen für Hotkey-Konfiguration."""
|
||||
|
||||
def __init__(self, callback):
|
||||
self._callback = callback
|
||||
self._listener: keyboard.Listener | None = None
|
||||
|
||||
def start(self):
|
||||
def on_press(key):
|
||||
key_str = key_to_str(key)
|
||||
self._listener.stop()
|
||||
self._callback(key_str)
|
||||
self._listener = keyboard.Listener(on_press=on_press)
|
||||
self._listener.start()
|
||||
@@ -1,325 +0,0 @@
|
||||
"""
|
||||
Hermes Voice Client — GUI
|
||||
Push-to-Talk: Hotkey halten → sprechen → loslassen → Hermes antwortet
|
||||
"""
|
||||
import queue
|
||||
import threading
|
||||
import sys
|
||||
import customtkinter as ctk
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import config_manager as cfg
|
||||
from audio_input import start_recording, stop_recording, transcribe
|
||||
from audio_output import speak, stop_speaking, play_ding
|
||||
from screen_capture import capture_screen
|
||||
from agent_client import ask_agent, get_agent_status
|
||||
from hotkey_listener import HotkeyListener, KeyCapturer, str_to_display
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
# ── UI-Event-Queue (thread-safe) ─────────────────────────────────────────
|
||||
_ui_queue: queue.Queue = queue.Queue()
|
||||
|
||||
def ui_event(event: str, data=None):
|
||||
_ui_queue.put((event, data))
|
||||
|
||||
|
||||
# ── Hauptfenster ─────────────────────────────────────────────────────────
|
||||
class HermesApp(ctk.CTk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = cfg.load()
|
||||
self._hotkey: HotkeyListener | None = None
|
||||
self._worker: threading.Thread | None = None
|
||||
|
||||
self.title("Hermes Voice")
|
||||
self.geometry("400x520")
|
||||
self.resizable(False, False)
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
self._build_ui()
|
||||
self._start_hotkey()
|
||||
self.after(100, self._poll_queue)
|
||||
|
||||
# ── Layout ───────────────────────────────────────────────────────────
|
||||
def _build_ui(self):
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(2, weight=1)
|
||||
|
||||
# Header
|
||||
header = ctk.CTkFrame(self, height=50, corner_radius=0)
|
||||
header.grid(row=0, column=0, sticky="ew")
|
||||
header.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkLabel(header, text="⚡ Hermes Voice",
|
||||
font=ctk.CTkFont(size=16, weight="bold")).grid(
|
||||
row=0, column=0, padx=16, pady=12, sticky="w")
|
||||
ctk.CTkButton(header, text="⚙", width=36, height=36,
|
||||
command=self._open_settings).grid(
|
||||
row=0, column=1, padx=8, pady=8)
|
||||
|
||||
# Status
|
||||
status_frame = ctk.CTkFrame(self, corner_radius=12)
|
||||
status_frame.grid(row=1, column=0, padx=16, pady=(16, 8), sticky="ew")
|
||||
status_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._status_dot = ctk.CTkLabel(status_frame, text="●",
|
||||
font=ctk.CTkFont(size=32),
|
||||
text_color="#22c55e")
|
||||
self._status_dot.grid(row=0, column=0, pady=(12, 4))
|
||||
|
||||
self._status_label = ctk.CTkLabel(status_frame, text="Bereit",
|
||||
font=ctk.CTkFont(size=14))
|
||||
self._status_label.grid(row=1, column=0, pady=(0, 4))
|
||||
|
||||
self._hotkey_label = ctk.CTkLabel(
|
||||
status_frame,
|
||||
text=f"[ {str_to_display(self.settings['hotkey'])} ] gedrückt halten",
|
||||
font=ctk.CTkFont(size=11),
|
||||
text_color="gray",
|
||||
)
|
||||
self._hotkey_label.grid(row=2, column=0, pady=(0, 12))
|
||||
|
||||
# Conversation log
|
||||
log_frame = ctk.CTkFrame(self, corner_radius=12)
|
||||
log_frame.grid(row=2, column=0, padx=16, pady=8, sticky="nsew")
|
||||
log_frame.grid_columnconfigure(0, weight=1)
|
||||
log_frame.grid_rowconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(log_frame, text="Letzte Konversation",
|
||||
font=ctk.CTkFont(size=11), text_color="gray").grid(
|
||||
row=0, column=0, padx=12, pady=(8, 0), sticky="w")
|
||||
|
||||
self._log = ctk.CTkTextbox(log_frame, font=ctk.CTkFont(size=12),
|
||||
state="disabled", wrap="word")
|
||||
self._log.grid(row=1, column=0, padx=8, pady=(4, 8), sticky="nsew")
|
||||
|
||||
# Footer
|
||||
footer = ctk.CTkFrame(self, height=40, corner_radius=0)
|
||||
footer.grid(row=3, column=0, sticky="ew")
|
||||
footer.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._screenshot_var = ctk.BooleanVar(
|
||||
value=self.settings["screenshot_enabled"])
|
||||
ctk.CTkCheckBox(footer, text="Screenshot mitsenden",
|
||||
variable=self._screenshot_var,
|
||||
command=self._toggle_screenshot).grid(
|
||||
row=0, column=0, padx=16, pady=8, sticky="w")
|
||||
|
||||
ctk.CTkButton(footer, text="Stop", width=60, height=28,
|
||||
fg_color="#dc2626", hover_color="#b91c1c",
|
||||
command=stop_speaking).grid(
|
||||
row=0, column=1, padx=8, pady=8)
|
||||
|
||||
# ── Hotkey ───────────────────────────────────────────────────────────
|
||||
def _start_hotkey(self):
|
||||
if self._hotkey:
|
||||
self._hotkey.stop()
|
||||
self._hotkey = HotkeyListener(
|
||||
self.settings["hotkey"],
|
||||
on_press_cb=self._hotkey_pressed,
|
||||
on_release_cb=self._hotkey_released,
|
||||
)
|
||||
self._hotkey.start()
|
||||
|
||||
def _hotkey_pressed(self):
|
||||
ui_event("status", ("recording", "🔴 Ich höre…", "#ef4444"))
|
||||
play_ding()
|
||||
start_recording()
|
||||
|
||||
def _hotkey_released(self):
|
||||
audio = stop_recording()
|
||||
ui_event("status", ("thinking", "💭 Denke…", "#3b82f6"))
|
||||
self._worker = threading.Thread(
|
||||
target=self._process, args=(audio,), daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def _process(self, audio):
|
||||
text = transcribe(audio,
|
||||
self.settings["whisper_model"],
|
||||
self.settings["language"])
|
||||
if not text:
|
||||
ui_event("status", ("idle", "Bereit", "#22c55e"))
|
||||
return
|
||||
|
||||
ui_event("log_user", text)
|
||||
screenshot = (capture_screen()
|
||||
if self.settings["screenshot_enabled"] else None)
|
||||
|
||||
ui_event("status", ("thinking", "💭 Denke…", "#3b82f6"))
|
||||
response, tool_calls = ask_agent(text, screenshot, self.settings)
|
||||
|
||||
for tc in tool_calls:
|
||||
ui_event("log_tool", tc)
|
||||
|
||||
ui_event("log_hermes", response)
|
||||
ui_event("status", ("speaking", "🔊 Spricht…", "#a855f7"))
|
||||
speak(response, self.settings["tts_voice"], self.settings["tts_rate"])
|
||||
ui_event("status", ("idle", "Bereit", "#22c55e"))
|
||||
|
||||
# ── Queue-Polling ────────────────────────────────────────────────────
|
||||
def _poll_queue(self):
|
||||
while not _ui_queue.empty():
|
||||
event, data = _ui_queue.get_nowait()
|
||||
if event == "status":
|
||||
_, label, color = data
|
||||
self._status_label.configure(text=label)
|
||||
self._status_dot.configure(text_color=color)
|
||||
elif event == "log_user":
|
||||
self._append_log(f"Du: {data}")
|
||||
elif event == "log_hermes":
|
||||
self._append_log(f"Hermes: {data}\n")
|
||||
self.after(80, self._poll_queue)
|
||||
|
||||
def _append_log(self, text: str):
|
||||
self._log.configure(state="normal")
|
||||
self._log.insert("end", text + "\n")
|
||||
self._log.see("end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
# ── Settings Dialog ──────────────────────────────────────────────────
|
||||
def _open_settings(self):
|
||||
SettingsWindow(self)
|
||||
|
||||
def _toggle_screenshot(self):
|
||||
self.settings["screenshot_enabled"] = self._screenshot_var.get()
|
||||
cfg.save(self.settings)
|
||||
|
||||
def apply_settings(self, new_settings: dict):
|
||||
self.settings = new_settings
|
||||
cfg.save(new_settings)
|
||||
self._hotkey.update_key(new_settings["hotkey"])
|
||||
self._hotkey_label.configure(
|
||||
text=f"[ {str_to_display(new_settings['hotkey'])} ] gedrückt halten")
|
||||
self._screenshot_var.set(new_settings["screenshot_enabled"])
|
||||
|
||||
def _on_close(self):
|
||||
if self._hotkey:
|
||||
self._hotkey.stop()
|
||||
stop_speaking()
|
||||
self.destroy()
|
||||
|
||||
|
||||
# ── Settings Window ───────────────────────────────────────────────────────
|
||||
class SettingsWindow(ctk.CTkToplevel):
|
||||
def __init__(self, parent: HermesApp):
|
||||
super().__init__(parent)
|
||||
self._parent = parent
|
||||
self.title("Einstellungen")
|
||||
self.geometry("420x520")
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
self._s = dict(parent.settings)
|
||||
self._capturing = False
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
row = 0
|
||||
|
||||
def label(text):
|
||||
nonlocal row
|
||||
ctk.CTkLabel(self, text=text, anchor="w").grid(
|
||||
row=row, column=0, padx=16, pady=6, sticky="w")
|
||||
|
||||
def entry(key, width=240):
|
||||
nonlocal row
|
||||
var = ctk.StringVar(value=str(self._s.get(key, "")))
|
||||
e = ctk.CTkEntry(self, textvariable=var, width=width)
|
||||
e.grid(row=row, column=1, padx=16, pady=6, sticky="ew")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
def dropdown(key, options, width=200):
|
||||
nonlocal row
|
||||
var = ctk.StringVar(value=str(self._s.get(key, options[0])))
|
||||
dd = ctk.CTkOptionMenu(self, values=options, variable=var, width=width)
|
||||
dd.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
def toggle(key):
|
||||
nonlocal row
|
||||
var = ctk.BooleanVar(value=bool(self._s.get(key, False)))
|
||||
sw = ctk.CTkSwitch(self, text="", variable=var)
|
||||
sw.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
row += 1
|
||||
return var
|
||||
|
||||
label("MC2 URL")
|
||||
self._url = entry("mc2_url")
|
||||
|
||||
label("Hotkey")
|
||||
self._hotkey_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self._hotkey_frame.grid(row=row, column=1, padx=16, pady=6, sticky="w")
|
||||
self._hotkey_display = ctk.CTkLabel(
|
||||
self._hotkey_frame,
|
||||
text=str_to_display(self._s.get("hotkey", "ctrl_r")),
|
||||
width=100,
|
||||
)
|
||||
self._hotkey_display.grid(row=0, column=0, padx=(0, 8))
|
||||
ctk.CTkButton(self._hotkey_frame, text="Aufnehmen", width=90,
|
||||
command=self._capture_hotkey).grid(row=0, column=1)
|
||||
row += 1
|
||||
|
||||
label("TTS Stimme")
|
||||
self._voice = dropdown("tts_voice", [
|
||||
"de-DE-SeraphinaMultilingualNeural",
|
||||
"de-DE-AmalaNeural",
|
||||
"de-DE-KatjaNeural",
|
||||
"de-DE-KillianNeural",
|
||||
"de-DE-ConradNeural",
|
||||
"en-US-AriaNeural",
|
||||
"en-US-JennyNeural",
|
||||
])
|
||||
|
||||
label("Whisper Modell")
|
||||
self._whisper = dropdown("whisper_model",
|
||||
["tiny", "base", "small", "medium"])
|
||||
|
||||
label("Sprache")
|
||||
self._lang = dropdown("language",
|
||||
["de", "en", "auto"])
|
||||
|
||||
label("Screenshot")
|
||||
self._screenshot = toggle("screenshot_enabled")
|
||||
|
||||
label("Monitor")
|
||||
self._monitor = dropdown("screenshot_monitor", ["1", "2", "3"])
|
||||
|
||||
label("Max. Tokens")
|
||||
self._tokens = entry("max_response_tokens", width=100)
|
||||
|
||||
row += 1
|
||||
ctk.CTkButton(self, text="Speichern", command=self._save).grid(
|
||||
row=row, column=0, columnspan=2, pady=16, padx=16, sticky="ew")
|
||||
|
||||
def _capture_hotkey(self):
|
||||
self._hotkey_display.configure(text="Taste drücken…")
|
||||
|
||||
def on_key(key_str):
|
||||
self._s["hotkey"] = key_str
|
||||
self.after(0, lambda: self._hotkey_display.configure(
|
||||
text=str_to_display(key_str)))
|
||||
|
||||
KeyCapturer(on_key).start()
|
||||
|
||||
def _save(self):
|
||||
self._s["mc2_url"] = self._url.get().strip()
|
||||
self._s["tts_voice"] = self._voice.get()
|
||||
self._s["whisper_model"] = self._whisper.get()
|
||||
self._s["language"] = self._lang.get()
|
||||
self._s["screenshot_enabled"] = self._screenshot.get()
|
||||
self._s["screenshot_monitor"] = int(self._monitor.get())
|
||||
try:
|
||||
self._s["max_response_tokens"] = int(self._tokens.get())
|
||||
except ValueError:
|
||||
pass
|
||||
self._parent.apply_settings(self._s)
|
||||
self.destroy()
|
||||
|
||||
|
||||
# ── Entry Point ───────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
app = HermesApp()
|
||||
app.mainloop()
|
||||
@@ -1,29 +0,0 @@
|
||||
# Hermes Voice Client — Dependencies
|
||||
# Installation: pip install -r requirements.txt
|
||||
|
||||
# Speech-to-Text (lokal auf CPU)
|
||||
faster-whisper>=1.0.0
|
||||
|
||||
# Audio I/O
|
||||
sounddevice>=0.4.6
|
||||
numpy>=1.24.0
|
||||
scipy>=1.11.0
|
||||
|
||||
# Text-to-Speech
|
||||
edge-tts>=6.1.9
|
||||
|
||||
# Screen Capture
|
||||
mss>=9.0.1
|
||||
Pillow>=10.0.0
|
||||
|
||||
# HTTP Client
|
||||
httpx>=0.27.0
|
||||
|
||||
# Audio Playback
|
||||
pygame>=2.5.0
|
||||
|
||||
# GUI
|
||||
customtkinter>=5.2.0
|
||||
|
||||
# Globaler Hotkey
|
||||
pynput>=1.7.6
|
||||
@@ -1,19 +0,0 @@
|
||||
import base64
|
||||
import io
|
||||
import mss
|
||||
from PIL import Image
|
||||
from config import SCREENSHOT_MONITOR, SCREENSHOT_WIDTH, SCREENSHOT_HEIGHT
|
||||
|
||||
|
||||
def capture_screen() -> str:
|
||||
"""Screenshot des primären Monitors als base64-JPEG."""
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[SCREENSHOT_MONITOR]
|
||||
raw = sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
||||
|
||||
img = img.resize((SCREENSHOT_WIDTH, SCREENSHOT_HEIGHT), Image.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=80)
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
@@ -1,44 +0,0 @@
|
||||
@echo off
|
||||
:: Immer aus dem eigenen Verzeichnis laufen
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ========================================
|
||||
echo Hermes Voice Client -- Setup
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Python pruefen
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [FEHLER] Python nicht gefunden. Bitte Python 3.11+ installieren.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Venv anlegen falls nicht vorhanden
|
||||
if not exist ".venv" (
|
||||
echo [1/3] Erstelle virtuelle Umgebung...
|
||||
python -m venv .venv
|
||||
)
|
||||
|
||||
:: Dependencies installieren
|
||||
echo [2/3] Installiere Dependencies...
|
||||
.venv\Scripts\pip install --upgrade pip -q
|
||||
.venv\Scripts\pip install -r requirements.txt
|
||||
|
||||
:: Kurz-Check
|
||||
echo [3/3] Pruefe Konfiguration...
|
||||
.venv\Scripts\python -c "import customtkinter; import pynput; print('GUI + Hotkey OK')"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Setup abgeschlossen!
|
||||
echo.
|
||||
echo Naechste Schritte:
|
||||
echo 1. start.bat ausfuehren
|
||||
echo 2. Einstellungen (Zahnrad) pruefen:
|
||||
echo - MC2 URL (Standard: 192.168.178.151:9001)
|
||||
echo - Hotkey (Standard: Rechte Strg)
|
||||
echo Optional: build.bat fuer .exe-Kompilierung
|
||||
echo ========================================
|
||||
pause
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
:: Hermes Voice Client starten (kein Konsolenfenster im Hintergrund)
|
||||
cd /d "%~dp0"
|
||||
start "" .venv\Scripts\pythonw.exe main.py
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
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,7 +0,0 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
*.log
|
||||
# Avatar-VRM ist gross + Lizenz/Redistribution -> nicht committen (lokal/Box vorhalten)
|
||||
src/renderer/public/avatar.vrm
|
||||
src/renderer/public/vad/
|
||||
@@ -1,8 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
curl -s -m 5 http://127.0.0.1:8130/health > lucy_check.log 2>&1
|
||||
echo.>> lucy_check.log
|
||||
echo --- electron/vite/pocket laeuft? --->> lucy_check.log
|
||||
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pocket_server:app|electron-vite|lucy-desktop' } | ForEach-Object { $_.Name + ' ' + $_.ProcessId }" >> lucy_check.log 2>&1
|
||||
type lucy_check.log
|
||||
@@ -1,13 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-desktop"
|
||||
echo Beende haengende Versuche (ohne mich selbst)...
|
||||
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'electron.exe' -or $_.CommandLine -match 'electron-vite|pocket_server:app|start-lucy' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"
|
||||
timeout /t 2 >nul
|
||||
echo Starte 'npm run dev' mit Log-Capture...
|
||||
start /b "" cmd /c "npm run dev > dev.log 2>&1"
|
||||
timeout /t 22 >nul
|
||||
echo === dev.log ===
|
||||
type dev.log
|
||||
echo.
|
||||
echo (laeuft im Hintergrund weiter, falls erfolgreich)
|
||||
@@ -1,12 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
REM Hygiene: Recon-Log mit Token entfernen
|
||||
del /q "F:\Coding Stuff\mission-control-2\box_recon.log" 2>nul
|
||||
echo Beende laufende Lucy-Instanz (pocket_server :8130 + Electron/Vite)...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pocket_server:app|lucy-desktop|electron-vite' } | ForEach-Object { Write-Output ('kill ' + $_.ProcessId + ' ' + $_.Name); Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
timeout /t 2 >nul
|
||||
echo.
|
||||
echo Starte Lucy neu (frischer Voice-Server + Client mit erweitertem Umlaut-Fix)...
|
||||
start "" "F:\Coding Stuff\mission-control-2\client\lucy-desktop\start-lucy.cmd"
|
||||
timeout /t 3 >nul
|
||||
echo Fertig.
|
||||
@@ -1,7 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ws=New-Object -ComObject WScript.Shell; $p=Join-Path ([Environment]::GetFolderPath('Desktop')) 'Lucy.lnk'; $l=$ws.CreateShortcut($p); $l.TargetPath='F:\Coding Stuff\mission-control-2\client\lucy-desktop\start-lucy.cmd'; $l.WorkingDirectory='F:\Coding Stuff\mission-control-2\client\lucy-desktop'; $l.IconLocation='F:\Coding Stuff\mission-control-2\client\lucy-desktop\lucy.ico'; $l.Description='Lucy - lokaler Sprach-Companion starten'; $l.Save(); Write-Output ('OK: ' + $p)" > lucy_shortcut.log 2>&1
|
||||
type lucy_shortcut.log
|
||||
echo.
|
||||
echo Fertig - "Lucy" liegt jetzt auf dem Desktop.
|
||||
pause
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from "electron-vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { resolve } from "path"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: { rollupOptions: { input: { index: resolve(__dirname, "src/main/index.ts") } } },
|
||||
},
|
||||
preload: {
|
||||
build: { rollupOptions: { input: { index: resolve(__dirname, "src/preload/index.ts") } } },
|
||||
},
|
||||
renderer: {
|
||||
root: resolve(__dirname, "src/renderer"),
|
||||
build: { rollupOptions: { input: { index: resolve(__dirname, "src/renderer/index.html") } } },
|
||||
// Silero-VAD-Assets (Worklet + ONNX + onnxruntime-WASM) liegen self-hosted in
|
||||
// src/renderer/public/vad/ (scripts/copy-vad-assets.mjs, läuft via npm postinstall) —
|
||||
// Pfade = baseAssetPath/onnxWASMBasePath in useVAD.ts.
|
||||
plugins: [react()],
|
||||
},
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Generated
-5389
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "lucy-desktop",
|
||||
"version": "0.1.0",
|
||||
"description": "Lucy — lokaler Sprach-Companion (Hermes-Hirn auf der Box, lokale Stimme auf der 9070 XT)",
|
||||
"main": "out/main/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"postinstall": "node scripts/copy-vad-assets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pixiv/three-vrm": "^3.4.0",
|
||||
"@pixiv/three-vrm-animation": "^3.5.4",
|
||||
"@react-three/drei": "^9.114.0",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.169.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.2.1",
|
||||
"electron-vite": "^3.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron": true,
|
||||
"esbuild": true
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Kopiert die Silero-VAD-Assets (Worklet + ONNX-Modell + onnxruntime-WASM) nach public/vad/,
|
||||
// damit die App sie self-hosted lädt (offlinefähig, kein CDN). Läuft via npm postinstall.
|
||||
// public/ wird von Vite in Dev UND Build verbatim ausgeliefert -> ein Pfad für beide Welten.
|
||||
import { copyFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const dest = join(root, "src", "renderer", "public", "vad")
|
||||
mkdirSync(dest, { recursive: true })
|
||||
|
||||
const jobs = [
|
||||
[join(root, "node_modules", "@ricky0123", "vad-web", "dist"), (f) => f === "vad.worklet.bundle.min.js" || f.endsWith(".onnx")],
|
||||
[join(root, "node_modules", "onnxruntime-web", "dist"), (f) => f.endsWith(".wasm") || f.endsWith(".mjs")],
|
||||
]
|
||||
|
||||
let n = 0
|
||||
for (const [src, match] of jobs) {
|
||||
for (const f of readdirSync(src)) {
|
||||
if (match(f)) { copyFileSync(join(src, f), join(dest, f)); n++ }
|
||||
}
|
||||
}
|
||||
console.log(`[vad-assets] ${n} Dateien nach ${dest} kopiert.`)
|
||||
@@ -1,242 +0,0 @@
|
||||
import { app, BrowserWindow, ipcMain, Tray, Menu, globalShortcut, nativeImage, desktopCapturer, shell, screen, session } from "electron"
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { join } from "path"
|
||||
|
||||
// Lucy — Main-Prozess. Spawnt die lokale Stimme (pocket-tts, :8130) + verwaltet Fenster/Tray/Hotkey.
|
||||
// Fenster ist frameless+transparent: eigene Titelleiste im Renderer. Zwei Modi: 'full' (Arbeitsfenster)
|
||||
// und 'overlay' (kleine schwebende Lucy, always-on-top). pocket-tts ist CPU-kühl -> kein /wake-/sleep.
|
||||
|
||||
const PORT = process.env.LUCY_TTS_PORT || "8130"
|
||||
const TTS = `http://127.0.0.1:${PORT}`
|
||||
const TTS_DIR = process.env.LUCY_TTS_DIR || join(app.getAppPath(), "..", "lucy-tts")
|
||||
const PY = process.env.LUCY_TTS_PY || join(TTS_DIR, "ptts-venv", "Scripts", "python.exe")
|
||||
const HOTKEY = process.env.LUCY_HOTKEY || "CommandOrControl+Shift+Space"
|
||||
|
||||
const FULL_SIZE = { width: 1100, height: 720 }
|
||||
const OVERLAY_SIZE = { width: 360, height: 480 }
|
||||
|
||||
let win: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let ttsProc: ChildProcess | null = null
|
||||
let mode: "full" | "overlay" = "full"
|
||||
let pinned = false
|
||||
let cursorTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// MateEngine-Idee: Lucy folgt dem Mauszeiger. Der Zeiger ist auch AUSSERHALB des Fensters (Geistmodus),
|
||||
// daher global pollen (screen.getCursorScreenPoint) und die Richtung relativ zu Lucys Kopf an den Renderer geben.
|
||||
function startCursorTracking() {
|
||||
if (cursorTimer) return
|
||||
cursorTimer = setInterval(() => {
|
||||
if (!win || win.isDestroyed() || !win.isVisible()) return
|
||||
const b = win.getBounds()
|
||||
const p = screen.getCursorScreenPoint()
|
||||
const cx = b.x + b.width / 2
|
||||
const cy = b.y + b.height * 0.38 // ungefaehre Kopfhoehe im Fenster
|
||||
win.webContents.send("lucy:cursor", { x: (p.x - cx) / Math.max(1, b.width), y: (p.y - cy) / Math.max(1, b.height) })
|
||||
// Auto-Ducken: im Overlay nach Nicht-Naehe an den Rand; bei Annaeherung an den Rest hervorholen
|
||||
if (mode === "overlay" && !pinned && !boundsAnim) {
|
||||
const now = Date.now()
|
||||
const inWin = p.x >= b.x && p.y >= b.y && p.x <= b.x + b.width && p.y <= b.y + b.height
|
||||
if (inWin) lastNear = now
|
||||
if (!tucked && now - lastNear > AUTO_TUCK_MS) tuckToEdge()
|
||||
else if (tucked) {
|
||||
const wa = screen.getDisplayNearestPoint({ x: b.x, y: b.y }).workArea
|
||||
if (p.x >= wa.x + wa.width - SLIVER - 12 && p.y >= b.y - 12 && p.y <= b.y + b.height + 12) { lastNear = now; untuck() }
|
||||
}
|
||||
}
|
||||
}, 40) // ~25 Hz reicht fuers Blickfolgen, kostet kaum Last
|
||||
}
|
||||
function stopCursorTracking() { if (cursorTimer) { clearInterval(cursorTimer); cursorTimer = null } }
|
||||
|
||||
// --- An den Rand ducken (MateEngine-Idee) ---
|
||||
type Rect = { x: number; y: number; width: number; height: number }
|
||||
let tucked = false
|
||||
let prevBounds: Rect | null = null
|
||||
let lastNear = Date.now()
|
||||
let boundsAnim: ReturnType<typeof setInterval> | null = null
|
||||
const SLIVER = 58 // sichtbarer Rest am Rand, wenn geduckt
|
||||
const AUTO_TUCK_MS = 15000 // nach so langer Nicht-Naehe im Overlay automatisch ducken
|
||||
|
||||
function animateBounds(target: Rect, after?: () => void) {
|
||||
if (!win) return
|
||||
if (boundsAnim) { clearInterval(boundsAnim); boundsAnim = null }
|
||||
const start = win.getBounds(); const t0 = Date.now(); const dur = 260
|
||||
boundsAnim = setInterval(() => {
|
||||
if (!win || win.isDestroyed()) { if (boundsAnim) clearInterval(boundsAnim); boundsAnim = null; return }
|
||||
const k = Math.min(1, (Date.now() - t0) / dur)
|
||||
const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2 // easeInOutQuad
|
||||
win.setBounds({
|
||||
x: Math.round(start.x + (target.x - start.x) * e),
|
||||
y: Math.round(start.y + (target.y - start.y) * e),
|
||||
width: target.width, height: target.height,
|
||||
})
|
||||
if (k >= 1) { if (boundsAnim) clearInterval(boundsAnim); boundsAnim = null; after?.() }
|
||||
}, 16)
|
||||
}
|
||||
function tuckToEdge() {
|
||||
if (!win || mode !== "overlay" || tucked) return
|
||||
prevBounds = win.getBounds()
|
||||
const wa = screen.getDisplayNearestPoint({ x: prevBounds.x, y: prevBounds.y }).workArea
|
||||
animateBounds({ x: wa.x + wa.width - SLIVER, y: prevBounds.y, width: prevBounds.width, height: prevBounds.height })
|
||||
tucked = true; setTrayMenu()
|
||||
}
|
||||
function untuck() {
|
||||
if (!win || !tucked) return
|
||||
const t = prevBounds || win.getBounds()
|
||||
animateBounds({ x: t.x, y: t.y, width: t.width, height: t.height })
|
||||
tucked = false; setTrayMenu()
|
||||
}
|
||||
function toggleTuck() { if (tucked) untuck(); else tuckToEdge() }
|
||||
|
||||
async function ensureTtsServer() {
|
||||
try {
|
||||
const r = await fetch(`${TTS}/health`)
|
||||
if (r.ok) { console.log("[lucy] TTS-Server läuft bereits auf", TTS); return }
|
||||
} catch { /* selbst starten */ }
|
||||
console.log("[lucy] starte pocket_server:", PY, "(cwd:", TTS_DIR + ")")
|
||||
ttsProc = spawn(PY, ["-m", "uvicorn", "pocket_server:app", "--host", "127.0.0.1", "--port", PORT],
|
||||
{ cwd: TTS_DIR, windowsHide: true, env: { ...process.env } })
|
||||
ttsProc.stdout?.on("data", (d) => console.log("[tts]", d.toString().trimEnd()))
|
||||
ttsProc.stderr?.on("data", (d) => console.log("[tts]", d.toString().trimEnd()))
|
||||
ttsProc.on("error", (e) => console.error("[lucy] pocket_server-Spawn fehlgeschlagen:", e))
|
||||
ttsProc.on("exit", (code) => { console.log("[lucy] pocket_server beendet, code", code); ttsProc = null })
|
||||
}
|
||||
function stopTtsServer() {
|
||||
if (ttsProc && !ttsProc.killed) { try { ttsProc.kill() } catch { /* */ }; ttsProc = null }
|
||||
}
|
||||
|
||||
function applyMode(next: "full" | "overlay") {
|
||||
if (!win) return
|
||||
// beim Verlassen des Overlays ggf. Duck-Zustand zuruecksetzen (Position wiederherstellen)
|
||||
if (tucked && next !== "overlay") { tucked = false; if (prevBounds) win.setPosition(prevBounds.x, prevBounds.y) }
|
||||
mode = next
|
||||
const overlay = next === "overlay"
|
||||
const size = overlay ? OVERLAY_SIZE : FULL_SIZE
|
||||
win.setAlwaysOnTop(overlay || pinned, "floating")
|
||||
win.setSize(size.width, size.height, false)
|
||||
win.setResizable(!overlay)
|
||||
if (overlay) win.setMinimumSize(280, 360); else win.setMinimumSize(420, 420)
|
||||
win.webContents.send("lucy:mode", next)
|
||||
lastNear = Date.now(); setTrayMenu()
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: FULL_SIZE.width, height: FULL_SIZE.height, minWidth: 420, minHeight: 420,
|
||||
frame: false, transparent: true, backgroundColor: "#00000000", resizable: true,
|
||||
title: "Lucy", show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.mjs"),
|
||||
sandbox: false, webSecurity: false,
|
||||
},
|
||||
})
|
||||
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
else win.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
win.once("ready-to-show", () => win?.show())
|
||||
win.on("closed", () => { win = null })
|
||||
}
|
||||
|
||||
function showWin() {
|
||||
if (!win) { createWindow(); return }
|
||||
if (win.isMinimized()) win.restore()
|
||||
if (tucked) untuck()
|
||||
win.show(); win.focus()
|
||||
}
|
||||
|
||||
function lucyIcon() {
|
||||
// 16x16 Icon zur Laufzeit (silberblauer Kreis) -> sichtbar im Tray, ohne Asset-Datei
|
||||
const s = 16, buf = Buffer.alloc(s * s * 4)
|
||||
for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) {
|
||||
const i = (y * s + x) * 4
|
||||
const dx = x - 7.5, dy = y - 7.5, inside = dx * dx + dy * dy <= 56
|
||||
buf[i] = 0xfc; buf[i + 1] = 0xd3; buf[i + 2] = 0x7d; buf[i + 3] = inside ? 0xff : 0x00 // BGRA
|
||||
}
|
||||
return nativeImage.createFromBitmap(buf, { width: s, height: s })
|
||||
}
|
||||
|
||||
function setTrayMenu() {
|
||||
if (!tray) return
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Lucy zeigen", click: showWin },
|
||||
{ label: "Overlay-Modus", type: "checkbox", checked: mode === "overlay",
|
||||
click: (mi) => { applyMode(mi.checked ? "overlay" : "full"); showWin() } },
|
||||
{ label: tucked ? "Hervorholen" : "An den Rand ducken", enabled: mode === "overlay", click: toggleTuck },
|
||||
{ type: "separator" },
|
||||
{ label: "Beenden", click: () => { app.quit() } },
|
||||
]))
|
||||
}
|
||||
function buildTray() {
|
||||
tray = new Tray(lucyIcon())
|
||||
tray.setToolTip("Lucy")
|
||||
setTrayMenu()
|
||||
tray.on("click", showWin)
|
||||
}
|
||||
|
||||
function registerHotkey() {
|
||||
globalShortcut.unregisterAll()
|
||||
// Global: Sprach-Aufnahme an/aus (Toggle, da Global-Shortcuts kein key-up liefern)
|
||||
const ok = globalShortcut.register(HOTKEY, () => { showWin(); win?.webContents.send("lucy:hotkey") })
|
||||
if (!ok) console.error("[lucy] Global-Hotkey-Registrierung fehlgeschlagen:", HOTKEY)
|
||||
}
|
||||
|
||||
// ---- IPC vom Renderer (Titelleiste/Modus) ----
|
||||
ipcMain.handle("win:minimize", () => win?.minimize())
|
||||
ipcMain.handle("win:close", () => win?.hide()) // in den Tray, bleibt warm; echtes Beenden über Tray
|
||||
ipcMain.handle("win:setMode", (_e, m: "full" | "overlay") => { applyMode(m); return mode })
|
||||
ipcMain.handle("win:getMode", () => mode)
|
||||
ipcMain.handle("win:togglePin", () => { pinned = !pinned; win?.setAlwaysOnTop(pinned || mode === "overlay", "floating"); return pinned })
|
||||
|
||||
// Links im echten System-Browser oeffnen (nicht im App-Fenster); nur http(s) erlauben
|
||||
ipcMain.handle("open:external", (_e, url: string) => { if (/^https?:\/\//i.test(url)) void shell.openExternal(url) })
|
||||
|
||||
// Geistmodus (Overlay v2): Fenster durchklickbar machen -> Lucy "sitzt" auf dem Desktop, du arbeitest
|
||||
// dahinter weiter. forward:true liefert weiter Hover-Events an den Renderer, damit er ueber Lucys
|
||||
// Bedienelementen kurzzeitig wieder klickbar schalten kann.
|
||||
ipcMain.handle("win:setClickThrough", (_e, on: boolean) => {
|
||||
win?.setIgnoreMouseEvents(!!on, { forward: true }); return !!on
|
||||
})
|
||||
|
||||
// ---- Bildschirm-Sicht (Lucys Augen) ----
|
||||
const CAP = { width: 1280, height: 720 }
|
||||
ipcMain.handle("screen:capture", async () => {
|
||||
const sources = await desktopCapturer.getSources({ types: ["screen"], thumbnailSize: CAP })
|
||||
return sources.map((s) => s.thumbnail.toDataURL()) // ALLE Bildschirme (Multi-Monitor) als PNG-DataURLs
|
||||
})
|
||||
ipcMain.handle("screen:listWindows", async () => {
|
||||
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: { width: 0, height: 0 } })
|
||||
return sources.filter((s) => s.name && s.name !== "Lucy").map((s) => ({ id: s.id, name: s.name }))
|
||||
})
|
||||
ipcMain.handle("screen:captureWindow", async (_e, id: string) => {
|
||||
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: CAP })
|
||||
return sources.find((s) => s.id === id)?.thumbnail.toDataURL() ?? null
|
||||
})
|
||||
// Fuzzy-Fenstersuche: bester Treffer für einen im Gesagten erkannten App-/Fensternamen
|
||||
ipcMain.handle("screen:captureByName", async (_e, query: string) => {
|
||||
const q = query.toLowerCase().trim()
|
||||
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: CAP })
|
||||
const cand = sources.filter((s) => s.name && s.name !== "Lucy")
|
||||
const hit = cand.find((s) => s.name.toLowerCase().includes(q))
|
||||
|| cand.find((s) => q.split(/\s+/).some((w) => w.length > 2 && s.name.toLowerCase().includes(w)))
|
||||
return hit ? { name: hit.name, image: hit.thumbnail.toDataURL() } : null
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
void ensureTtsServer()
|
||||
createWindow()
|
||||
buildTray()
|
||||
registerHotkey()
|
||||
startCursorTracking()
|
||||
// Tanzen zur Musik: System-Audio (Loopback) fuer getDisplayMedia bereitstellen, ohne System-Picker
|
||||
session.defaultSession.setDisplayMediaRequestHandler((_req, callback) => {
|
||||
desktopCapturer.getSources({ types: ["screen"] })
|
||||
.then((sources) => callback({ video: sources[0], audio: "loopback" }))
|
||||
.catch(() => { try { callback({}) } catch { /* */ } })
|
||||
}, { useSystemPicker: false })
|
||||
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() })
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => { /* im Tray weiterlaufen lassen; Beenden nur über Tray/Quit */ })
|
||||
app.on("before-quit", () => {
|
||||
if (boundsAnim) clearInterval(boundsAnim)
|
||||
stopCursorTracking(); stopTtsServer(); globalShortcut.unregisterAll(); tray?.destroy()
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
// Schmale Bruecke Renderer -> Main: Fenster-Chrome (frameless), Modus-Umschaltung, Tray/Hotkey-Events.
|
||||
contextBridge.exposeInMainWorld("lucy", {
|
||||
minimize: () => ipcRenderer.invoke("win:minimize"),
|
||||
close: () => ipcRenderer.invoke("win:close"),
|
||||
setMode: (m: "full" | "overlay") => ipcRenderer.invoke("win:setMode", m),
|
||||
getMode: () => ipcRenderer.invoke("win:getMode"),
|
||||
togglePin: () => ipcRenderer.invoke("win:togglePin"),
|
||||
// Bildschirm-Sicht
|
||||
captureScreen: () => ipcRenderer.invoke("screen:capture"), // alle Monitore -> string[]
|
||||
listWindows: () => ipcRenderer.invoke("screen:listWindows"),
|
||||
captureWindow: (id: string) => ipcRenderer.invoke("screen:captureWindow", id),
|
||||
captureByName: (q: string) => ipcRenderer.invoke("screen:captureByName", q),
|
||||
openExternal: (url: string) => ipcRenderer.invoke("open:external", url), // Link im System-Browser
|
||||
setClickThrough: (on: boolean) => ipcRenderer.invoke("win:setClickThrough", on), // Geistmodus (Overlay v2)
|
||||
// Events aus dem Main-Prozess (Tray/Hotkey/Modus)
|
||||
onMode: (cb: (m: "full" | "overlay") => void) => {
|
||||
const h = (_e: unknown, m: "full" | "overlay") => cb(m)
|
||||
ipcRenderer.on("lucy:mode", h)
|
||||
return () => ipcRenderer.removeListener("lucy:mode", h)
|
||||
},
|
||||
onHotkey: (cb: () => void) => {
|
||||
const h = () => cb()
|
||||
ipcRenderer.on("lucy:hotkey", h)
|
||||
return () => ipcRenderer.removeListener("lucy:hotkey", h)
|
||||
},
|
||||
// Mauszeiger-Position (relativ zu Lucys Kopf) -> Blickfolgen
|
||||
onCursor: (cb: (p: { x: number; y: number }) => void) => {
|
||||
const h = (_e: unknown, p: { x: number; y: number }) => cb(p)
|
||||
ipcRenderer.on("lucy:cursor", h)
|
||||
return () => ipcRenderer.removeListener("lucy:cursor", h)
|
||||
},
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:8130 http://192.168.178.151:9001 ws://localhost:*; media-src 'self' blob: data: http://127.0.0.1:8130; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline'" />
|
||||
<title>Lucy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +0,0 @@
|
||||
# VRMA-Animationen — Quelle & Lizenz
|
||||
|
||||
Die `.vrma`-Bewegungs-Clips in diesem Ordner stammen aus:
|
||||
|
||||
**tk256ailab/vrm-viewer** — https://github.com/tk256ailab/vrm-viewer
|
||||
Lizenz: **MIT** · Copyright (c) 2025 TK256
|
||||
|
||||
Die MIT-Lizenz erlaubt Nutzung, Modifikation und Weitergabe; der Copyright-
|
||||
Hinweis ist beizubehalten (= diese Datei).
|
||||
|
||||
Genutzte Clips: Relax, Thinking, LookAround, Surprised, Sad, Clapping, Goodbye, Blush.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user